From 159a9fae08281b21bc9e604609f91f7c9c9c80ad Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sat, 29 Aug 2026 22:30:37 +0800 Subject: [PATCH 1/7] feat: add notification outbox and todo center --- README.md | 6 +- .../mock-server.mjs | 543 +++++++-- .../src/Main.tsx | 10 + .../src/components/AppHeader.tsx | 16 +- .../settings/NotificationSettingsSection.tsx | 313 +++++ .../settings/SettingsNavigation.tsx | 11 +- .../src/i18n/locales/en/common.json | 1 + .../src/i18n/locales/en/settings.json | 52 + .../src/i18n/locales/en/todos.json | 51 + .../src/i18n/locales/ja/common.json | 1 + .../src/i18n/locales/ja/settings.json | 52 + .../src/i18n/locales/ja/todos.json | 50 + .../src/i18n/locales/zh-CN/common.json | 1 + .../src/i18n/locales/zh-CN/settings.json | 52 + .../src/i18n/locales/zh-CN/todos.json | 50 + .../src/i18n/resources.ts | 6 + .../src/notifications/api.ts | 4 + .../src/notifications/hooks.ts | 11 + .../src/notifications/types.ts | 10 + .../src/pages/IncidentsPage.tsx | 46 +- .../src/pages/MetadataReviewPage.tsx | 26 +- .../src/pages/SettingsPage.tsx | 8 + .../src/pages/TodoPage.tsx | 311 +++++ .../src/settings/systemTypes.ts | 27 + .../src/todos/api.ts | 13 + .../src/todos/hooks.ts | 15 + .../src/todos/types.ts | 30 + .../INotificationOutboxRepository.cs | 62 + .../DataRepository/ITodoRepository.cs | 52 + .../Notifications/NotificationEvent.cs | 41 + .../CompleteDownloadBackgroundServiceTests.cs | 10 +- .../IncidentReporterTests.cs | 37 + .../NotificationPipelineTests.cs | 226 ++++ .../RuntimeSettingsServiceTests.cs | 45 + .../SyncFeedTests.cs | 21 +- .../TodosControllerTests.cs | 49 + .../Configuration/RuntimeSettingsModels.cs | 99 +- .../Configuration/RuntimeSettingsService.cs | 8 +- .../Controllers/AnimationInfoController.cs | 18 +- .../External/AppJsonSerializerContext.cs | 5 + .../External/ApplicationSettings.cs | 23 +- .../Controllers/External/Notifications.cs | 13 + .../Controllers/External/Todos.cs | 39 + .../Controllers/NotificationsController.cs | 60 + .../Controllers/SettingsController.cs | 37 +- .../Controllers/TodosController.cs | 95 ++ ..._AddNotificationsAndTodoCenter.Designer.cs | 1071 +++++++++++++++++ ...829135234_AddNotificationsAndTodoCenter.cs | 74 ++ .../ApplicationContextModelSnapshot.cs | 88 ++ .../Models/ApplicationContext.cs | 50 + .../Models/NotificationOutboxMessage.cs | 22 + .../Models/TodoItemState.cs | 9 + SecondDimensionWatcherReDive/Program.cs | 16 + .../NotificationOutboxRepository.cs | 157 +++ .../Repositories/TodoRepository.cs | 173 +++ .../CompleteDownloadBackgroundService.cs | 14 +- .../Services/InferAnimationMetadata.cs | 24 +- .../Services/SyncFeed.cs | 46 +- .../Utils/Incidents/IncidentReporter.cs | 22 +- .../NotificationDeliveryBackgroundService.cs | 201 ++++ .../Notifications/NotificationPublisher.cs | 88 ++ .../appsettings.example.json | 15 + packaging/appsettings.yml | 12 + 63 files changed, 4616 insertions(+), 122 deletions(-) create mode 100644 SecondDimensionWatcherReDive.Client/src/components/settings/NotificationSettingsSection.tsx create mode 100644 SecondDimensionWatcherReDive.Client/src/i18n/locales/en/todos.json create mode 100644 SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/todos.json create mode 100644 SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/todos.json create mode 100644 SecondDimensionWatcherReDive.Client/src/notifications/api.ts create mode 100644 SecondDimensionWatcherReDive.Client/src/notifications/hooks.ts create mode 100644 SecondDimensionWatcherReDive.Client/src/notifications/types.ts create mode 100644 SecondDimensionWatcherReDive.Client/src/pages/TodoPage.tsx create mode 100644 SecondDimensionWatcherReDive.Client/src/todos/api.ts create mode 100644 SecondDimensionWatcherReDive.Client/src/todos/hooks.ts create mode 100644 SecondDimensionWatcherReDive.Client/src/todos/types.ts create mode 100644 SecondDimensionWatcherReDive.Framework/DataRepository/INotificationOutboxRepository.cs create mode 100644 SecondDimensionWatcherReDive.Framework/DataRepository/ITodoRepository.cs create mode 100644 SecondDimensionWatcherReDive.Framework/Notifications/NotificationEvent.cs create mode 100644 SecondDimensionWatcherReDive.Test/NotificationPipelineTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/TodosControllerTests.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/External/Notifications.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/External/Todos.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/NotificationsController.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/TodosController.cs create mode 100644 SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.Designer.cs create mode 100644 SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.cs create mode 100644 SecondDimensionWatcherReDive/Models/NotificationOutboxMessage.cs create mode 100644 SecondDimensionWatcherReDive/Models/TodoItemState.cs create mode 100644 SecondDimensionWatcherReDive/Repositories/NotificationOutboxRepository.cs create mode 100644 SecondDimensionWatcherReDive/Repositories/TodoRepository.cs create mode 100644 SecondDimensionWatcherReDive/Utils/Notifications/NotificationDeliveryBackgroundService.cs create mode 100644 SecondDimensionWatcherReDive/Utils/Notifications/NotificationPublisher.cs diff --git a/README.md b/README.md index a55a5f46..7e8e90aa 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/HCGStudio/SecondDimensionWat | `AI:Anthropic:ApiKey` / `BaseUrl` / `Model` / `MaxTokens` / `ApiVersion` | Anthropic 端点 | | `AI:CodexAppServer:Endpoint` / `BearerToken` / `Model` / `PermissionProfile` / `TimeoutSeconds` | Codex app-server WebSocket 端点;空模型使用服务端默认模型;权限配置默认 `:read-only`,也可填写管理员定义的 profile id | | `Inference:RateLimitDelayMs` | 推断 API 调用最小间隔(毫秒,默认 1000) | +| `Notifications:Webhook:Enabled` / `Url` | 通用 Webhook 通知渠道;完整 URL 按敏感配置处理,建议从网页设置中保存 | +| `Notifications:Events` / `QuietHours` | 允许投递的领域事件与可选免打扰时段;事件先写入持久化 Outbox,再异步重试投递 | | `Valkey:ConnectionString` | Valkey / Redis 连接(可选;为空则使用内存缓存) | > 使用现有媒体库导入前,必须至少配置一个 `MediaLibrary:AllowedRoots`。导入源必须位于白名单内,且不能与 `FileStore:Local` 管理的下载目录相同、互为父目录或以其他方式重叠。导入与后续对账只会修改数据库中的媒体记录和虚拟路径映射;系统绝不会移动、重命名或删除原文件。短暂缺失的条目会先撤下映射并保留观看/审核记录,超过 `MissingGracePeriod`(默认 24 小时)后才清理数据库记录。 @@ -112,7 +114,9 @@ bash <(curl -fsSL https://raw.githubusercontent.com/HCGStudio/SecondDimensionWat ### 网页运行时设置 -登录后打开「设置」,可修改 AI 执行模式与 Provider、AI/TMDB 密钥、qBittorrent、媒体库扫描、异常检测和 NFS。保存值存入 PostgreSQL,并覆盖部署文件或环境变量中的默认值;密钥和密码使用持久化 Data Protection 密钥环加密,API 不会回显明文。可对单个敏感项选择保留、替换、清除或恢复部署默认值。 +登录后打开「设置」,可修改 AI 执行模式与 Provider、AI/TMDB 密钥、qBittorrent、媒体库扫描、异常检测、通知和 NFS。保存值存入 PostgreSQL,并覆盖部署文件或环境变量中的默认值;密钥、密码和 Webhook URL 使用持久化 Data Protection 密钥环加密,API 不会回显明文。可对单个敏感项选择保留、替换、清除或恢复部署默认值。 + +通知先以唯一去重键写入 PostgreSQL Outbox,再由后台服务投递。Webhook 请求带有稳定的 `X-SDW-Event-Id`,接收端应以此做幂等;5xx、408、429 和网络错误会指数退避重试,永久失败可在「设置 → 通知」查看,且任何投递失败都不会回滚订阅、下载、推断或异常处理。顶栏「待办中心」会按风险汇总待确认下载、异常、低置信度/失败元数据和磁盘预警,并支持已读、稍后提醒及无副作用批量操作。 数据库连接、JWT、下载存储根目录、登录密码文件、CORS 和 Valkey 仍属于启动/基础设施配置,不允许从网页修改。NFS 监听地址、端口和启用状态会保存,但需要重启应用才能切换;其余上述设置对后续请求和新任务热生效。后台定时任务的间隔变更不会中断已经开始的等待,最迟会在当前等待周期结束后采用新值。 diff --git a/SecondDimensionWatcherReDive.Client/mock-server.mjs b/SecondDimensionWatcherReDive.Client/mock-server.mjs index f5ee4d4d..19207aca 100644 --- a/SecondDimensionWatcherReDive.Client/mock-server.mjs +++ b/SecondDimensionWatcherReDive.Client/mock-server.mjs @@ -692,50 +692,147 @@ let feeds = [ // Per-feed subscription automation policies and historical releases. const POLICY_CREATED_AT = new Date(Date.now() - 86400_000).toISOString(); const subscriptionPolicies = new Map([ - [feeds[0].id, { - feedId: feeds[0].id, - subtitleGroups: ["LoliHouse", "喵萌奶茶屋"], - resolutions: ["1080p"], - codecs: ["HEVC"], - languages: ["简中", "繁中"], - minSizeBytes: 300 * 1024 * 1024, - maxSizeBytes: 1600 * 1024 * 1024, - excludedKeywords: ["合集", "NCOP"], - mode: "ManualConfirm", - createdAt: POLICY_CREATED_AT, - updatedAt: new Date(Date.now() - 3600_000 * 8).toISOString(), - }], - [feeds[1].id, { - feedId: feeds[1].id, - subtitleGroups: ["ANi", "SubsPlease"], - resolutions: ["1080p"], - codecs: [], - languages: ["繁中"], - minSizeBytes: null, - maxSizeBytes: 1400 * 1024 * 1024, - excludedKeywords: ["预告"], - mode: "AutoDownload", - createdAt: POLICY_CREATED_AT, - updatedAt: new Date(Date.now() - 3600_000 * 3).toISOString(), - }], + [ + feeds[0].id, + { + feedId: feeds[0].id, + subtitleGroups: ["LoliHouse", "喵萌奶茶屋"], + resolutions: ["1080p"], + codecs: ["HEVC"], + languages: ["简中", "繁中"], + minSizeBytes: 300 * 1024 * 1024, + maxSizeBytes: 1600 * 1024 * 1024, + excludedKeywords: ["合集", "NCOP"], + mode: "ManualConfirm", + createdAt: POLICY_CREATED_AT, + updatedAt: new Date(Date.now() - 3600_000 * 8).toISOString(), + }, + ], + [ + feeds[1].id, + { + feedId: feeds[1].id, + subtitleGroups: ["ANi", "SubsPlease"], + resolutions: ["1080p"], + codecs: [], + languages: ["繁中"], + minSizeBytes: null, + maxSizeBytes: 1400 * 1024 * 1024, + excludedKeywords: ["预告"], + mode: "AutoDownload", + createdAt: POLICY_CREATED_AT, + updatedAt: new Date(Date.now() - 3600_000 * 3).toISOString(), + }, + ], ]); const RELEASE_HISTORY_BY_FEED = new Map([ - [feeds[0].id, [ - { id: randomUUID(), title: "[LoliHouse] 葬送的芙莉莲 - 28 [WebRip 1080p HEVC-10bit AAC][简繁内封]", publishedAt: new Date(Date.now() - 3600_000 * 5).toISOString(), sizeBytes: 824 * 1024 * 1024, subtitleGroup: "LoliHouse", resolution: "1080p", codec: "HEVC", languages: ["简中", "繁中"] }, - { id: randomUUID(), title: "[ANi] 葬送的芙莉莲 - 28 [1080P][繁日双语]", publishedAt: new Date(Date.now() - 3600_000 * 12).toISOString(), sizeBytes: 516 * 1024 * 1024, subtitleGroup: "ANi", resolution: "1080p", codec: "AVC", languages: ["繁中", "日语"] }, - { id: randomUUID(), title: "[喵萌奶茶屋] 葬送的芙莉莲 01-28 合集 [1080p HEVC][简繁]", publishedAt: new Date(Date.now() - 86400_000).toISOString(), sizeBytes: 18.4 * 1024 * 1024 * 1024, subtitleGroup: "喵萌奶茶屋", resolution: "1080p", codec: "HEVC", languages: ["简中", "繁中"] }, - { id: randomUUID(), title: "[LoliHouse] 葬送的芙莉莲 - 27 [2160p HEVC][简繁]", publishedAt: new Date(Date.now() - 86400_000 * 2).toISOString(), sizeBytes: 2250 * 1024 * 1024, subtitleGroup: "LoliHouse", resolution: "2160p", codec: "HEVC", languages: ["简中", "繁中"] }, - ]], - [feeds[1].id, [ - { id: randomUUID(), title: "[ANi] 迷宫饭 - 24 [1080P][繁日双语]", publishedAt: new Date(Date.now() - 3600_000 * 7).toISOString(), sizeBytes: 612 * 1024 * 1024, subtitleGroup: "ANi", resolution: "1080p", codec: "AVC", languages: ["繁中", "日语"] }, - { id: randomUUID(), title: "[SubsPlease] Dungeon Meshi - 24 (1080p) [English]", publishedAt: new Date(Date.now() - 3600_000 * 18).toISOString(), sizeBytes: 1380 * 1024 * 1024, subtitleGroup: "SubsPlease", resolution: "1080p", codec: "AVC", languages: ["English"] }, - { id: randomUUID(), title: "[ANi] 迷宫饭 完结纪念预告 [1080P][繁中]", publishedAt: new Date(Date.now() - 86400_000 * 2).toISOString(), sizeBytes: 92 * 1024 * 1024, subtitleGroup: "ANi", resolution: "1080p", codec: "AVC", languages: ["繁中"] }, - ]], - [feeds[2].id, [ - { id: randomUUID(), title: "[LoliHouse] 药屋少女的呢喃 - 24 [WebRip 1080p HEVC][简繁]", publishedAt: new Date(Date.now() - 3600_000 * 10).toISOString(), sizeBytes: 745 * 1024 * 1024, subtitleGroup: "LoliHouse", resolution: "1080p", codec: "HEVC", languages: ["简中", "繁中"] }, - { id: randomUUID(), title: "[ANi] 药屋少女的呢喃 - 24 [720P][繁中]", publishedAt: new Date(Date.now() - 86400_000).toISOString(), sizeBytes: 324 * 1024 * 1024, subtitleGroup: "ANi", resolution: "720p", codec: "AVC", languages: ["繁中"] }, - ]], + [ + feeds[0].id, + [ + { + id: randomUUID(), + title: + "[LoliHouse] 葬送的芙莉莲 - 28 [WebRip 1080p HEVC-10bit AAC][简繁内封]", + publishedAt: new Date(Date.now() - 3600_000 * 5).toISOString(), + sizeBytes: 824 * 1024 * 1024, + subtitleGroup: "LoliHouse", + resolution: "1080p", + codec: "HEVC", + languages: ["简中", "繁中"], + }, + { + id: randomUUID(), + title: "[ANi] 葬送的芙莉莲 - 28 [1080P][繁日双语]", + publishedAt: new Date(Date.now() - 3600_000 * 12).toISOString(), + sizeBytes: 516 * 1024 * 1024, + subtitleGroup: "ANi", + resolution: "1080p", + codec: "AVC", + languages: ["繁中", "日语"], + }, + { + id: randomUUID(), + title: "[喵萌奶茶屋] 葬送的芙莉莲 01-28 合集 [1080p HEVC][简繁]", + publishedAt: new Date(Date.now() - 86400_000).toISOString(), + sizeBytes: 18.4 * 1024 * 1024 * 1024, + subtitleGroup: "喵萌奶茶屋", + resolution: "1080p", + codec: "HEVC", + languages: ["简中", "繁中"], + }, + { + id: randomUUID(), + title: "[LoliHouse] 葬送的芙莉莲 - 27 [2160p HEVC][简繁]", + publishedAt: new Date(Date.now() - 86400_000 * 2).toISOString(), + sizeBytes: 2250 * 1024 * 1024, + subtitleGroup: "LoliHouse", + resolution: "2160p", + codec: "HEVC", + languages: ["简中", "繁中"], + }, + ], + ], + [ + feeds[1].id, + [ + { + id: randomUUID(), + title: "[ANi] 迷宫饭 - 24 [1080P][繁日双语]", + publishedAt: new Date(Date.now() - 3600_000 * 7).toISOString(), + sizeBytes: 612 * 1024 * 1024, + subtitleGroup: "ANi", + resolution: "1080p", + codec: "AVC", + languages: ["繁中", "日语"], + }, + { + id: randomUUID(), + title: "[SubsPlease] Dungeon Meshi - 24 (1080p) [English]", + publishedAt: new Date(Date.now() - 3600_000 * 18).toISOString(), + sizeBytes: 1380 * 1024 * 1024, + subtitleGroup: "SubsPlease", + resolution: "1080p", + codec: "AVC", + languages: ["English"], + }, + { + id: randomUUID(), + title: "[ANi] 迷宫饭 完结纪念预告 [1080P][繁中]", + publishedAt: new Date(Date.now() - 86400_000 * 2).toISOString(), + sizeBytes: 92 * 1024 * 1024, + subtitleGroup: "ANi", + resolution: "1080p", + codec: "AVC", + languages: ["繁中"], + }, + ], + ], + [ + feeds[2].id, + [ + { + id: randomUUID(), + title: "[LoliHouse] 药屋少女的呢喃 - 24 [WebRip 1080p HEVC][简繁]", + publishedAt: new Date(Date.now() - 3600_000 * 10).toISOString(), + sizeBytes: 745 * 1024 * 1024, + subtitleGroup: "LoliHouse", + resolution: "1080p", + codec: "HEVC", + languages: ["简中", "繁中"], + }, + { + id: randomUUID(), + title: "[ANi] 药屋少女的呢喃 - 24 [720P][繁中]", + publishedAt: new Date(Date.now() - 86400_000).toISOString(), + sizeBytes: 324 * 1024 * 1024, + subtitleGroup: "ANi", + resolution: "720p", + codec: "AVC", + languages: ["繁中"], + }, + ], + ], ]); function simulatePolicy(feedId, policy) { @@ -755,28 +852,59 @@ function simulatePolicy(feedId, policy) { if (field === "resolution") { normalized = normalized.replace(/\s/g, ""); const aliases = { - "4K": "2160P", UHD: "2160P", "2160": "2160P", - "1440": "1440P", FHD: "1080P", "1080": "1080P", - HD: "720P", "720": "720P", "576": "576P", "480": "480P", + "4K": "2160P", + UHD: "2160P", + 2160: "2160P", + 1440: "1440P", + FHD: "1080P", + 1080: "1080P", + HD: "720P", + 720: "720P", + 576: "576P", + 480: "480P", }; return aliases[normalized] ?? normalized; } if (field === "codec") { normalized = normalized.replace(/[.\-\s]/g, ""); const aliases = { - H265: "HEVC", X265: "HEVC", H264: "AVC", X264: "AVC", + H265: "HEVC", + X265: "HEVC", + H264: "AVC", + X264: "AVC", }; return aliases[normalized] ?? normalized; } if (field === "languages") { normalized = normalized.replace(/[_\-\s]/g, ""); const aliases = { - CHS: "ZHHANS", SC: "ZHHANS", GB: "ZHHANS", ZHCN: "ZHHANS", - "简体": "ZHHANS", "简中": "ZHHANS", "簡中": "ZHHANS", "简体中文": "ZHHANS", - CHT: "ZHHANT", TC: "ZHHANT", BIG5: "ZHHANT", ZHTW: "ZHHANT", ZHHK: "ZHHANT", - "繁体": "ZHHANT", "繁體": "ZHHANT", "繁中": "ZHHANT", "繁體中文": "ZHHANT", - JPN: "JA", JAP: "JA", "日语": "JA", "日語": "JA", "日本語": "JA", JAPANESE: "JA", - ENG: "EN", "英语": "EN", "英語": "EN", ENGLISH: "EN", + CHS: "ZHHANS", + SC: "ZHHANS", + GB: "ZHHANS", + ZHCN: "ZHHANS", + 简体: "ZHHANS", + 简中: "ZHHANS", + 簡中: "ZHHANS", + 简体中文: "ZHHANS", + CHT: "ZHHANT", + TC: "ZHHANT", + BIG5: "ZHHANT", + ZHTW: "ZHHANT", + ZHHK: "ZHHANT", + 繁体: "ZHHANT", + 繁體: "ZHHANT", + 繁中: "ZHHANT", + 繁體中文: "ZHHANT", + JPN: "JA", + JAP: "JA", + 日语: "JA", + 日語: "JA", + 日本語: "JA", + JAPANESE: "JA", + ENG: "EN", + 英语: "EN", + 英語: "EN", + ENGLISH: "EN", }; return aliases[normalized] ?? normalized; } @@ -786,7 +914,13 @@ function simulatePolicy(feedId, policy) { const actual = actualValues.filter(Boolean); const expected = (expectedValues ?? []).filter(Boolean); if (expected.length === 0) { - return { field, passed: true, actual: actual.join(", ") || null, expected: null, message: "anyValueAllowed" }; + return { + field, + passed: true, + actual: actual.join(", ") || null, + expected: null, + message: "anyValueAllowed", + }; } const normalizedExpected = new Set( expected.map((value) => normalizeAllowedValue(field, value)), @@ -794,28 +928,47 @@ function simulatePolicy(feedId, policy) { const passed = actual.some((value) => normalizedExpected.has(normalizeAllowedValue(field, value)), ); - return { field, passed, actual: actual.join(", ") || null, expected: expected.join(", "), message: passed ? "allowedValueMatched" : "allowedValueMissed" }; + return { + field, + passed, + actual: actual.join(", ") || null, + expected: expected.join(", "), + message: passed ? "allowedValueMatched" : "allowedValueMissed", + }; }; const entries = history.map((item) => { const explanations = [ - checkAllowed("subtitleGroup", [item.subtitleGroup], policy.subtitleGroups), + checkAllowed( + "subtitleGroup", + [item.subtitleGroup], + policy.subtitleGroups, + ), checkAllowed("resolution", [item.resolution], policy.resolutions), checkAllowed("codec", [item.codec], policy.codecs), checkAllowed("languages", item.languages, policy.languages), ]; - const min = typeof policy.minSizeBytes === "number" ? policy.minSizeBytes : null; - const max = typeof policy.maxSizeBytes === "number" ? policy.maxSizeBytes : null; - const sizePassed = (min == null || item.sizeBytes >= min) && (max == null || item.sizeBytes <= max); + const min = + typeof policy.minSizeBytes === "number" ? policy.minSizeBytes : null; + const max = + typeof policy.maxSizeBytes === "number" ? policy.maxSizeBytes : null; + const sizePassed = + (min == null || item.sizeBytes >= min) && + (max == null || item.sizeBytes <= max); explanations.push({ field: "size", passed: sizePassed, actual: formatBytes(item.sizeBytes), - expected: min == null && max == null ? null : `${min == null ? "0 B" : formatBytes(min)} – ${max == null ? "∞" : formatBytes(max)}`, + expected: + min == null && max == null + ? null + : `${min == null ? "0 B" : formatBytes(min)} – ${max == null ? "∞" : formatBytes(max)}`, message: sizePassed ? "withinSizeRange" : "outsideSizeRange", }); const excluded = (policy.excludedKeywords ?? []).filter(Boolean); - const found = excluded.find((keyword) => item.title.toLowerCase().includes(keyword.toLowerCase())); + const found = excluded.find((keyword) => + item.title.toLowerCase().includes(keyword.toLowerCase()), + ); explanations.push({ field: "excludedKeywords", passed: !found, @@ -823,10 +976,21 @@ function simulatePolicy(feedId, policy) { expected: excluded.length > 0 ? excluded.join(", ") : null, message: found ? "excludedKeywordFound" : "noExcludedKeyword", }); - return { id: item.id, title: item.title, publishedAt: item.publishedAt, sizeBytes: item.sizeBytes, matched: explanations.every((reason) => reason.passed), explanations }; + return { + id: item.id, + title: item.title, + publishedAt: item.publishedAt, + sizeBytes: item.sizeBytes, + matched: explanations.every((reason) => reason.passed), + explanations, + }; }); - return { total: entries.length, matched: entries.filter((entry) => entry.matched).length, entries }; + return { + total: entries.length, + matched: entries.filter((entry) => entry.matched).length, + entries, + }; } // WebDAV access tokens @@ -901,6 +1065,22 @@ let systemSettings = { restartRequired: true, pendingRestart: false, }, + notifications: { + webhookEnabled: false, + events: [ + "releaseMatched", + "downloadPendingConfirmation", + "downloadCompleted", + "downloadFailed", + "incidentOpened", + "metadataNeedsReview", + "diskSpaceLow", + ], + quietHoursStart: null, + quietHoursEnd: null, + timeZoneId: "UTC", + webhookUrl: { isConfigured: false, source: "none" }, + }, }; const deploymentSecrets = { @@ -909,8 +1089,11 @@ const deploymentSecrets = { codex: { isConfigured: false, source: "none" }, tmdb: { isConfigured: true, source: "deployment" }, torrent: { isConfigured: false, source: "none" }, + webhook: { isConfigured: false, source: "none" }, }; +let notificationDeliveries = []; + function applySecretMutation(current, mutation, deploymentValue) { if (!mutation || mutation.operation === "keep") return current; if (mutation.operation === "set") { @@ -1052,7 +1235,9 @@ function playablePaths() { !entry.isDirectory && /\.(mkv|mp4|webm|avi|flv|wmv|mov|m4v|ts|m2ts)$/i.test(entry.fileName) ) { - paths.push(directory ? `${directory}/${entry.fileName}` : entry.fileName); + paths.push( + directory ? `${directory}/${entry.fileName}` : entry.fileName, + ); } } } @@ -1122,7 +1307,9 @@ function associatedSubtitles(animation, videoPath) { entry.fileName.toLowerCase().startsWith(stem.toLowerCase()), ) .map((entry) => { - const path = directory ? `${directory}/${entry.fileName}` : entry.fileName; + const path = directory + ? `${directory}/${entry.fileName}` + : entry.fileName; const language = entry.fileName.includes("zh-Hans") ? "zh-Hans" : entry.fileName.includes(".en.") @@ -1153,7 +1340,8 @@ if (finishedForPlayback[0]) { }); } const previousEpisode = finishedForPlayback.find( - (animation) => animation.animation?.tmdbId === "209867" && animation.episode === 27, + (animation) => + animation.animation?.tmdbId === "209867" && animation.episode === 27, ); if (previousEpisode) { const path = "Season 1/EP01.mp4"; @@ -1172,7 +1360,8 @@ let mockIncidents = [ type: "feedFailure", severity: "error", title: "Mikan RSS returned HTTP 503", - detail: "The feed could not be refreshed during the last three sync attempts.", + detail: + "The feed could not be refreshed during the last three sync attempts.", sourceId: feeds[0]?.id ?? null, detectedAt: new Date(Date.now() - 42 * 60_000).toISOString(), updatedAt: new Date(Date.now() - 12 * 60_000).toISOString(), @@ -1187,7 +1376,8 @@ let mockIncidents = [ type: "downloadStalled", severity: "warning", title: "Download has not progressed for 20 minutes", - detail: "No peers are currently available. Retry will reannounce the torrent.", + detail: + "No peers are currently available. Retry will reannounce the torrent.", sourceId: finishedForPlayback[1]?.id ?? null, detectedAt: new Date(Date.now() - 25 * 60_000).toISOString(), updatedAt: new Date(Date.now() - 5 * 60_000).toISOString(), @@ -1217,7 +1407,8 @@ let mockIncidents = [ type: "fileMappingFailure", severity: "error", title: "Downloaded files could not be mapped", - detail: "The download completed, but no playable video mapping was produced.", + detail: + "The download completed, but no playable video mapping was produced.", sourceId: finishedForPlayback[2]?.id ?? null, detectedAt: new Date(Date.now() - 2 * 3600_000).toISOString(), updatedAt: new Date(Date.now() - 2 * 3600_000).toISOString(), @@ -1316,6 +1507,63 @@ function vfsResolve(rawPath) { return { entry: match, isDirectory: false }; } +const mockTodoStates = new Map(); + +function currentMockTodos() { + const anime = [...animations.values()]; + const base = [ + anime[0] && { + key: `automation:${anime[0].id}`, + type: "ReleaseMatched", + priority: "Normal", + title: anime[0].title, + detail: "A notify-only subscription matched this release.", + deepLink: `/todo?focus=automation:${anime[0].id}`, + resourceId: anime[0].id, + occurredAt: anime[0].publishTime, + }, + anime[1] && { + key: `automation:${anime[1].id}`, + type: "DownloadPendingConfirmation", + priority: "High", + title: anime[1].title, + detail: "A matched release is waiting for download confirmation.", + deepLink: `/todo?focus=automation:${anime[1].id}`, + resourceId: anime[1].id, + occurredAt: anime[1].publishTime, + }, + ...mockIncidents + .filter((incident) => !incident.resolvedAt) + .map((incident) => ({ + key: `incident:${incident.id}`, + type: incident.type === "diskSpaceLow" ? "DiskSpaceLow" : "Incident", + priority: incident.severity === "critical" ? "Critical" : "High", + title: incident.title, + detail: incident.detail, + deepLink: + incident.type === "diskSpaceLow" + ? "/incidents?type=diskSpaceLow" + : `/incidents?focus=${incident.id}`, + resourceId: incident.id, + occurredAt: incident.detectedAt, + })), + ].filter(Boolean); + + return base + .map((item) => ({ + ...item, + readAt: mockTodoStates.get(item.key)?.readAt ?? null, + snoozedUntil: mockTodoStates.get(item.key)?.snoozedUntil ?? null, + })) + .sort((left, right) => { + const rank = { Normal: 0, High: 1, Critical: 2 }; + return ( + rank[right.priority] - rank[left.priority] || + new Date(right.occurredAt) - new Date(left.occurredAt) + ); + }); +} + // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- @@ -1502,6 +1750,21 @@ async function route(method, pathname, searchParams, req, res) { systemSettings.pendingRestart = systemSettings.nfs.pendingRestart; } + if (body.notifications) { + systemSettings.notifications = { + webhookEnabled: body.notifications.webhookEnabled, + events: [...body.notifications.events], + quietHoursStart: body.notifications.quietHoursStart, + quietHoursEnd: body.notifications.quietHoursEnd, + timeZoneId: body.notifications.timeZoneId, + webhookUrl: applySecretMutation( + systemSettings.notifications.webhookUrl, + body.notifications.webhookUrl, + deploymentSecrets.webhook, + ), + }; + } + systemSettings.revision += 1; return json(res, systemSettings); } catch (error) { @@ -1509,6 +1772,71 @@ async function route(method, pathname, searchParams, req, res) { } } + if (method === "POST" && pathname === "/api/notifications/test") { + if ( + !systemSettings.notifications.webhookEnabled || + !systemSettings.notifications.webhookUrl.isConfigured + ) + return json(res, { message: "Configure the webhook first" }, 409); + const eventId = randomUUID(); + notificationDeliveries.unshift({ + id: eventId, + type: "test", + status: "Delivered", + attemptCount: 1, + occurredAt: new Date().toISOString(), + lastAttemptAt: new Date().toISOString(), + deliveredAt: new Date().toISOString(), + lastError: null, + }); + return json(res, { eventId }, 202); + } + + if (method === "GET" && pathname === "/api/notifications/deliveries") { + const take = Math.min( + 100, + Math.max(1, Number(searchParams.get("take")) || 20), + ); + return json(res, notificationDeliveries.slice(0, take)); + } + + if (method === "GET" && pathname === "/api/todos") { + const includeRead = searchParams.get("includeRead") === "true"; + const includeSnoozed = searchParams.get("includeSnoozed") === "true"; + const now = Date.now(); + const all = currentMockTodos(); + const unreadCount = all.filter( + (item) => + !item.readAt && + (!item.snoozedUntil || new Date(item.snoozedUntil) <= now), + ).length; + const items = all.filter( + (item) => + (includeRead || !item.readAt) && + (includeSnoozed || + !item.snoozedUntil || + new Date(item.snoozedUntil) <= now), + ); + return json(res, { items, totalCount: items.length, unreadCount }); + } + + if (method === "PATCH" && pathname === "/api/todos/state") { + const body = await readBody(req); + const now = new Date().toISOString(); + for (const key of body.keys ?? []) { + const state = mockTodoStates.get(key) ?? { + readAt: null, + snoozedUntil: null, + }; + if (body.action === "markRead") state.readAt = now; + if (body.action === "markUnread") state.readAt = null; + if (body.action === "snooze") state.snoozedUntil = body.snoozedUntil; + if (body.action === "unsnooze") state.snoozedUntil = null; + mockTodoStates.set(key, state); + } + return empty(res, 204); + } + // --- Playback continuity --- if (method === "GET" && pathname === "/api/playback/continue") { @@ -1555,7 +1883,11 @@ async function route(method, pathname, searchParams, req, res) { if (method === "GET" && pathname === "/api/playback/context") { const animation = animations.get(searchParams.get("animationInfoId")); const path = searchParams.get("path"); - if (!animation || !animation.isDownloadFinished || !playablePaths().includes(path)) { + if ( + !animation || + !animation.isDownloadFinished || + !playablePaths().includes(path) + ) { return empty(res, 404); } return json(res, { @@ -1576,7 +1908,8 @@ async function route(method, pathname, searchParams, req, res) { if (method === "PUT" && pathname === "/api/playback/progress") { const body = await readBody(req); const animation = animations.get(body.animationInfoId); - if (!animation || !playablePaths().includes(body.path)) return empty(res, 404); + if (!animation || !playablePaths().includes(body.path)) + return empty(res, 404); const positionSeconds = Math.max(0, Number(body.positionSeconds) || 0); const durationSeconds = Math.max(0, Number(body.durationSeconds) || 0); const key = playbackKey(animation.id, body.path); @@ -1586,7 +1919,10 @@ async function route(method, pathname, searchParams, req, res) { (durationSeconds > 0 && positionSeconds / durationSeconds >= 0.9); const updatedAt = new Date().toISOString(); const stored = { - positionSeconds: Math.min(positionSeconds, durationSeconds || positionSeconds), + positionSeconds: Math.min( + positionSeconds, + durationSeconds || positionSeconds, + ), durationSeconds, isWatched, updatedAt, @@ -1599,7 +1935,8 @@ async function route(method, pathname, searchParams, req, res) { if (method === "PUT" && pathname === "/api/playback/watched") { const body = await readBody(req); const animation = animations.get(body.animationInfoId); - if (!animation || !playablePaths().includes(body.path)) return empty(res, 404); + if (!animation || !playablePaths().includes(body.path)) + return empty(res, 404); const key = playbackKey(animation.id, body.path); const previous = playbackProgress.get(key) ?? { positionSeconds: 0, @@ -1638,7 +1975,10 @@ async function route(method, pathname, searchParams, req, res) { if (method === "GET" && pathname === "/api/incidents") { const type = searchParams.get("type"); const includeResolved = searchParams.get("includeResolved") === "true"; - const skip = Math.max(0, parseInt(searchParams.get("skip") ?? "0", 10) || 0); + const skip = Math.max( + 0, + parseInt(searchParams.get("skip") ?? "0", 10) || 0, + ); const take = Math.min( 200, Math.max(1, parseInt(searchParams.get("take") ?? "50", 10) || 50), @@ -1680,7 +2020,8 @@ async function route(method, pathname, searchParams, req, res) { incident.lastRetryError = null; incident.canRetry = false; } else { - incident.lastRetryError = "Free space is still below the configured threshold"; + incident.lastRetryError = + "Free space is still below the configured threshold"; } results.push({ incidentId: incident.id, @@ -1701,15 +2042,21 @@ async function route(method, pathname, searchParams, req, res) { if (method === "POST" && match) { const incident = mockIncidents.find((item) => item.id === match[1]); if (!incident) return empty(res, 404); - if (incident.resolvedAt) return json(res, { error: "Already resolved" }, 409); + if (incident.resolvedAt) + return json(res, { error: "Already resolved" }, 409); incident.retryCount += 1; incident.lastRetryAt = new Date().toISOString(); incident.updatedAt = incident.lastRetryAt; if (incident.type === "diskSpaceLow") { - incident.lastRetryError = "Free space is still below the configured threshold"; + incident.lastRetryError = + "Free space is still below the configured threshold"; return json( res, - { incidentId: incident.id, success: false, error: incident.lastRetryError }, + { + incidentId: incident.id, + success: false, + error: incident.lastRetryError, + }, 422, ); } @@ -2026,8 +2373,7 @@ async function route(method, pathname, searchParams, req, res) { g.episodes.sort( (a, b) => new Date(b.publishTime).getTime() - - new Date(a.publishTime).getTime() || - b.id.localeCompare(a.id), + new Date(a.publishTime).getTime() || b.id.localeCompare(a.id), ); g.episodeCount = g.episodes.length; return g; @@ -2324,11 +2670,15 @@ async function route(method, pathname, searchParams, req, res) { // POST /api/subscription-policies/:feedId/simulate { - const m = pathname.match(/^\/api\/subscription-policies\/([^/]+)\/simulate$/); + const m = pathname.match( + /^\/api\/subscription-policies\/([^/]+)\/simulate$/, + ); if (method === "POST" && m) { const feedId = decodeURIComponent(m[1]); if (!feeds.some((feed) => feed.id === feedId)) return empty(res, 404); - return readBody(req).then((body) => json(res, simulatePolicy(feedId, body))); + return readBody(req).then((body) => + json(res, simulatePolicy(feedId, body)), + ); } } @@ -2349,18 +2699,34 @@ async function route(method, pathname, searchParams, req, res) { const existing = subscriptionPolicies.get(feedId); const policy = { feedId, - subtitleGroups: Array.isArray(body.subtitleGroups) ? body.subtitleGroups : [], - resolutions: Array.isArray(body.resolutions) ? body.resolutions : [], + subtitleGroups: Array.isArray(body.subtitleGroups) + ? body.subtitleGroups + : [], + resolutions: Array.isArray(body.resolutions) + ? body.resolutions + : [], codecs: Array.isArray(body.codecs) ? body.codecs : [], languages: Array.isArray(body.languages) ? body.languages : [], - minSizeBytes: typeof body.minSizeBytes === "number" ? body.minSizeBytes : null, - maxSizeBytes: typeof body.maxSizeBytes === "number" ? body.maxSizeBytes : null, - excludedKeywords: Array.isArray(body.excludedKeywords) ? body.excludedKeywords : [], - mode: ["NotifyOnly", "ManualConfirm", "AutoDownload"].includes(body.mode) ? body.mode : "ManualConfirm", + minSizeBytes: + typeof body.minSizeBytes === "number" ? body.minSizeBytes : null, + maxSizeBytes: + typeof body.maxSizeBytes === "number" ? body.maxSizeBytes : null, + excludedKeywords: Array.isArray(body.excludedKeywords) + ? body.excludedKeywords + : [], + mode: ["NotifyOnly", "ManualConfirm", "AutoDownload"].includes( + body.mode, + ) + ? body.mode + : "ManualConfirm", createdAt: existing?.createdAt ?? new Date().toISOString(), updatedAt: new Date().toISOString(), }; - if (policy.minSizeBytes != null && policy.maxSizeBytes != null && policy.minSizeBytes > policy.maxSizeBytes) { + if ( + policy.minSizeBytes != null && + policy.maxSizeBytes != null && + policy.minSizeBytes > policy.maxSizeBytes + ) { return json(res, { error: "Invalid size range" }, 400); } subscriptionPolicies.set(feedId, policy); @@ -2903,8 +3269,7 @@ server.listen(PORT, () => { (animation) => animation.isDownloadFinished, ).length; const downloadingCount = [...animations.values()].filter( - (animation) => - animation.isDownloadTracked && !animation.isDownloadFinished, + (animation) => animation.isDownloadTracked && !animation.isDownloadFinished, ).length; console.log( ` ${animations.size} anime entries (${finishedCount} finished, ${downloadingCount} active downloads, rest untracked)`, diff --git a/SecondDimensionWatcherReDive.Client/src/Main.tsx b/SecondDimensionWatcherReDive.Client/src/Main.tsx index 47a5f247..aec280f4 100644 --- a/SecondDimensionWatcherReDive.Client/src/Main.tsx +++ b/SecondDimensionWatcherReDive.Client/src/Main.tsx @@ -17,6 +17,7 @@ import { MetadataReviewPage } from "./pages/MetadataReviewPage"; import { PlayerPage } from "./pages/PlayerPage"; import { SettingsPage } from "./pages/SettingsPage"; import { TasksPage } from "./pages/TasksPage"; +import { TodoPage } from "./pages/TodoPage"; const router = createBrowserRouter([ { @@ -82,6 +83,15 @@ const router = createBrowserRouter([ ), errorElement: , }, + { + path: "/todo", + element: ( + + + + ), + errorElement: , + }, { path: "/incidents", element: ( diff --git a/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx b/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx index 0a626d68..0aa302fb 100644 --- a/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx +++ b/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router"; import { + BellRing, Check, Clapperboard, Cog, @@ -27,6 +28,7 @@ import i18n, { } from "../i18n"; import { useIncidents } from "../incidents/hooks"; import { cn } from "../lib/cn"; +import { useTodos } from "../todos/hooks"; import { DropdownMenu, DropdownMenuContent, @@ -42,8 +44,17 @@ interface NavItem { badge?: number; } -const createNavItems = (incidentCount?: number): NavItem[] => [ +const createNavItems = ( + incidentCount?: number, + todoCount?: number, +): NavItem[] => [ { icon: , labelKey: "nav.home", path: "/" }, + { + icon: , + labelKey: "nav.todo", + path: "/todo", + badge: todoCount, + }, { icon: , labelKey: "nav.downloading", @@ -221,8 +232,9 @@ export const AppHeader: React.FC = () => { const { t } = useTranslation(); const { data: status } = useLoginStatus(); const { data: incidents } = useIncidents({ take: 1 }); + const { data: todos } = useTodos(); const navigate = useNavigate(); - const items = createNavItems(incidents?.openCount); + const items = createNavItems(incidents?.openCount, todos?.unreadCount); return (
diff --git a/SecondDimensionWatcherReDive.Client/src/components/settings/NotificationSettingsSection.tsx b/SecondDimensionWatcherReDive.Client/src/components/settings/NotificationSettingsSection.tsx new file mode 100644 index 00000000..4a705cc5 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/components/settings/NotificationSettingsSection.tsx @@ -0,0 +1,313 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { BellRing, Clock3, History, Send, Webhook } from "lucide-react"; + +import { sendTestNotification } from "../../notifications/api"; +import { useNotificationDeliveries } from "../../notifications/hooks"; +import { + NotificationEventType, + NotificationSettings, + SecretDraft, + SystemSettings, + createSecretDraft, + toSecretMutation, +} from "../../settings/systemTypes"; +import { useToast } from "../ToastProvider"; +import { Button } from "../ui/Button"; +import { Card } from "../ui/Card"; +import { FormRow } from "../ui/FormRow"; +import { Input } from "../ui/Input"; +import { + SecretField, + SettingsSaveBar, + SettingsSectionHeader, + ToggleField, +} from "./SettingsControls"; + +const eventTypes: NotificationEventType[] = [ + "releaseMatched", + "downloadPendingConfirmation", + "downloadCompleted", + "downloadFailed", + "incidentOpened", + "metadataNeedsReview", + "diskSpaceLow", +]; + +export interface NotificationSettingsSectionProps { + value: NotificationSettings; + onSave: (patch: { + notifications: Omit & { + webhookUrl: ReturnType; + }; + }) => Promise; +} + +export const NotificationSettingsSection: React.FC< + NotificationSettingsSectionProps +> = ({ value, onSave }) => { + const { t, i18n } = useTranslation("settings"); + const { addToast } = useToast(); + const { data: deliveries, mutate: mutateDeliveries } = + useNotificationDeliveries(); + const [draft, setDraft] = React.useState(() => ({ + ...value, + events: [...value.events], + })); + const [urlDraft, setUrlDraft] = + React.useState(createSecretDraft); + const [saving, setSaving] = React.useState(false); + const [testing, setTesting] = React.useState(false); + const [saved, setSaved] = React.useState(false); + + React.useEffect(() => { + setDraft({ ...value, events: [...value.events] }); + setUrlDraft(createSecretDraft()); + }, [value]); + + const secretMutation = toSecretMutation(urlDraft); + const dirty = + JSON.stringify(draft) !== JSON.stringify(value) || secretMutation !== null; + const quietPairValid = + (draft.quietHoursStart === null && draft.quietHoursEnd === null) || + (Boolean(draft.quietHoursStart) && Boolean(draft.quietHoursEnd)); + const invalid = + draft.events.length === 0 || + !draft.timeZoneId.trim() || + !quietPairValid || + (draft.webhookEnabled && + !value.webhookUrl.isConfigured && + !urlDraft.value.trim()) || + (draft.webhookEnabled && urlDraft.operation === "clear"); + + const reset = React.useCallback(() => { + setDraft({ ...value, events: [...value.events] }); + setUrlDraft(createSecretDraft()); + setSaved(false); + }, [value]); + + const save = React.useCallback(async () => { + if (invalid || saving) { + if (invalid) + addToast({ + title: t("system.notifications.validationFailed"), + color: "warning", + }); + return; + } + setSaving(true); + setSaved(false); + try { + await onSave({ + notifications: { + webhookEnabled: draft.webhookEnabled, + events: draft.events, + quietHoursStart: draft.quietHoursStart || null, + quietHoursEnd: draft.quietHoursEnd || null, + timeZoneId: draft.timeZoneId, + webhookUrl: secretMutation, + }, + }); + setSaved(true); + addToast({ + title: t("system.notifications.saved"), + color: "success", + }); + } catch (error) { + addToast({ + title: + error instanceof Error && error.message === "409" + ? t("system.save.conflict") + : t("system.save.failed"), + color: "danger", + }); + } finally { + setSaving(false); + } + }, [addToast, draft, invalid, onSave, saving, secretMutation, t]); + + const test = React.useCallback(async () => { + setTesting(true); + try { + await sendTestNotification(); + await mutateDeliveries(); + addToast({ + title: t("system.notifications.testQueued"), + color: "success", + }); + } catch { + addToast({ + title: t("system.notifications.testFailed"), + color: "danger", + }); + } finally { + setTesting(false); + } + }, [addToast, mutateDeliveries, t]); + + return ( +
+ + + } + title={t("system.notifications.webhook.title")} + description={t("system.notifications.webhook.description")} + > +
+ + setDraft((current) => ({ ...current, webhookEnabled })) + } + /> + + +
+
+ + } + title={t("system.notifications.events.title")} + description={t("system.notifications.events.description")} + > +
+ {eventTypes.map((eventType) => ( + + setDraft((current) => ({ + ...current, + events: checked + ? [...current.events, eventType] + : current.events.filter((item) => item !== eventType), + })) + } + /> + ))} +
+
+ + } + title={t("system.notifications.quiet.title")} + description={t("system.notifications.quiet.description")} + > +
+ + + setDraft((current) => ({ + ...current, + quietHoursStart: event.target.value || null, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + quietHoursEnd: event.target.value || null, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + timeZoneId: event.target.value, + })) + } + /> + +
+
+ + } + title={t("system.notifications.delivery.title")} + description={t("system.notifications.delivery.description")} + > + {!deliveries?.length ? ( +

+ {t("system.notifications.delivery.empty")} +

+ ) : ( +
    + {deliveries.map((delivery) => ( +
  • +
    + + {t(`system.notifications.events.items.${delivery.type}`, { + defaultValue: delivery.type, + })} + + {delivery.lastError ? ( +

    {delivery.lastError}

    + ) : null} +
    + + {t(`system.notifications.delivery.status.${delivery.status}`)}{" "} + ·{" "} + {new Date(delivery.occurredAt).toLocaleString( + i18n.resolvedLanguage, + )} + +
  • + ))} +
+ )} +
+ + void save()} + /> +
+ ); +}; diff --git a/SecondDimensionWatcherReDive.Client/src/components/settings/SettingsNavigation.tsx b/SecondDimensionWatcherReDive.Client/src/components/settings/SettingsNavigation.tsx index 741573f6..aa04c595 100644 --- a/SecondDimensionWatcherReDive.Client/src/components/settings/SettingsNavigation.tsx +++ b/SecondDimensionWatcherReDive.Client/src/components/settings/SettingsNavigation.tsx @@ -1,7 +1,14 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Activity, Bot, Database, Download, Network } from "lucide-react"; +import { + Activity, + BellRing, + Bot, + Database, + Download, + Network, +} from "lucide-react"; import { cn } from "../../lib/cn"; import { Select } from "./SettingsControls"; @@ -11,6 +18,7 @@ export const settingsSectionIds = [ "downloads", "media", "health", + "notifications", "access", ] as const; @@ -21,6 +29,7 @@ const sectionIcons: Record = { downloads: , media: , health: , + notifications: , access: , }; diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/common.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/common.json index 541cb5e8..c39f1aae 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/common.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/common.json @@ -2,6 +2,7 @@ "appName": "SDW Re:Dive", "nav": { "home": "Home", + "todo": "To-do center", "downloading": "Downloading", "downloaded": "Downloaded", "files": "Files", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json index 9c91e391..071a19de 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json @@ -10,6 +10,7 @@ "downloads": "Downloads & storage", "media": "Media & metadata", "health": "Health monitoring", + "notifications": "Notifications", "access": "Access protocols" }, "pendingRestart": { @@ -165,6 +166,57 @@ "minimumPercent": "Minimum available percentage (%)" } }, + "notifications": { + "eyebrow": "Outbound alerts", + "title": "Notifications", + "description": "Deliver selected domain events without delaying downloads, inference, or incident handling.", + "saved": "Notification settings saved", + "validationFailed": "Configure a webhook, at least one event, and valid quiet hours", + "testQueued": "Test notification queued", + "testFailed": "Test notification could not be queued", + "webhook": { + "title": "Generic webhook", + "description": "POST a stable event envelope. X-SDW-Event-Id can be used by the receiver for idempotency.", + "enabled": "Enable webhook delivery", + "enabledHelp": "Failures are retried in the background and never fail the originating operation.", + "url": "Webhook URL", + "secretHelp": "The complete URL is encrypted at rest and is never returned to the browser or written to logs.", + "test": "Send test", + "testing": "Queuing test…" + }, + "events": { + "title": "Event subscriptions", + "description": "Choose which events may create an outbound delivery.", + "items": { + "releaseMatched": "Notify-only release matched", + "downloadPendingConfirmation": "Download needs confirmation", + "downloadCompleted": "Download completed", + "downloadFailed": "Download failed", + "incidentOpened": "Incident opened", + "metadataNeedsReview": "Metadata needs review", + "diskSpaceLow": "Disk space is low", + "test": "Test notification" + } + }, + "quiet": { + "title": "Quiet hours", + "description": "Leave both times blank to deliver immediately. During quiet hours, queued events remain pending.", + "start": "Start (hh:mm:ss)", + "end": "End (hh:mm:ss)", + "timeZone": "IANA time zone" + }, + "delivery": { + "title": "Recent delivery records", + "description": "Inspect queued, successful, retried, and permanently failed deliveries without exposing the destination.", + "empty": "No delivery attempts yet.", + "status": { + "Pending": "Pending", + "Processing": "Delivering", + "Delivered": "Delivered", + "Failed": "Failed" + } + } + }, "access": { "eyebrow": "Device access", "title": "Access protocols", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/todos.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/todos.json new file mode 100644 index 00000000..0910272a --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/todos.json @@ -0,0 +1,51 @@ +{ + "eyebrow": "Attention queue", + "title": "To-do center", + "subtitle": "Review matched releases, confirmations, incidents, metadata, and disk alerts in risk order.", + "unread_one": "{{count}} unread item", + "unread_other": "{{count}} unread items", + "listLabel": "Current to-do items", + "filters": { + "includeRead": "Show read", + "includeSnoozed": "Show snoozed" + }, + "actions": { + "selectAll": "Select all", + "selectItem": "Select {{title}}", + "readSelected": "Mark selected read", + "snoozeSelected": "Snooze selected 1 hour", + "markRead": "Mark read", + "markUnread": "Mark unread", + "snooze": "Snooze 1 hour", + "download": "Download / retry", + "open": "Open details" + }, + "types": { + "ReleaseMatched": "Matched release", + "DownloadPendingConfirmation": "Confirmation required", + "DownloadFailed": "Download failed", + "Incident": "Incident", + "MetadataReview": "Metadata review", + "DiskSpaceLow": "Low disk space" + }, + "details": { + "ReleaseMatched": "A notify-only subscription matched this release.", + "DownloadPendingConfirmation": "A matched release is waiting for download confirmation.", + "DownloadFailed": "Automatic download could not be started. Review and retry it." + }, + "priorities": { + "Normal": "Normal", + "High": "High priority", + "Critical": "Critical" + }, + "empty": { + "title": "You're all caught up", + "body": "There are no visible items requiring attention." + }, + "errors": { "loadFailed": "The to-do queue could not be loaded." }, + "toast": { + "updateFailed": "Could not update the selected items", + "downloadStarted": "Download started and the item was marked read", + "downloadFailed": "The download could not be started" + } +} diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/common.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/common.json index cd05358a..73ceb4d2 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/common.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/common.json @@ -2,6 +2,7 @@ "appName": "SDW Re:Dive", "nav": { "home": "ホーム", + "todo": "TODO センター", "downloading": "ダウンロード中", "downloaded": "ダウンロード済み", "files": "ファイル一覧", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json index 40eebfea..605f65f8 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json @@ -10,6 +10,7 @@ "downloads": "ダウンロードと保存先", "media": "メディアとメタデータ", "health": "ヘルス監視", + "notifications": "通知", "access": "アクセスプロトコル" }, "pendingRestart": { @@ -165,6 +166,57 @@ "minimumPercent": "最小空き割合(%)" } }, + "notifications": { + "eyebrow": "外部アラート", + "title": "通知", + "description": "ダウンロード、推論、障害処理を妨げずに選択したイベントを配信します。", + "saved": "通知設定を保存しました", + "validationFailed": "Webhook、1 件以上のイベント、有効な静穏時間を設定してください", + "testQueued": "テスト通知をキューに追加しました", + "testFailed": "テスト通知を追加できませんでした", + "webhook": { + "title": "汎用 Webhook", + "description": "安定したイベント形式を POST します。受信側は X-SDW-Event-Id で重複を防止できます。", + "enabled": "Webhook 配信を有効化", + "enabledHelp": "失敗はバックグラウンドで再試行され、元の処理には影響しません。", + "url": "Webhook URL", + "secretHelp": "完全な URL は暗号化保存され、ブラウザーやログには返されません。", + "test": "テストを送信", + "testing": "キューに追加中…" + }, + "events": { + "title": "イベント購読", + "description": "外部配信するイベントを選択します。", + "items": { + "releaseMatched": "通知のみのリリース一致", + "downloadPendingConfirmation": "ダウンロード確認待ち", + "downloadCompleted": "ダウンロード完了", + "downloadFailed": "ダウンロード失敗", + "incidentOpened": "障害発生", + "metadataNeedsReview": "メタデータ確認待ち", + "diskSpaceLow": "ディスク容量不足", + "test": "テスト通知" + } + }, + "quiet": { + "title": "静穏時間", + "description": "両方を空欄にすると即時配信します。静穏時間中はキューで待機します。", + "start": "開始(hh:mm:ss)", + "end": "終了(hh:mm:ss)", + "timeZone": "IANA タイムゾーン" + }, + "delivery": { + "title": "最近の配信記録", + "description": "送信先を表示せず、待機、成功、再試行、失敗を確認できます。", + "empty": "配信記録はまだありません。", + "status": { + "Pending": "待機中", + "Processing": "配信中", + "Delivered": "配信済み", + "Failed": "失敗" + } + } + }, "access": { "eyebrow": "デバイスアクセス", "title": "アクセスプロトコル", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/todos.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/todos.json new file mode 100644 index 00000000..2d243323 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/todos.json @@ -0,0 +1,50 @@ +{ + "eyebrow": "確認キュー", + "title": "統合 TODO センター", + "subtitle": "一致したリリース、確認待ち、障害、メタデータ、ディスク警告をリスク順に確認します。", + "unread": "未読 {{count}} 件", + "listLabel": "現在の TODO", + "filters": { + "includeRead": "既読を表示", + "includeSnoozed": "スヌーズを表示" + }, + "actions": { + "selectAll": "すべて選択", + "selectItem": "{{title}} を選択", + "readSelected": "選択項目を既読にする", + "snoozeSelected": "選択項目を 1 時間スヌーズ", + "markRead": "既読にする", + "markUnread": "未読にする", + "snooze": "1 時間スヌーズ", + "download": "ダウンロード / 再試行", + "open": "詳細を開く" + }, + "types": { + "ReleaseMatched": "リリース一致", + "DownloadPendingConfirmation": "確認待ち", + "DownloadFailed": "ダウンロード失敗", + "Incident": "障害", + "MetadataReview": "メタデータ確認", + "DiskSpaceLow": "ディスク容量不足" + }, + "details": { + "ReleaseMatched": "通知のみの購読にこのリリースが一致しました。", + "DownloadPendingConfirmation": "一致したリリースがダウンロード確認を待っています。", + "DownloadFailed": "自動ダウンロードを開始できませんでした。確認して再試行してください。" + }, + "priorities": { + "Normal": "通常", + "High": "優先", + "Critical": "緊急" + }, + "empty": { + "title": "すべて完了しました", + "body": "現在、確認が必要な表示項目はありません。" + }, + "errors": { "loadFailed": "TODO キューを読み込めませんでした。" }, + "toast": { + "updateFailed": "選択項目を更新できませんでした", + "downloadStarted": "ダウンロードを開始し、既読にしました", + "downloadFailed": "ダウンロードを開始できませんでした" + } +} diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/common.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/common.json index 5ffff49b..81ad34b9 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/common.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/common.json @@ -2,6 +2,7 @@ "appName": "二次元观测器", "nav": { "home": "主页", + "todo": "待办中心", "downloading": "下载列表", "downloaded": "已下载", "files": "文件浏览", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json index 1d101bef..86b3b893 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json @@ -10,6 +10,7 @@ "downloads": "下载与存储", "media": "媒体与元数据", "health": "健康监控", + "notifications": "通知", "access": "访问协议" }, "pendingRestart": { @@ -165,6 +166,57 @@ "minimumPercent": "最小可用比例(%)" } }, + "notifications": { + "eyebrow": "外部提醒", + "title": "通知", + "description": "在不阻塞下载、推断或异常处理的前提下投递所选领域事件。", + "saved": "通知设置已保存", + "validationFailed": "请配置 Webhook、至少一个事件和有效的免打扰时段", + "testQueued": "测试通知已进入队列", + "testFailed": "无法加入测试通知", + "webhook": { + "title": "通用 Webhook", + "description": "发送稳定的事件信封;接收端可用 X-SDW-Event-Id 实现幂等。", + "enabled": "启用 Webhook 投递", + "enabledHelp": "失败会在后台重试,绝不会导致原业务操作失败。", + "url": "Webhook URL", + "secretHelp": "完整 URL 会加密保存,绝不返回浏览器或写入日志。", + "test": "发送测试", + "testing": "正在加入队列…" + }, + "events": { + "title": "事件订阅", + "description": "选择允许向外投递的事件。", + "items": { + "releaseMatched": "仅通知订阅命中", + "downloadPendingConfirmation": "下载等待确认", + "downloadCompleted": "下载完成", + "downloadFailed": "下载失败", + "incidentOpened": "异常已开启", + "metadataNeedsReview": "元数据需要审查", + "diskSpaceLow": "磁盘空间不足", + "test": "测试通知" + } + }, + "quiet": { + "title": "免打扰时段", + "description": "开始和结束均留空则立即投递;免打扰期间事件会保留在队列。", + "start": "开始(hh:mm:ss)", + "end": "结束(hh:mm:ss)", + "timeZone": "IANA 时区" + }, + "delivery": { + "title": "最近投递记录", + "description": "查看排队、成功、重试和永久失败记录,且不会暴露目标地址。", + "empty": "还没有投递记录。", + "status": { + "Pending": "等待中", + "Processing": "投递中", + "Delivered": "已投递", + "Failed": "已失败" + } + } + }, "access": { "eyebrow": "设备接入", "title": "访问协议", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/todos.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/todos.json new file mode 100644 index 00000000..1eb39946 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/todos.json @@ -0,0 +1,50 @@ +{ + "eyebrow": "关注队列", + "title": "统一待办中心", + "subtitle": "按风险顺序处理订阅命中、下载确认、异常、元数据审查和磁盘预警。", + "unread": "{{count}} 个未读待办", + "listLabel": "当前待办事项", + "filters": { + "includeRead": "显示已读", + "includeSnoozed": "显示已稍后提醒" + }, + "actions": { + "selectAll": "全选", + "selectItem": "选择 {{title}}", + "readSelected": "将所选标为已读", + "snoozeSelected": "所选稍后 1 小时提醒", + "markRead": "标为已读", + "markUnread": "标为未读", + "snooze": "稍后 1 小时提醒", + "download": "下载 / 重试", + "open": "查看详情" + }, + "types": { + "ReleaseMatched": "订阅命中", + "DownloadPendingConfirmation": "等待确认", + "DownloadFailed": "下载失败", + "Incident": "异常", + "MetadataReview": "元数据审查", + "DiskSpaceLow": "磁盘空间不足" + }, + "details": { + "ReleaseMatched": "仅通知订阅命中了此发布。", + "DownloadPendingConfirmation": "此匹配发布正在等待下载确认。", + "DownloadFailed": "自动下载未能启动,请检查后重试。" + }, + "priorities": { + "Normal": "普通", + "High": "高优先级", + "Critical": "紧急" + }, + "empty": { + "title": "待办已清空", + "body": "当前没有需要关注的可见事项。" + }, + "errors": { "loadFailed": "无法加载待办队列。" }, + "toast": { + "updateFailed": "无法更新所选待办", + "downloadStarted": "已开始下载并将待办标为已读", + "downloadFailed": "无法开始下载" + } +} diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/resources.ts b/SecondDimensionWatcherReDive.Client/src/i18n/resources.ts index 05332829..e73ac138 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/resources.ts +++ b/SecondDimensionWatcherReDive.Client/src/i18n/resources.ts @@ -11,6 +11,7 @@ import enPlayer from "./locales/en/player.json"; import enSeason from "./locales/en/season.json"; import enSettings from "./locales/en/settings.json"; import enTasks from "./locales/en/tasks.json"; +import enTodos from "./locales/en/todos.json"; import jaAnimation from "./locales/ja/animation.json"; import jaAuth from "./locales/ja/auth.json"; import jaChat from "./locales/ja/chat.json"; @@ -24,6 +25,7 @@ import jaPlayer from "./locales/ja/player.json"; import jaSeason from "./locales/ja/season.json"; import jaSettings from "./locales/ja/settings.json"; import jaTasks from "./locales/ja/tasks.json"; +import jaTodos from "./locales/ja/todos.json"; import zhCnAnimation from "./locales/zh-CN/animation.json"; import zhCnAuth from "./locales/zh-CN/auth.json"; import zhCnChat from "./locales/zh-CN/chat.json"; @@ -37,6 +39,7 @@ import zhCnPlayer from "./locales/zh-CN/player.json"; import zhCnSeason from "./locales/zh-CN/season.json"; import zhCnSettings from "./locales/zh-CN/settings.json"; import zhCnTasks from "./locales/zh-CN/tasks.json"; +import zhCnTodos from "./locales/zh-CN/todos.json"; export const resources = { "zh-cn": { @@ -53,6 +56,7 @@ export const resources = { settings: zhCnSettings, tasks: zhCnTasks, player: zhCnPlayer, + todos: zhCnTodos, }, en: { common: enCommon, @@ -68,6 +72,7 @@ export const resources = { settings: enSettings, tasks: enTasks, player: enPlayer, + todos: enTodos, }, ja: { common: jaCommon, @@ -83,5 +88,6 @@ export const resources = { settings: jaSettings, tasks: jaTasks, player: jaPlayer, + todos: jaTodos, }, } as const; diff --git a/SecondDimensionWatcherReDive.Client/src/notifications/api.ts b/SecondDimensionWatcherReDive.Client/src/notifications/api.ts new file mode 100644 index 00000000..a1e9cb91 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/notifications/api.ts @@ -0,0 +1,4 @@ +import fetcher from "../auth/httpClient"; + +export const sendTestNotification = () => + fetcher<{ eventId: string }>("/api/notifications/test", { method: "POST" }); diff --git a/SecondDimensionWatcherReDive.Client/src/notifications/hooks.ts b/SecondDimensionWatcherReDive.Client/src/notifications/hooks.ts new file mode 100644 index 00000000..1c2c537d --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/notifications/hooks.ts @@ -0,0 +1,11 @@ +import useSWR from "swr"; + +import fetcher from "../auth/httpClient"; +import { NotificationDelivery } from "./types"; + +export const useNotificationDeliveries = () => + useSWR( + "/api/notifications/deliveries?take=10", + fetcher, + { refreshInterval: 5000 }, + ); diff --git a/SecondDimensionWatcherReDive.Client/src/notifications/types.ts b/SecondDimensionWatcherReDive.Client/src/notifications/types.ts new file mode 100644 index 00000000..5126449c --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/notifications/types.ts @@ -0,0 +1,10 @@ +export interface NotificationDelivery { + id: string; + type: string; + status: "Pending" | "Processing" | "Delivered" | "Failed"; + attemptCount: number; + occurredAt: string; + lastAttemptAt: string | null; + deliveredAt: string | null; + lastError: string | null; +} diff --git a/SecondDimensionWatcherReDive.Client/src/pages/IncidentsPage.tsx b/SecondDimensionWatcherReDive.Client/src/pages/IncidentsPage.tsx index 242dab2c..4b531131 100644 --- a/SecondDimensionWatcherReDive.Client/src/pages/IncidentsPage.tsx +++ b/SecondDimensionWatcherReDive.Client/src/pages/IncidentsPage.tsx @@ -1,6 +1,7 @@ import dayjs from "dayjs"; import React from "react"; import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router"; import { AlertTriangle, @@ -47,16 +48,20 @@ const severityClasses: Record = { const IncidentCard: React.FC<{ incident: Incident; retrying: boolean; + focused: boolean; onRetry: () => void; -}> = ({ incident, retrying, onRetry }) => { +}> = ({ incident, retrying, focused, onRetry }) => { const { t } = useTranslation("incidents"); const isResolved = incident.resolvedAt != null; return (
@@ -138,7 +143,13 @@ const IncidentCard: React.FC<{ export const IncidentsPage: React.FC = () => { const { t } = useTranslation(["incidents", "errors"]); const { addToast } = useToast(); - const [type, setType] = React.useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + const requestedType = searchParams.get("type"); + const initialType = incidentTypes.includes(requestedType as IncidentType) + ? (requestedType as IncidentType) + : null; + const focus = searchParams.get("focus"); + const [type, setType] = React.useState(initialType); const [includeResolved, setIncludeResolved] = React.useState(false); const [page, setPage] = React.useState(0); const [retryingIds, setRetryingIds] = React.useState>(new Set()); @@ -154,6 +165,30 @@ export const IncidentsPage: React.FC = () => { setPage(0); }, [includeResolved, type]); + React.useEffect(() => { + setType(initialType); + }, [initialType]); + + React.useEffect(() => { + if (!focus || !data) return; + document.getElementById(`incident-${focus}`)?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }, [data, focus]); + + const selectType = React.useCallback( + (nextType: IncidentType | null) => { + setType(nextType); + const next = new URLSearchParams(searchParams); + if (nextType) next.set("type", nextType); + else next.delete("type"); + next.delete("focus"); + setSearchParams(next, { replace: true }); + }, + [searchParams, setSearchParams], + ); + React.useEffect(() => { if (!data || page === 0 || page * 50 < data.totalCount) return; setPage(Math.max(0, Math.ceil(data.totalCount / 50) - 1)); @@ -241,7 +276,7 @@ export const IncidentsPage: React.FC = () => {
+ +
+
+ + {error ? ( + } + title={

{t("errors:loadFailed")}

} + body={

{t("todos:errors.loadFailed")}

} + /> + ) : !data ? ( +
+ +
+ ) : !items.length ? ( + } + title={

{t("todos:empty.title")}

} + body={

{t("todos:empty.body")}

} + /> + ) : ( +
    + {items.map((item) => { + const automation = + item.type === "ReleaseMatched" || + item.type === "DownloadPendingConfirmation" || + item.type === "DownloadFailed"; + const detail = automation + ? t(`todos:details.${item.type}`) + : item.detail; + return ( +
  • +
    + + setSelected((current) => { + const next = new Set(current); + if (event.target.checked) next.add(item.key); + else next.delete(item.key); + return next; + }) + } + /> +
    +
    +
    +
    + + {t(`todos:types.${item.type}`)} + + + {t(`todos:priorities.${item.priority}`)} + +
    +

    + {item.title} +

    +
    + +
    +

    + {detail} +

    +
    + {automation ? ( + + ) : ( + + )} + + +
    +
    +
    +
  • + ); + })} +
+ )} + + ); +}; diff --git a/SecondDimensionWatcherReDive.Client/src/settings/systemTypes.ts b/SecondDimensionWatcherReDive.Client/src/settings/systemTypes.ts index dea97f1b..079a4ac3 100644 --- a/SecondDimensionWatcherReDive.Client/src/settings/systemTypes.ts +++ b/SecondDimensionWatcherReDive.Client/src/settings/systemTypes.ts @@ -90,6 +90,24 @@ export interface NfsSettings { pendingRestart: boolean; } +export type NotificationEventType = + | "releaseMatched" + | "downloadPendingConfirmation" + | "downloadCompleted" + | "downloadFailed" + | "incidentOpened" + | "metadataNeedsReview" + | "diskSpaceLow"; + +export interface NotificationSettings { + webhookEnabled: boolean; + events: NotificationEventType[]; + quietHoursStart: string | null; + quietHoursEnd: string | null; + timeZoneId: string; + webhookUrl: SecretState; +} + export interface SystemSettings { revision: number; pendingRestart: boolean; @@ -99,6 +117,7 @@ export interface SystemSettings { mediaLibrary: MediaLibrarySettings; incidents: IncidentSettings; nfs: NfsSettings; + notifications: NotificationSettings; } export interface OpenAiSettingsPatch extends Omit { @@ -144,6 +163,13 @@ export type NfsSettingsPatch = Omit< "restartRequired" | "pendingRestart" >; +export interface NotificationSettingsPatch extends Omit< + NotificationSettings, + "webhookUrl" +> { + webhookUrl?: SecretMutation | null; +} + export interface SystemSettingsPatch { expectedRevision: number; ai?: AiSettingsPatch; @@ -152,6 +178,7 @@ export interface SystemSettingsPatch { mediaLibrary?: MediaLibrarySettings; incidents?: IncidentSettings; nfs?: NfsSettingsPatch; + notifications?: NotificationSettingsPatch; } export const createSecretDraft = (): SecretDraft => ({ diff --git a/SecondDimensionWatcherReDive.Client/src/todos/api.ts b/SecondDimensionWatcherReDive.Client/src/todos/api.ts new file mode 100644 index 00000000..95735f11 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/todos/api.ts @@ -0,0 +1,13 @@ +import fetcher from "../auth/httpClient"; +import { TodoStateAction } from "./types"; + +export const updateTodoState = ( + keys: string[], + action: TodoStateAction, + snoozedUntil?: string, +) => + fetcher("/api/todos/state", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keys, action, snoozedUntil }), + }); diff --git a/SecondDimensionWatcherReDive.Client/src/todos/hooks.ts b/SecondDimensionWatcherReDive.Client/src/todos/hooks.ts new file mode 100644 index 00000000..8c4ee423 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/todos/hooks.ts @@ -0,0 +1,15 @@ +import useSWR from "swr"; + +import fetcher from "../auth/httpClient"; +import { TodoList } from "./types"; + +export const useTodos = (options?: { + includeRead?: boolean; + includeSnoozed?: boolean; +}) => { + const params = new URLSearchParams(); + if (options?.includeRead) params.set("includeRead", "true"); + if (options?.includeSnoozed) params.set("includeSnoozed", "true"); + const query = params.toString(); + return useSWR(`/api/todos${query ? `?${query}` : ""}`, fetcher); +}; diff --git a/SecondDimensionWatcherReDive.Client/src/todos/types.ts b/SecondDimensionWatcherReDive.Client/src/todos/types.ts new file mode 100644 index 00000000..6545cf2a --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/todos/types.ts @@ -0,0 +1,30 @@ +export type TodoType = + | "ReleaseMatched" + | "DownloadPendingConfirmation" + | "DownloadFailed" + | "Incident" + | "MetadataReview" + | "DiskSpaceLow"; + +export type TodoPriority = "Normal" | "High" | "Critical"; + +export interface TodoItem { + key: string; + type: TodoType; + priority: TodoPriority; + title: string; + detail: string; + deepLink: string; + resourceId: string | null; + occurredAt: string; + readAt: string | null; + snoozedUntil: string | null; +} + +export interface TodoList { + items: TodoItem[]; + totalCount: number; + unreadCount: number; +} + +export type TodoStateAction = "markRead" | "markUnread" | "snooze" | "unsnooze"; diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/INotificationOutboxRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/INotificationOutboxRepository.cs new file mode 100644 index 00000000..25ebfbff --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/INotificationOutboxRepository.cs @@ -0,0 +1,62 @@ +using SecondDimensionWatcherReDive.Framework.Notifications; + +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public enum NotificationDeliveryStatus +{ + Pending, + Processing, + Delivered, + Failed +} + +public sealed record NotificationOutboxMessage( + Guid Id, + string DeduplicationKey, + NotificationEventType Type, + string Title, + string Body, + string DeepLink, + string? PayloadJson, + DateTimeOffset OccurredAt, + NotificationDeliveryStatus Status, + int AttemptCount, + DateTimeOffset NextAttemptAt, + DateTimeOffset? LastAttemptAt, + DateTimeOffset? DeliveredAt, + string? LastError); + +public interface INotificationOutboxRepository +{ + Task EnqueueAsync( + NotificationOutboxMessage message, + CancellationToken cancellationToken); + + Task> ClaimDueAsync( + DateTimeOffset now, + DateTimeOffset leaseUntil, + int take, + CancellationToken cancellationToken); + + Task MarkDeliveredAsync( + Guid id, + DateTimeOffset deliveredAt, + CancellationToken cancellationToken); + + Task MarkFailedAsync( + Guid id, + int attemptCount, + DateTimeOffset attemptedAt, + DateTimeOffset? nextAttemptAt, + string error, + CancellationToken cancellationToken); + + Task RescheduleAsync( + Guid id, + DateTimeOffset nextAttemptAt, + CancellationToken cancellationToken); + + Task> GetRecentAsync( + int take, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/ITodoRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/ITodoRepository.cs new file mode 100644 index 00000000..7be1cc10 --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/ITodoRepository.cs @@ -0,0 +1,52 @@ +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public enum TodoItemType +{ + ReleaseMatched, + DownloadPendingConfirmation, + DownloadFailed, + Incident, + MetadataReview, + DiskSpaceLow +} + +public enum TodoPriority +{ + Normal, + High, + Critical +} + +public sealed record TodoItem( + string Key, + TodoItemType Type, + TodoPriority Priority, + string Title, + string Detail, + string DeepLink, + Guid? ResourceId, + DateTimeOffset OccurredAt, + DateTimeOffset? ReadAt, + DateTimeOffset? SnoozedUntil); + +public sealed record TodoPage( + IReadOnlyList Items, + int TotalCount, + int UnreadCount); + +public interface ITodoRepository +{ + Task GetAsync( + bool includeRead, + bool includeSnoozed, + DateTimeOffset now, + CancellationToken cancellationToken); + + Task SetStateAsync( + IReadOnlyCollection keys, + DateTimeOffset? readAt, + bool updateReadAt, + DateTimeOffset? snoozedUntil, + bool updateSnoozedUntil, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/Notifications/NotificationEvent.cs b/SecondDimensionWatcherReDive.Framework/Notifications/NotificationEvent.cs new file mode 100644 index 00000000..c4f1d784 --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/Notifications/NotificationEvent.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace SecondDimensionWatcherReDive.Framework.Notifications; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum NotificationEventType +{ + [JsonStringEnumMemberName("releaseMatched")] + ReleaseMatched, + [JsonStringEnumMemberName("downloadPendingConfirmation")] + DownloadPendingConfirmation, + [JsonStringEnumMemberName("downloadCompleted")] + DownloadCompleted, + [JsonStringEnumMemberName("downloadFailed")] + DownloadFailed, + [JsonStringEnumMemberName("incidentOpened")] + IncidentOpened, + [JsonStringEnumMemberName("metadataNeedsReview")] + MetadataNeedsReview, + [JsonStringEnumMemberName("diskSpaceLow")] + DiskSpaceLow, + [JsonStringEnumMemberName("test")] + Test +} + +public sealed record NotificationEvent( + NotificationEventType Type, + string DeduplicationKey, + string Title, + string Body, + string DeepLink, + string? PayloadJson = null, + DateTimeOffset? OccurredAt = null, + Guid? Id = null); + +public interface INotificationPublisher +{ + Task PublishAsync( + NotificationEvent notificationEvent, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Test/CompleteDownloadBackgroundServiceTests.cs b/SecondDimensionWatcherReDive.Test/CompleteDownloadBackgroundServiceTests.cs index 7c402db4..762540c5 100644 --- a/SecondDimensionWatcherReDive.Test/CompleteDownloadBackgroundServiceTests.cs +++ b/SecondDimensionWatcherReDive.Test/CompleteDownloadBackgroundServiceTests.cs @@ -5,6 +5,7 @@ using SecondDimensionWatcherReDive.Data; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.PluginParams; +using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.Plugin; using SecondDimensionWatcherReDive.Services; using SecondDimensionWatcherReDive.Utils.FileStore; @@ -80,11 +81,13 @@ public async Task ProcessRequestAsync_TrackedDownload_CompletesBeforeMappingAndP CancellationToken.None)) .Returns(Task.CompletedTask); using var provider = CreateProvider(repository.Object, mapper.Object, plugin.Object); + var notifications = new Mock(); var service = new CompleteDownloadBackgroundService( Channel.CreateUnbounded(), provider.GetRequiredService(), Mock.Of>(), - Mock.Of()); + Mock.Of(), + notifications.Object); await service.ProcessRequestAsync(request, CancellationToken.None); @@ -96,6 +99,11 @@ public async Task ProcessRequestAsync_TrackedDownload_CompletesBeforeMappingAndP && parameter.StorePath == request.StorePath && parameter.FileStore == request.FileStore), CancellationToken.None), Times.Once); + notifications.Verify(candidate => candidate.PublishAsync( + It.Is(notification => + notification.Type == NotificationEventType.DownloadCompleted + && notification.DeduplicationKey.Contains(request.ItemId.ToString(), StringComparison.Ordinal)), + CancellationToken.None), Times.Once); } private static ServiceProvider CreateProvider( diff --git a/SecondDimensionWatcherReDive.Test/IncidentReporterTests.cs b/SecondDimensionWatcherReDive.Test/IncidentReporterTests.cs index 1f22c9b4..0ba77937 100644 --- a/SecondDimensionWatcherReDive.Test/IncidentReporterTests.cs +++ b/SecondDimensionWatcherReDive.Test/IncidentReporterTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using Moq; using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.Utils.Incidents; namespace SecondDimensionWatcherReDive.Test; @@ -47,4 +48,40 @@ await reporter.ReportAsync(new IncidentReport( Assert.AreEqual(persisted[0].Fingerprint, persisted[1].Fingerprint); Assert.AreNotEqual(persisted[0].Id, persisted[1].Id); } + + [TestMethod] + public async Task ReportAsync_DiskSpaceLow_PublishesOnlySpecificEvent() + { + var repository = new Mock(); + repository.Setup(candidate => candidate.UpsertAsync( + It.IsAny(), + CancellationToken.None)) + .ReturnsAsync((Incident incident, CancellationToken _) => incident); + var provider = new Mock(); + provider.Setup(candidate => candidate.GetService(typeof(IIncidentRepository))) + .Returns(repository.Object); + var scope = new Mock(); + scope.SetupGet(candidate => candidate.ServiceProvider).Returns(provider.Object); + var factory = new Mock(); + factory.Setup(candidate => candidate.CreateScope()).Returns(scope.Object); + var notifications = new Mock(); + var reporter = new IncidentReporter( + factory.Object, + Mock.Of>(), + notifications.Object); + + await reporter.ReportAsync(new IncidentReport( + IncidentType.DiskSpaceLow, + IncidentSeverity.Critical, + "Disk space is low", + "Less than 5% is available.", + "/downloads"), CancellationToken.None); + + notifications.Verify(candidate => candidate.PublishAsync( + It.Is(notification => + notification.Type == NotificationEventType.DiskSpaceLow + && notification.DeepLink == "/incidents?type=diskSpaceLow"), + CancellationToken.None), Times.Once); + notifications.VerifyNoOtherCalls(); + } } diff --git a/SecondDimensionWatcherReDive.Test/NotificationPipelineTests.cs b/SecondDimensionWatcherReDive.Test/NotificationPipelineTests.cs new file mode 100644 index 00000000..c0e94eb6 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/NotificationPipelineTests.cs @@ -0,0 +1,226 @@ +using System.Net; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; +using SecondDimensionWatcherReDive.Utils.Notifications; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class NotificationPipelineTests +{ + [TestMethod] + public async Task PublishAsync_SubscribedEvent_EnqueuesStableOutboxEnvelope() + { + var repository = new Mock(); + repository.Setup(candidate => candidate.EnqueueAsync( + It.IsAny(), CancellationToken.None)) + .ReturnsAsync(true); + using var services = new ServiceCollection() + .AddScoped(_ => repository.Object) + .BuildServiceProvider(); + var configuration = Configuration(new Dictionary + { + ["Notifications:Webhook:Enabled"] = "true", + ["Notifications:Events"] = "ReleaseMatched,DownloadCompleted" + }); + var publisher = new NotificationPublisher( + services.GetRequiredService(), + configuration, + Mock.Of>()); + + await publisher.PublishAsync(new NotificationEvent( + NotificationEventType.ReleaseMatched, + "release:stable", + "Matched", + "Anime title", + "/todo?focus=automation:id"), CancellationToken.None); + + repository.Verify(candidate => candidate.EnqueueAsync( + It.Is(message => + message.DeduplicationKey == "release:stable" + && message.Type == NotificationEventType.ReleaseMatched + && message.Status == NotificationDeliveryStatus.Pending + && message.AttemptCount == 0), + CancellationToken.None), Times.Once); + } + + [TestMethod] + public async Task PublishAsync_PersistenceFailure_DoesNotEscapeIntoCoreOperation() + { + var repository = new Mock(); + repository.Setup(candidate => candidate.EnqueueAsync( + It.IsAny(), CancellationToken.None)) + .ThrowsAsync(new InvalidOperationException("database unavailable")); + using var services = new ServiceCollection() + .AddScoped(_ => repository.Object) + .BuildServiceProvider(); + var publisher = new NotificationPublisher( + services.GetRequiredService(), + Configuration(new Dictionary + { + ["Notifications:Webhook:Enabled"] = "true", + ["Notifications:Events"] = "IncidentOpened" + }), + Mock.Of>()); + + await publisher.PublishAsync(new NotificationEvent( + NotificationEventType.IncidentOpened, + "incident:stable", + "Incident", + "Detail", + "/incidents"), CancellationToken.None); + } + + [TestMethod] + public async Task DeliverBatchAsync_Success_SendsIdempotencyHeaderAndMarksDelivered() + { + var message = Message(); + var repository = new Mock(); + repository.Setup(candidate => candidate.ClaimDueAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), CancellationToken.None)) + .ReturnsAsync([message]); + string? eventId = null; + string? body = null; + var handler = new DelegateHandler(async request => + { + eventId = request.Headers.GetValues("X-SDW-Event-Id").Single(); + body = await request.Content!.ReadAsStringAsync(); + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + + var service = DeliveryService(repository.Object, handler); + var count = await service.DeliverBatchAsync(CancellationToken.None); + + Assert.AreEqual(1, count); + Assert.AreEqual(message.Id.ToString("D"), eventId); + StringAssert.Contains(body!, "\"deepLink\":\"/todo?focus=automation:item\""); + repository.Verify(candidate => candidate.MarkDeliveredAsync( + message.Id, It.IsAny(), CancellationToken.None), Times.Once); + } + + [TestMethod] + public async Task DeliverBatchAsync_ServerFailure_RecordsRetryWithoutThrowing() + { + var message = Message(); + var repository = new Mock(); + repository.Setup(candidate => candidate.ClaimDueAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), CancellationToken.None)) + .ReturnsAsync([message]); + var service = DeliveryService(repository.Object, + new DelegateHandler(_ => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)))); + + await service.DeliverBatchAsync(CancellationToken.None); + + repository.Verify(candidate => candidate.MarkFailedAsync( + message.Id, + 1, + It.IsAny(), + It.Is(next => next.HasValue), + "HTTP 503", + CancellationToken.None), Times.Once); + } + + [TestMethod] + public async Task DeliverBatchAsync_NetworkFailure_DoesNotLogSecretWebhookUrl() + { + var message = Message(); + var repository = new Mock(); + repository.Setup(candidate => candidate.ClaimDueAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), CancellationToken.None)) + .ReturnsAsync([message]); + const string SecretUrl = "https://hooks.example.test/sdw?token=never-log-this"; + var logger = new CollectingLogger(); + var service = DeliveryService( + repository.Object, + new DelegateHandler(_ => throw new HttpRequestException(SecretUrl)), + logger, + SecretUrl); + + await service.DeliverBatchAsync(CancellationToken.None); + + Assert.IsTrue(logger.Entries.Any(entry => entry.Contains( + nameof(HttpRequestException), StringComparison.Ordinal))); + Assert.IsFalse(logger.Entries.Any(entry => entry.Contains( + "never-log-this", StringComparison.Ordinal))); + } + + private static NotificationDeliveryBackgroundService DeliveryService( + INotificationOutboxRepository repository, + HttpMessageHandler handler, + ILogger? logger = null, + string endpoint = "https://hooks.example.test/sdw") + { + var scopeServices = new ServiceCollection() + .AddScoped(_ => repository) + .BuildServiceProvider(); + var clients = new Mock(); + clients.Setup(candidate => candidate.CreateClient("NotificationWebhook")) + .Returns(new HttpClient(handler)); + return new NotificationDeliveryBackgroundService( + scopeServices.GetRequiredService(), + clients.Object, + Configuration(new Dictionary + { + ["Notifications:Webhook:Enabled"] = "true", + ["Notifications:Webhook:Url"] = endpoint, + ["Notifications:QuietHours:TimeZone"] = "UTC" + }), + logger ?? Mock.Of>()); + } + + private static NotificationOutboxMessage Message() => new( + Guid.NewGuid(), + "release:stable", + NotificationEventType.ReleaseMatched, + "Matched", + "Anime title", + "/todo?focus=automation:item", + "{\"animationId\":\"item\"}", + DateTimeOffset.UtcNow, + NotificationDeliveryStatus.Pending, + 0, + DateTimeOffset.UtcNow, + null, + null, + null); + + private static IConfiguration Configuration(IReadOnlyDictionary values) => + new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + + private sealed class DelegateHandler( + Func> callback) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => callback(request); + } + + private sealed class CollectingLogger : ILogger + { + public List Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add(formatter(state, exception)); + if (exception is not null) + Entries.Add(exception.ToString()); + } + } +} diff --git a/SecondDimensionWatcherReDive.Test/RuntimeSettingsServiceTests.cs b/SecondDimensionWatcherReDive.Test/RuntimeSettingsServiceTests.cs index a7ff6ae1..cd950089 100644 --- a/SecondDimensionWatcherReDive.Test/RuntimeSettingsServiceTests.cs +++ b/SecondDimensionWatcherReDive.Test/RuntimeSettingsServiceTests.cs @@ -50,6 +50,7 @@ public async Task GetSettings_RedactsEverySecret() Assert.DoesNotContain("deployment-codex-secret", json, StringComparison.Ordinal); Assert.DoesNotContain("deployment-tmdb-secret", json, StringComparison.Ordinal); Assert.DoesNotContain("deployment-torrent-secret", json, StringComparison.Ordinal); + Assert.DoesNotContain("deployment-webhook-secret", json, StringComparison.Ordinal); StringAssert.Contains(json, "\"isConfigured\":true"); StringAssert.Contains(json, "\"source\":\"deployment\""); StringAssert.Contains(json, "\"permissionProfile\":\":read-only\""); @@ -106,6 +107,46 @@ public async Task PatchSettings_ReturnsSecretMetadataWithoutEchoingNewValue() StringAssert.Contains(json, "\"source\":\"runtime\""); } + [TestMethod] + public async Task WebhookUrl_IsEncryptedAtRestAndNeverReturnedBySettingsApi() + { + await using var host = await SettingsTestHost.CreateAsync( + configurationOverrides: new Dictionary + { + [RuntimeSecretKeys.NotificationWebhookUrl] = null + }); + var initial = await host.RuntimeSettings.GetAsync(CancellationToken.None); + const string WebhookUrl = "https://hooks.example.test/sdw?token=must-remain-secret"; + var result = await host.RuntimeSettings.UpdateAsync( + new RuntimeSettingsPatch( + initial.Revision, + Ai: null, + Tmdb: null, + Torrent: null, + MediaLibrary: null, + Incidents: null, + Nfs: null, + Notifications: new NotificationSettingsUpdate( + initial.Desired.Notifications with { WebhookEnabled = true }, + new SecretMutation(SecretMutationOperation.Set, WebhookUrl))), + CancellationToken.None); + + Assert.AreEqual(RuntimeSettingsUpdateStatus.Saved, result.Status); + Assert.AreEqual(WebhookUrl, host.Configuration[RuntimeSecretKeys.NotificationWebhookUrl]); + Assert.DoesNotContain( + WebhookUrl, + host.Repository.Document?.ProtectedSecrets ?? string.Empty, + StringComparison.Ordinal); + + var controller = new SettingsController(host.RuntimeSettings); + var action = await controller.GetSettingsAsync(CancellationToken.None); + var json = JsonSerializer.Serialize( + ((OkObjectResult)action.Result!).Value, + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + Assert.DoesNotContain(WebhookUrl, json, StringComparison.Ordinal); + StringAssert.Contains(json, "\"webhookUrl\":{\"isConfigured\":true,\"source\":\"runtime\"}"); + } + [TestMethod] public async Task SetSecret_EncryptsPersistence_AndPublishesRuntimeValue() { @@ -783,6 +824,10 @@ public async ValueTask DisposeAsync() ["Incidents:ReconciliationInterval"] = "00:05:00", ["Incidents:Disk:MinimumAvailableBytes"] = "5368709120", ["Incidents:Disk:MinimumAvailablePercent"] = "5", + ["Notifications:Webhook:Enabled"] = "false", + ["Notifications:Webhook:Url"] = "https://hooks.example.test/delivery?token=deployment-webhook-secret", + ["Notifications:Events"] = "ReleaseMatched,DownloadCompleted", + ["Notifications:QuietHours:TimeZone"] = "UTC", ["Nfs:Enabled"] = "false", ["Nfs:Port"] = "2049", ["Nfs:BindAddress"] = "127.0.0.1", diff --git a/SecondDimensionWatcherReDive.Test/SyncFeedTests.cs b/SecondDimensionWatcherReDive.Test/SyncFeedTests.cs index 599c1ebf..946b7e49 100644 --- a/SecondDimensionWatcherReDive.Test/SyncFeedTests.cs +++ b/SecondDimensionWatcherReDive.Test/SyncFeedTests.cs @@ -7,6 +7,7 @@ using SecondDimensionWatcherReDive.Framework.Feed; using SecondDimensionWatcherReDive.Framework.FileDownload; using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.Services; using SecondDimensionWatcherReDive.Utils.Feed; @@ -19,6 +20,7 @@ public class SyncFeedTests private Mock _mockPolicyRepo = null!; private Mock _mockDownloadProvider = null!; private Mock _mockDownloadClient = null!; + private Mock _mockNotificationPublisher = null!; private SyncFeed _syncFeed = null!; private MethodInfo _processSingleMethod = null!; @@ -29,6 +31,7 @@ public void Setup() _mockPolicyRepo = new Mock(); _mockDownloadProvider = new Mock(); _mockDownloadClient = new Mock(); + _mockNotificationPublisher = new Mock(); _mockRepo.Setup(repository => repository.AddAsync( It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); @@ -61,7 +64,8 @@ public void Setup() Mock.Of>(), mockHttpClientFactory.Object, mockScopeFactory.Object, - new SubscriptionAutomationMatcher(new SubscriptionReleaseMetadataExtractor())); + new SubscriptionAutomationMatcher(new SubscriptionReleaseMetadataExtractor()), + notificationPublisher: _mockNotificationPublisher.Object); _processSingleMethod = typeof(SyncFeed) .GetMethod("ProcessSingle", BindingFlags.NonPublic | BindingFlags.Instance)!; @@ -171,6 +175,12 @@ public async Task ProcessSingle_NotifyOnly_PersistsNotifiedOutcomeAndExplanation StringAssert.Contains(added.AutomationExplanationJson!, "\"passed\":true"); _mockRepo.Verify(repository => repository.UpdateAsync( It.IsAny(), It.IsAny()), Times.Never); + _mockNotificationPublisher.Verify(publisher => publisher.PublishAsync( + It.Is(notification => + notification.Type == NotificationEventType.ReleaseMatched + && notification.DeduplicationKey == $"release-matched:{added.Id}" + && notification.DeepLink.Contains(added.Id.ToString(), StringComparison.Ordinal)), + It.IsAny()), Times.Once); } [TestMethod] @@ -190,6 +200,10 @@ public async Task ProcessSingle_ManualConfirm_PersistsPendingConfirmationWithout _mockDownloadClient.Verify(client => client.SubmitDownloadTaskAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _mockNotificationPublisher.Verify(publisher => publisher.PublishAsync( + It.Is(notification => + notification.Type == NotificationEventType.DownloadPendingConfirmation), + It.IsAny()), Times.Once); } [TestMethod] @@ -256,6 +270,11 @@ public async Task ProcessSingle_AutoDownloadRejected_MarksFailed() SubscriptionAutomationDisposition.AutoDownloadFailed, It.Is(token => token.CanBeCanceled && !token.IsCancellationRequested)), Times.Once); + _mockNotificationPublisher.Verify(publisher => publisher.PublishAsync( + It.Is(notification => + notification.Type == NotificationEventType.DownloadFailed + && notification.DeduplicationKey.StartsWith("auto-download-failed:", StringComparison.Ordinal)), + It.IsAny()), Times.Once); } [TestMethod] diff --git a/SecondDimensionWatcherReDive.Test/TodosControllerTests.cs b/SecondDimensionWatcherReDive.Test/TodosControllerTests.cs new file mode 100644 index 00000000..3c1f4a64 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/TodosControllerTests.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using Moq; +using SecondDimensionWatcherReDive.Controllers; +using SecondDimensionWatcherReDive.Controllers.External; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class TodosControllerTests +{ + [TestMethod] + public async Task UpdateStateAsync_MarkRead_OnlyPersistsPresentationState() + { + var id = Guid.NewGuid(); + var repository = new Mock(); + var controller = new TodosController(repository.Object); + + var result = await controller.UpdateStateAsync( + new UpdateTodoStateRequest([$"automation:{id}"], TodoStateAction.MarkRead, null), + CancellationToken.None); + + Assert.IsInstanceOfType(result); + repository.Verify(candidate => candidate.SetStateAsync( + It.Is>(keys => keys.Single() == $"automation:{id}"), + It.Is(value => value.HasValue), + true, + null, + false, + CancellationToken.None), Times.Once); + } + + [TestMethod] + public async Task UpdateStateAsync_SnoozeWithoutFutureTime_IsRejected() + { + var repository = new Mock(); + var controller = new TodosController(repository.Object); + + var result = await controller.UpdateStateAsync( + new UpdateTodoStateRequest( + [$"incident:{Guid.NewGuid()}"], + TodoStateAction.Snooze, + DateTimeOffset.UtcNow.AddMinutes(-1)), + CancellationToken.None); + + Assert.IsInstanceOfType(result); + repository.VerifyNoOtherCalls(); + } +} diff --git a/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsModels.cs b/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsModels.cs index 6c6722c0..7578d1b7 100644 --- a/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsModels.cs +++ b/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsModels.cs @@ -2,6 +2,7 @@ using System.Net; using System.Text.Json.Serialization; using Microsoft.Extensions.Configuration; +using SecondDimensionWatcherReDive.Framework.Notifications; namespace SecondDimensionWatcherReDive.Configuration; @@ -130,12 +131,20 @@ internal sealed record NfsSettingsValues( int LeaseSeconds, int MaxConnections); +internal sealed record NotificationSettingsValues( + bool WebhookEnabled, + IReadOnlyList Events, + TimeSpan? QuietHoursStart, + TimeSpan? QuietHoursEnd, + string TimeZoneId); + internal sealed record RuntimeSettingsValues( AiSettingsValues Ai, TorrentSettingsValues Torrent, MediaLibrarySettingsValues MediaLibrary, IncidentSettingsValues Incidents, - NfsSettingsValues Nfs); + NfsSettingsValues Nfs, + NotificationSettingsValues Notifications); internal sealed record RuntimeSettingsOverrides { @@ -148,6 +157,8 @@ internal sealed record RuntimeSettingsOverrides public IncidentSettingsValues? Incidents { get; init; } public NfsSettingsValues? Nfs { get; init; } + + public NotificationSettingsValues? Notifications { get; init; } } internal sealed record PersistedSecret(PersistedSecretMode Mode, string? Value); @@ -174,6 +185,10 @@ internal sealed record TorrentSettingsUpdate( TorrentSettingsValues Values, SecretMutation? Password); +internal sealed record NotificationSettingsUpdate( + NotificationSettingsValues Values, + SecretMutation? WebhookUrl); + internal sealed record RuntimeSettingsPatch( long ExpectedRevision, AiSettingsUpdate? Ai, @@ -181,7 +196,8 @@ internal sealed record RuntimeSettingsPatch( TorrentSettingsUpdate? Torrent, MediaLibrarySettingsValues? MediaLibrary, IncidentSettingsValues? Incidents, - NfsSettingsValues? Nfs); + NfsSettingsValues? Nfs, + NotificationSettingsUpdate? Notifications = null); internal sealed record ResolvedSecret( string? Value, @@ -213,6 +229,7 @@ internal static class RuntimeSecretKeys public const string CodexToken = "AI:CodexAppServer:BearerToken"; public const string TmdbApiKey = "TmdbApiKey"; public const string TorrentPassword = "Torrent:Remote:Password"; + public const string NotificationWebhookUrl = "Notifications:Webhook:Url"; public static readonly string[] All = [ @@ -220,7 +237,8 @@ internal static class RuntimeSecretKeys AnthropicApiKey, CodexToken, TmdbApiKey, - TorrentPassword + TorrentPassword, + NotificationWebhookUrl ]; } @@ -251,7 +269,13 @@ public static RuntimeSettingsValues FromConfiguration(IConfiguration configurati configuration.GetValue("Nfs:Port") ?? 2049, configuration["Nfs:BindAddress"] ?? "0.0.0.0", configuration.GetValue("Nfs:LeaseSeconds") ?? 90, - configuration.GetValue("Nfs:MaxConnections") ?? 32)); + configuration.GetValue("Nfs:MaxConnections") ?? 32), + new NotificationSettingsValues( + configuration.GetValue("Notifications:Webhook:Enabled") ?? false, + ReadNotificationEvents(configuration["Notifications:Events"]), + configuration.GetValue("Notifications:QuietHours:Start"), + configuration.GetValue("Notifications:QuietHours:End"), + configuration["Notifications:QuietHours:TimeZone"] ?? "UTC")); public static IReadOnlyDictionary ReadDeploymentSecrets( IConfiguration configuration) => @@ -288,6 +312,20 @@ private static TimeSpan ReadTimeSpan( TimeSpan fallback) => configuration.GetValue(key) ?? fallback; + private static IReadOnlyList ReadNotificationEvents(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return Enum.GetValues() + .Where(type => type != NotificationEventType.Test) + .ToArray(); + + return value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(item => Enum.TryParse(item, true, out var parsed) + ? parsed + : (NotificationEventType)(-1)) + .ToArray(); + } + private static TEnum ParseEnum(string? value, TEnum fallback) where TEnum : struct, Enum { @@ -391,6 +429,36 @@ public static IReadOnlyDictionary Validate( RequireRange(errors, "nfs.leaseSeconds", values.Nfs.LeaseSeconds, 1, int.MaxValue); RequireRange(errors, "nfs.maxConnections", values.Nfs.MaxConnections, 1, int.MaxValue); + if (values.Notifications.Events.Count == 0) + Add(errors, "notifications.events", "Select at least one notification event."); + if (values.Notifications.Events.Any(type => !Enum.IsDefined(type) || type == NotificationEventType.Test)) + Add(errors, "notifications.events", "The notification event selection is invalid."); + if (values.Notifications.QuietHoursStart.HasValue != values.Notifications.QuietHoursEnd.HasValue) + Add(errors, "notifications.quietHours", "Both quiet-hour boundaries are required."); + if (values.Notifications.QuietHoursStart is { } start + && (start < TimeSpan.Zero || start >= TimeSpan.FromDays(1))) + Add(errors, "notifications.quietHours.start", "The time must be within one day."); + if (values.Notifications.QuietHoursEnd is { } end + && (end < TimeSpan.Zero || end >= TimeSpan.FromDays(1))) + Add(errors, "notifications.quietHours.end", "The time must be within one day."); + try + { + _ = TimeZoneInfo.FindSystemTimeZoneById(values.Notifications.TimeZoneId); + } + catch (TimeZoneNotFoundException) + { + Add(errors, "notifications.quietHours.timeZoneId", "The time zone is unknown to the server."); + } + catch (InvalidTimeZoneException) + { + Add(errors, "notifications.quietHours.timeZoneId", "The time zone is invalid."); + } + if (values.Notifications.WebhookEnabled + && !secrets[RuntimeSecretKeys.NotificationWebhookUrl].IsConfigured) + Add(errors, "notifications.webhook.url", "A webhook URL is required when the channel is enabled."); + if (secrets[RuntimeSecretKeys.NotificationWebhookUrl] is { IsConfigured: true, Value: { } webhookUrl }) + ValidateWebhookUri(errors, "notifications.webhook.url", webhookUrl); + foreach (var key in RuntimeSecretKeys.All) { if (secrets.TryGetValue(key, out var secret) @@ -437,6 +505,19 @@ private static void ValidateWebSocketUri( "Plain ws is allowed only for loopback app-server endpoints; use wss for remote endpoints."); } + private static void ValidateWebhookUri( + Dictionary> errors, + string key, + string value) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + || !string.IsNullOrEmpty(uri.UserInfo) + || !string.IsNullOrEmpty(uri.Fragment)) + Add(errors, key, + "The webhook must be an absolute HTTP or HTTPS URL without user information or a fragment."); + } + private static void ValidateUserAgent( Dictionary> errors, string key, @@ -494,6 +575,7 @@ private static void RequireNonNegative( RuntimeSecretKeys.CodexToken => "ai.codexAppServer.token", RuntimeSecretKeys.TmdbApiKey => "tmdb.apiKey", RuntimeSecretKeys.TorrentPassword => "torrent.password", + RuntimeSecretKeys.NotificationWebhookUrl => "notifications.webhook.url", _ => key }; @@ -571,6 +653,15 @@ internal static class RuntimeSettingsFlattener flattened["Nfs:LeaseSeconds"] = values.Nfs.LeaseSeconds.ToString(CultureInfo.InvariantCulture); flattened["Nfs:MaxConnections"] = values.Nfs.MaxConnections.ToString(CultureInfo.InvariantCulture); + flattened["Notifications:Webhook:Enabled"] = + values.Notifications.WebhookEnabled.ToString(CultureInfo.InvariantCulture); + flattened["Notifications:Events"] = string.Join(',', values.Notifications.Events); + flattened["Notifications:QuietHours:Start"] = + values.Notifications.QuietHoursStart?.ToString("c", CultureInfo.InvariantCulture); + flattened["Notifications:QuietHours:End"] = + values.Notifications.QuietHoursEnd?.ToString("c", CultureInfo.InvariantCulture); + flattened["Notifications:QuietHours:TimeZone"] = values.Notifications.TimeZoneId; + foreach (var key in RuntimeSecretKeys.All) flattened[key] = secrets.TryGetValue(key, out var secret) && secret.IsConfigured ? secret.Value diff --git a/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsService.cs b/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsService.cs index a1b8bf59..27b3efb9 100644 --- a/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsService.cs +++ b/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsService.cs @@ -316,7 +316,8 @@ current with Torrent = patch.Torrent?.Values ?? current.Torrent, MediaLibrary = patch.MediaLibrary ?? current.MediaLibrary, Incidents = patch.Incidents ?? current.Incidents, - Nfs = patch.Nfs ?? current.Nfs + Nfs = patch.Nfs ?? current.Nfs, + Notifications = patch.Notifications?.Values ?? current.Notifications }; private static RuntimeSecretOverrides ApplySecrets( @@ -329,6 +330,7 @@ private static RuntimeSecretOverrides ApplySecrets( ApplySecret(values, RuntimeSecretKeys.CodexToken, patch.Ai?.CodexToken); ApplySecret(values, RuntimeSecretKeys.TmdbApiKey, patch.Tmdb?.ApiKey); ApplySecret(values, RuntimeSecretKeys.TorrentPassword, patch.Torrent?.Password); + ApplySecret(values, RuntimeSecretKeys.NotificationWebhookUrl, patch.Notifications?.WebhookUrl); return new RuntimeSecretOverrides { Values = values }; } @@ -423,6 +425,7 @@ private static IReadOnlyDictionary ValidateSecretMutations( ValidateSecretMutation(errors, "ai.codexAppServer.token", patch.Ai?.CodexToken); ValidateSecretMutation(errors, "tmdb.apiKey", patch.Tmdb?.ApiKey); ValidateSecretMutation(errors, "torrent.password", patch.Torrent?.Password); + ValidateSecretMutation(errors, "notifications.webhook.url", patch.Notifications?.WebhookUrl); return errors; } @@ -657,7 +660,8 @@ private static RuntimeSettingsValues Merge( overrides.Torrent ?? deployment.Torrent, overrides.MediaLibrary ?? deployment.MediaLibrary, overrides.Incidents ?? deployment.Incidents, - overrides.Nfs ?? deployment.Nfs); + overrides.Nfs ?? deployment.Nfs, + overrides.Notifications ?? deployment.Notifications); private void EnsureInitialized() { diff --git a/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs b/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs index 8e3ee5b9..ba3498dd 100644 --- a/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs +++ b/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Caching.Distributed; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.FileDownload; +using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.Utils.FileStore; using SecondDimensionWatcherReDive.Utils.Incidents; @@ -19,7 +20,8 @@ internal class AnimationInfoController( IDistributedCache distributedCache, IFileDownloadClientProvider fileDownloadClientProvider, IFileMapper fileMapper, - IIncidentReporter? incidentReporter = null) + IIncidentReporter? incidentReporter = null, + INotificationPublisher? notificationPublisher = null) : ControllerBase { [HttpGet] @@ -104,6 +106,7 @@ await CompensateFailedStartAsync( downloadClient, downloadAttemptId, remoteMayHaveAccepted: false); + await PublishDownloadFailureAsync(info, downloadAttemptId, cancellationToken); return BadRequest(); } } @@ -122,12 +125,25 @@ await CompensateFailedStartAsync( // Preserve the initiating exception. A conditional cleanup can // be retried safely by the tracker or a later cancellation. } + await PublishDownloadFailureAsync(info, downloadAttemptId, cancellationToken); throw; } return Ok(); } + private Task PublishDownloadFailureAsync( + Framework.DataRepository.AnimationInfo info, + Guid downloadAttemptId, + CancellationToken cancellationToken) => + notificationPublisher?.PublishAsync(new NotificationEvent( + NotificationEventType.DownloadFailed, + $"download-failed:{info.Id}:{downloadAttemptId}", + "Download failed to start", + info.Title, + info.Animation is null ? "/" : $"/anime/{info.Animation.TmdbId}"), cancellationToken) + ?? Task.CompletedTask; + [HttpPost("pause/{id:guid}")] public async Task PauseDownload([FromRoute] Guid id, CancellationToken cancellationToken) { diff --git a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs index 0f203097..3db9e256 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs @@ -65,4 +65,9 @@ namespace SecondDimensionWatcherReDive.Controllers.External; [JsonSerializable(typeof(QueueMediaLibraryScanResponse))] [JsonSerializable(typeof(ApplicationSettingsResponse))] [JsonSerializable(typeof(PatchApplicationSettingsRequest))] +[JsonSerializable(typeof(NotificationDeliveryItem))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(TestNotificationResponse))] +[JsonSerializable(typeof(TodoListResponse))] +[JsonSerializable(typeof(UpdateTodoStateRequest))] internal partial class AppJsonSerializerContext : JsonSerializerContext; diff --git a/SecondDimensionWatcherReDive/Controllers/External/ApplicationSettings.cs b/SecondDimensionWatcherReDive/Controllers/External/ApplicationSettings.cs index b9d23dc1..80eb1d2f 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/ApplicationSettings.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/ApplicationSettings.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using SecondDimensionWatcherReDive.Configuration; +using SecondDimensionWatcherReDive.Framework.Notifications; namespace SecondDimensionWatcherReDive.Controllers.External; @@ -71,6 +72,14 @@ internal sealed record NfsSettingsResponse( bool RestartRequired, bool PendingRestart); +internal sealed record NotificationSettingsResponse( + bool WebhookEnabled, + IReadOnlyList Events, + TimeSpan? QuietHoursStart, + TimeSpan? QuietHoursEnd, + string TimeZoneId, + SecretStateResponse WebhookUrl); + internal sealed record ApplicationSettingsResponse( long Revision, bool PendingRestart, @@ -79,7 +88,8 @@ internal sealed record ApplicationSettingsResponse( TorrentSettingsResponse Torrent, MediaLibrarySettingsResponse MediaLibrary, IncidentSettingsResponse Incidents, - NfsSettingsResponse Nfs); + NfsSettingsResponse Nfs, + NotificationSettingsResponse Notifications); internal sealed record SecretMutationRequest( [property: Required] SecretMutationOperation? Operation, @@ -148,6 +158,14 @@ internal sealed record NfsSettingsPatchRequest( [property: Required] int? LeaseSeconds, [property: Required] int? MaxConnections); +internal sealed record NotificationSettingsPatchRequest( + [property: Required] bool? WebhookEnabled, + [property: Required] IReadOnlyList? Events, + TimeSpan? QuietHoursStart, + TimeSpan? QuietHoursEnd, + [property: Required] string? TimeZoneId, + SecretMutationRequest? WebhookUrl); + internal sealed record PatchApplicationSettingsRequest( long ExpectedRevision, AiSettingsPatchRequest? Ai, @@ -155,4 +173,5 @@ internal sealed record PatchApplicationSettingsRequest( TorrentSettingsPatchRequest? Torrent, MediaLibrarySettingsPatchRequest? MediaLibrary, IncidentSettingsPatchRequest? Incidents, - NfsSettingsPatchRequest? Nfs); + NfsSettingsPatchRequest? Nfs, + NotificationSettingsPatchRequest? Notifications = null); diff --git a/SecondDimensionWatcherReDive/Controllers/External/Notifications.cs b/SecondDimensionWatcherReDive/Controllers/External/Notifications.cs new file mode 100644 index 00000000..2c9590cd --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/External/Notifications.cs @@ -0,0 +1,13 @@ +namespace SecondDimensionWatcherReDive.Controllers.External; + +internal sealed record NotificationDeliveryItem( + Guid Id, + string Type, + string Status, + int AttemptCount, + DateTimeOffset OccurredAt, + DateTimeOffset? LastAttemptAt, + DateTimeOffset? DeliveredAt, + string? LastError); + +internal sealed record TestNotificationResponse(Guid EventId); diff --git a/SecondDimensionWatcherReDive/Controllers/External/Todos.cs b/SecondDimensionWatcherReDive/Controllers/External/Todos.cs new file mode 100644 index 00000000..650d670a --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/External/Todos.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SecondDimensionWatcherReDive.Controllers.External; + +internal sealed record TodoItemResponse( + string Key, + string Type, + string Priority, + string Title, + string Detail, + string DeepLink, + Guid? ResourceId, + DateTimeOffset OccurredAt, + DateTimeOffset? ReadAt, + DateTimeOffset? SnoozedUntil); + +internal sealed record TodoListResponse( + IReadOnlyList Items, + int TotalCount, + int UnreadCount); + +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum TodoStateAction +{ + [JsonStringEnumMemberName("markRead")] + MarkRead, + [JsonStringEnumMemberName("markUnread")] + MarkUnread, + [JsonStringEnumMemberName("snooze")] + Snooze, + [JsonStringEnumMemberName("unsnooze")] + Unsnooze +} + +internal sealed record UpdateTodoStateRequest( + [property: Required, MinLength(1), MaxLength(100)] IReadOnlyList Keys, + [property: Required] TodoStateAction Action, + DateTimeOffset? SnoozedUntil); diff --git a/SecondDimensionWatcherReDive/Controllers/NotificationsController.cs b/SecondDimensionWatcherReDive/Controllers/NotificationsController.cs new file mode 100644 index 00000000..c55f903f --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/NotificationsController.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Controllers.External; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; + +namespace SecondDimensionWatcherReDive.Controllers; + +[ApiController] +[Route("api/notifications")] +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] +internal sealed class NotificationsController( + INotificationPublisher publisher, + INotificationOutboxRepository outboxRepository, + IConfiguration configuration) : ControllerBase +{ + [HttpPost("test")] + public async Task> SendTestAsync( + CancellationToken cancellationToken) + { + if (!configuration.GetValue("Notifications:Webhook:Enabled") + || string.IsNullOrWhiteSpace(configuration["Notifications:Webhook:Url"])) + return Conflict(new { message = "Enable and configure the webhook channel first." }); + + var id = Guid.NewGuid(); + await publisher.PublishAsync(new NotificationEvent( + NotificationEventType.Test, + $"test:{id}", + "SecondDimensionWatcher Re:Dive test", + "Your webhook notification channel is configured correctly.", + "/settings?section=notifications", + Id: id), cancellationToken); + return Accepted(new TestNotificationResponse(id)); + } + + [HttpGet("deliveries")] + public async Task>> GetDeliveriesAsync( + [FromQuery] int take = 20, + CancellationToken cancellationToken = default) + { + take = Math.Clamp(take, 1, 100); + var items = await outboxRepository.GetRecentAsync(take, cancellationToken); + return Ok(items.Select(item => new NotificationDeliveryItem( + item.Id, + ToJsonName(item.Type), + item.Status.ToString(), + item.AttemptCount, + item.OccurredAt, + item.LastAttemptAt, + item.DeliveredAt, + item.LastError)).ToList()); + } + + private static string ToJsonName(NotificationEventType type) + { + var name = type.ToString(); + return char.ToLowerInvariant(name[0]) + name[1..]; + } +} diff --git a/SecondDimensionWatcherReDive/Controllers/SettingsController.cs b/SecondDimensionWatcherReDive/Controllers/SettingsController.cs index 5bab8533..39dbd8a6 100644 --- a/SecondDimensionWatcherReDive/Controllers/SettingsController.cs +++ b/SecondDimensionWatcherReDive/Controllers/SettingsController.cs @@ -30,7 +30,8 @@ public async Task PatchSettingsAsync( && request.Torrent is null && request.MediaLibrary is null && request.Incidents is null - && request.Nfs is null) + && request.Nfs is null + && request.Notifications is null) { ModelState.AddModelError(string.Empty, "At least one settings section is required."); return ValidationProblem(ModelState); @@ -62,6 +63,7 @@ private bool TryMapPatch( var mediaLibrary = MapMediaLibrary(request.MediaLibrary); var incidents = MapIncidents(request.Incidents); var nfs = MapNfs(request.Nfs); + var notifications = MapNotifications(request.Notifications); patch = new RuntimeSettingsPatch( request.ExpectedRevision, @@ -72,7 +74,8 @@ request.Tmdb is null torrent, mediaLibrary, incidents, - nfs); + nfs, + notifications); return ModelState.IsValid; } @@ -227,6 +230,27 @@ request.Tmdb is null : null; } + private NotificationSettingsUpdate? MapNotifications( + NotificationSettingsPatchRequest? request) + { + if (request is null) + return null; + if (request.WebhookEnabled is null) AddRequired("notifications.webhookEnabled"); + if (request.Events is null) AddRequired("notifications.events"); + if (request.TimeZoneId is null) AddRequired("notifications.timeZoneId"); + if (!ModelState.IsValid) + return null; + + return new NotificationSettingsUpdate( + new NotificationSettingsValues( + request.WebhookEnabled!.Value, + request.Events!, + request.QuietHoursStart, + request.QuietHoursEnd, + request.TimeZoneId!), + MapSecret(request.WebhookUrl, "notifications.webhook.url")); + } + private SecretMutation? MapSecret(SecretMutationRequest? request, string path) { if (request is null) @@ -296,7 +320,14 @@ private static ApplicationSettingsResponse ToResponse(RuntimeSettingsState state values.Nfs.LeaseSeconds, values.Nfs.MaxConnections, RestartRequired: true, - state.PendingRestart)); + state.PendingRestart), + new NotificationSettingsResponse( + values.Notifications.WebhookEnabled, + values.Notifications.Events, + values.Notifications.QuietHoursStart, + values.Notifications.QuietHoursEnd, + values.Notifications.TimeZoneId, + Secret(state, RuntimeSecretKeys.NotificationWebhookUrl))); } private static SecretStateResponse Secret(RuntimeSettingsState state, string key) diff --git a/SecondDimensionWatcherReDive/Controllers/TodosController.cs b/SecondDimensionWatcherReDive/Controllers/TodosController.cs new file mode 100644 index 00000000..f109eedb --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/TodosController.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Controllers.External; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Controllers; + +[ApiController] +[Route("api/todos")] +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] +internal sealed class TodosController(ITodoRepository todoRepository) : ControllerBase +{ + [HttpGet] + public async Task> GetAsync( + [FromQuery] bool includeRead = false, + [FromQuery] bool includeSnoozed = false, + CancellationToken cancellationToken = default) + { + var page = await todoRepository.GetAsync( + includeRead, includeSnoozed, DateTimeOffset.UtcNow, cancellationToken); + return Ok(new TodoListResponse( + page.Items.Select(item => new TodoItemResponse( + item.Key, + item.Type.ToString(), + item.Priority.ToString(), + item.Title, + item.Detail, + item.DeepLink, + item.ResourceId, + item.OccurredAt, + item.ReadAt, + item.SnoozedUntil)).ToList(), + page.TotalCount, + page.UnreadCount)); + } + + [HttpPatch("state")] + public async Task UpdateStateAsync( + [FromBody] UpdateTodoStateRequest request, + CancellationToken cancellationToken) + { + var keys = request.Keys + .Where(IsValidKey) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (keys.Length != request.Keys.Count) + { + ModelState.AddModelError("keys", "Todo keys must be unique valid resource keys."); + return ValidationProblem(ModelState); + } + + var now = DateTimeOffset.UtcNow; + DateTimeOffset? readAt = null; + DateTimeOffset? snoozedUntil = null; + var updateRead = false; + var updateSnooze = false; + switch (request.Action) + { + case TodoStateAction.MarkRead: + updateRead = true; + readAt = now; + break; + case TodoStateAction.MarkUnread: + updateRead = true; + break; + case TodoStateAction.Snooze: + if (request.SnoozedUntil is null || request.SnoozedUntil <= now) + { + ModelState.AddModelError("snoozedUntil", "A future time is required when snoozing."); + return ValidationProblem(ModelState); + } + updateSnooze = true; + snoozedUntil = request.SnoozedUntil; + break; + case TodoStateAction.Unsnooze: + updateSnooze = true; + break; + default: + return BadRequest(); + } + + await todoRepository.SetStateAsync( + keys, readAt, updateRead, snoozedUntil, updateSnooze, cancellationToken); + return NoContent(); + } + + private static bool IsValidKey(string? key) => + key is not null + && key.Length <= 128 + && (key.StartsWith("automation:", StringComparison.Ordinal) + || key.StartsWith("incident:", StringComparison.Ordinal) + || key.StartsWith("metadata:", StringComparison.Ordinal)) + && Guid.TryParse(key[(key.IndexOf(':') + 1)..], out _); +} diff --git a/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.Designer.cs b/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.Designer.cs new file mode 100644 index 00000000..8a0ad989 --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.Designer.cs @@ -0,0 +1,1071 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SecondDimensionWatcherReDive.Models; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260829135234_AddNotificationsAndTodoCenter")] + partial class AddNotificationsAndTodoCenter + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Animation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PosterPath") + .HasColumnType("text"); + + b.Property("TmdbId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("Animations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AnimationGroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalDownloadInfo") + .IsRequired() + .HasColumnType("text"); + + b.Property("AiRetryCount") + .HasColumnType("integer"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("AutomationDisposition") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AutomationExplanationJson") + .HasColumnType("text"); + + b.Property("CachedDownloadData") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("CurrentMetadataReviewOperationId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadAttemptId") + .HasColumnType("uuid"); + + b.Property("DownloadCancellationId") + .HasColumnType("uuid"); + + b.Property("DownloadEndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadType") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("FileStore") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAiProcessed") + .HasColumnType("boolean"); + + b.Property("IsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("IsDownloadTracked") + .HasColumnType("boolean"); + + b.Property("MediaLibraryMissingSince") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaLibrarySourceId") + .HasColumnType("uuid"); + + b.Property("MetadataConfidence") + .HasColumnType("double precision"); + + b.Property("MetadataLastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MetadataStatus") + .HasColumnType("integer"); + + b.Property("PublishTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleaseSizeBytes") + .HasColumnType("bigint"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SourceFeedId") + .HasColumnType("uuid"); + + b.Property("StateVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("StorePath") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AnimationId"); + + b.HasIndex("CurrentMetadataReviewOperationId") + .IsUnique(); + + b.HasIndex("GroupId"); + + b.HasIndex("MediaLibrarySourceId"); + + b.HasIndex("SourceFeedId"); + + b.HasIndex("FileStore", "StorePath") + .IsUnique() + .HasFilter("\"DownloadType\" = 'http://schemas.hcgstudio.com/ws/2023/06/sdw/downloadtype/media-library-import'"); + + b.HasIndex("MetadataStatus", "PublishTime"); + + b.ToTable("AnimationInfo", t => + { + t.HasCheckConstraint("CK_AnimationInfo_MetadataConfidence_Range", "\"MetadataConfidence\" IS NULL OR (\"MetadataConfidence\" >= 0 AND \"MetadataConfidence\" <= 1)"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ApplicationSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ProtectedSecrets") + .HasColumnType("text"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings", t => + { + t.HasCheckConstraint("CK_ApplicationSettings_Revision_Positive", "\"Revision\" > 0"); + + t.HasCheckConstraint("CK_ApplicationSettings_Singleton", "\"Id\" = 1"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MikanSubgroupId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SeasonBangumiId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SeasonBangumiId", "MikanSubgroupId") + .IsUnique(); + + b.ToTable("BangumiSubgroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ChatConversations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToolCallId") + .HasColumnType("text"); + + b.Property("ToolCallsJson") + .HasColumnType("text"); + + b.Property("ToolName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Feed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Feeds"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("VirtualPath") + .IsUnique(); + + b.ToTable("FileMappings"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("AnimationId", "CreatedAt"); + + b.HasIndex("AnimationId", "Pattern") + .IsUnique(); + + b.ToTable("FileNameRegexRules"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Incident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DetectedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastRetryError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Fingerprint") + .IsUnique(); + + b.HasIndex("ResolvedAt", "Type", "UpdatedAt"); + + b.ToTable("Incidents"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MediaLibrarySource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsMonitoring") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastImportedCount") + .HasColumnType("integer"); + + b.Property("LastRemovedCount") + .HasColumnType("integer"); + + b.Property("LastScanAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSkippedCount") + .HasColumnType("integer"); + + b.Property("LastUpdatedCount") + .HasColumnType("integer"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("MediaLibrarySources"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OperationId", "Kind", "VirtualPath") + .IsUnique(); + + b.ToTable("MetadataReviewMappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AppliedVersion") + .HasColumnType("bigint"); + + b.Property("BaseFileStore") + .HasColumnType("text"); + + b.Property("BaseIsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("BaseStorePath") + .HasColumnType("text"); + + b.Property("BaseVersion") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousAiRetryCount") + .HasColumnType("integer"); + + b.Property("PreviousAnimationId") + .HasColumnType("uuid"); + + b.Property("PreviousConfidence") + .HasColumnType("double precision"); + + b.Property("PreviousCurrentOperationId") + .HasColumnType("uuid"); + + b.Property("PreviousDescription") + .HasColumnType("text"); + + b.Property("PreviousEpisode") + .HasColumnType("integer"); + + b.Property("PreviousGroupId") + .HasColumnType("uuid"); + + b.Property("PreviousIsAiProcessed") + .HasColumnType("boolean"); + + b.Property("PreviousLastError") + .HasColumnType("text"); + + b.Property("PreviousMetadataStatus") + .HasColumnType("integer"); + + b.Property("PreviousReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousSeason") + .HasColumnType("integer"); + + b.Property("ProposedAnimationName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationOriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationPosterPath") + .HasColumnType("text"); + + b.Property("ProposedAnimationTmdbId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedEpisode") + .HasColumnType("integer"); + + b.Property("ProposedGroupName") + .HasColumnType("text"); + + b.Property("ProposedSeason") + .HasColumnType("integer"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("UndoneAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId", "AppliedVersion") + .IsUnique(); + + b.HasIndex("AnimationInfoId", "State"); + + b.HasIndex("State", "ExpiresAt"); + + b.ToTable("MetadataReviewOperations", t => + { + t.HasCheckConstraint("CK_MetadataReviewOperations_Expiry", "\"ExpiresAt\" > \"CreatedAt\""); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MigrationMarker", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("MigrationMarkers"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.NotificationOutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DeduplicationKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DeepLink") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DeliveredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PayloadJson") + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.HasKey("Id"); + + b.HasIndex("DeduplicationKey") + .IsUnique(); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("NotificationOutboxMessages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AutoPlayNext") + .HasColumnType("boolean"); + + b.Property("SubtitleLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SubtitleTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("PlaybackPreferences"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsWatched") + .HasColumnType("boolean"); + + b.Property("PositionSeconds") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VirtualPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("WatchedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("UserId", "AnimationInfoId", "VirtualPath") + .IsUnique(); + + b.HasIndex("UserId", "IsWatched", "UpdatedAt"); + + b.ToTable("PlaybackProgresses", t => + { + t.HasCheckConstraint("CK_PlaybackProgresses_Duration_NonNegative", "\"DurationSeconds\" >= 0"); + + t.HasCheckConstraint("CK_PlaybackProgresses_Position_NonNegative", "\"PositionSeconds\" >= 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("MikanId") + .HasColumnType("integer"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MikanId") + .IsUnique(); + + b.ToTable("SeasonBangumis"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.Property("FeedId") + .HasColumnType("uuid"); + + b.PrimitiveCollection("Codecs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection("ExcludedKeywords") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("Languages") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("MaxSizeBytes") + .HasColumnType("bigint"); + + b.Property("MinSizeBytes") + .HasColumnType("bigint"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.PrimitiveCollection("Resolutions") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("SubtitleGroups") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("FeedId"); + + b.HasIndex("UpdatedAt"); + + b.ToTable("SubscriptionAutomationPolicies"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.TodoItemState", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SnoozedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("TodoItemStates"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebDavToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("WebDavTokens"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", "Animation") + .WithMany() + .HasForeignKey("AnimationId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationGroup", "Group") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.MediaLibrarySource", null) + .WithMany() + .HasForeignKey("MediaLibrarySourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", null) + .WithMany() + .HasForeignKey("SourceFeedId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Animation"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.SeasonBangumi", "SeasonBangumi") + .WithMany("Subgroups") + .HasForeignKey("SeasonBangumiId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SeasonBangumi"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.ChatConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", null) + .WithMany() + .HasForeignKey("AnimationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", "Operation") + .WithMany("MappingSnapshots") + .HasForeignKey("OperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", "Feed") + .WithOne() + .HasForeignKey("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", "FeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Feed"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Navigation("MappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Navigation("Subgroups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.cs b/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.cs new file mode 100644 index 00000000..d0c665a5 --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.cs @@ -0,0 +1,74 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + /// + public partial class AddNotificationsAndTodoCenter : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "NotificationOutboxMessages", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + DeduplicationKey = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Type = table.Column(type: "character varying(48)", maxLength: 48, nullable: false), + Title = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Body = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + DeepLink = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + PayloadJson = table.Column(type: "jsonb", nullable: true), + OccurredAt = table.Column(type: "timestamp with time zone", nullable: false), + Status = table.Column(type: "character varying(24)", maxLength: 24, nullable: false), + AttemptCount = table.Column(type: "integer", nullable: false), + NextAttemptAt = table.Column(type: "timestamp with time zone", nullable: false), + LastAttemptAt = table.Column(type: "timestamp with time zone", nullable: true), + DeliveredAt = table.Column(type: "timestamp with time zone", nullable: true), + LastError = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_NotificationOutboxMessages", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TodoItemStates", + columns: table => new + { + Key = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ReadAt = table.Column(type: "timestamp with time zone", nullable: true), + SnoozedUntil = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TodoItemStates", x => x.Key); + }); + + migrationBuilder.CreateIndex( + name: "IX_NotificationOutboxMessages_DeduplicationKey", + table: "NotificationOutboxMessages", + column: "DeduplicationKey", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_NotificationOutboxMessages_Status_NextAttemptAt", + table: "NotificationOutboxMessages", + columns: new[] { "Status", "NextAttemptAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "NotificationOutboxMessages"); + + migrationBuilder.DropTable( + name: "TodoItemStates"); + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs index 8126b9e1..5e7a1090 100644 --- a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs +++ b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs @@ -671,6 +671,74 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("MigrationMarkers"); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.NotificationOutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DeduplicationKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DeepLink") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DeliveredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PayloadJson") + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.HasKey("Id"); + + b.HasIndex("DeduplicationKey") + .IsUnique(); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("NotificationOutboxMessages"); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => { b.Property("UserId") @@ -832,6 +900,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SubscriptionAutomationPolicies"); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.TodoItemState", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SnoozedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("TodoItemStates"); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebDavToken", b => { b.Property("Id") diff --git a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs index 59764ac2..ccb9065b 100644 --- a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs +++ b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs @@ -33,9 +33,59 @@ public ApplicationContext(DbContextOptions options) public DbSet PlaybackPreferences { get; set; } public DbSet MediaLibrarySources { get; set; } public DbSet ApplicationSettings { get; set; } + public DbSet NotificationOutboxMessages { get; set; } + public DbSet TodoItemStates { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity() + .HasIndex(message => message.DeduplicationKey) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(message => new { message.Status, message.NextAttemptAt }); + + modelBuilder.Entity() + .Property(message => message.DeduplicationKey) + .HasMaxLength(256); + + modelBuilder.Entity() + .Property(message => message.Type) + .HasConversion() + .HasMaxLength(48); + + modelBuilder.Entity() + .Property(message => message.Status) + .HasConversion() + .HasMaxLength(24); + + modelBuilder.Entity() + .Property(message => message.Title) + .HasMaxLength(256); + + modelBuilder.Entity() + .Property(message => message.Body) + .HasMaxLength(2048); + + modelBuilder.Entity() + .Property(message => message.DeepLink) + .HasMaxLength(2048); + + modelBuilder.Entity() + .Property(message => message.PayloadJson) + .HasColumnType("jsonb"); + + modelBuilder.Entity() + .Property(message => message.LastError) + .HasMaxLength(2048); + + modelBuilder.Entity() + .HasKey(state => state.Key); + + modelBuilder.Entity() + .Property(state => state.Key) + .HasMaxLength(128); + modelBuilder.Entity() .Property(settings => settings.Id) .ValueGeneratedNever(); diff --git a/SecondDimensionWatcherReDive/Models/NotificationOutboxMessage.cs b/SecondDimensionWatcherReDive/Models/NotificationOutboxMessage.cs new file mode 100644 index 00000000..afcce31f --- /dev/null +++ b/SecondDimensionWatcherReDive/Models/NotificationOutboxMessage.cs @@ -0,0 +1,22 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; + +namespace SecondDimensionWatcherReDive.Models; + +public sealed class NotificationOutboxMessage +{ + public Guid Id { get; set; } + public string DeduplicationKey { get; set; } = string.Empty; + public NotificationEventType Type { get; set; } + public string Title { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public string DeepLink { get; set; } = string.Empty; + public string? PayloadJson { get; set; } + public DateTimeOffset OccurredAt { get; set; } + public NotificationDeliveryStatus Status { get; set; } + public int AttemptCount { get; set; } + public DateTimeOffset NextAttemptAt { get; set; } + public DateTimeOffset? LastAttemptAt { get; set; } + public DateTimeOffset? DeliveredAt { get; set; } + public string? LastError { get; set; } +} diff --git a/SecondDimensionWatcherReDive/Models/TodoItemState.cs b/SecondDimensionWatcherReDive/Models/TodoItemState.cs new file mode 100644 index 00000000..c463f9aa --- /dev/null +++ b/SecondDimensionWatcherReDive/Models/TodoItemState.cs @@ -0,0 +1,9 @@ +namespace SecondDimensionWatcherReDive.Models; + +public sealed class TodoItemState +{ + public string Key { get; set; } = string.Empty; + public DateTimeOffset? ReadAt { get; set; } + public DateTimeOffset? SnoozedUntil { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/SecondDimensionWatcherReDive/Program.cs b/SecondDimensionWatcherReDive/Program.cs index 80f5f197..1fb96ddf 100644 --- a/SecondDimensionWatcherReDive/Program.cs +++ b/SecondDimensionWatcherReDive/Program.cs @@ -20,6 +20,7 @@ using SecondDimensionWatcherReDive.Framework.FileStore; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.Tasks; +using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.Inference.AI; using SecondDimensionWatcherReDive.Models; using SecondDimensionWatcherReDive.NFS; @@ -33,6 +34,7 @@ using SecondDimensionWatcherReDive.Utils.FileStore; using SecondDimensionWatcherReDive.Utils.MetadataReview; using SecondDimensionWatcherReDive.Utils.Incidents; +using SecondDimensionWatcherReDive.Utils.Notifications; using SecondDimensionWatcherReDive.Utils.Scraper; var builder = WebApplication.CreateBuilder(args); @@ -204,6 +206,16 @@ } }); +builder.Services.AddHttpClient("NotificationWebhook") + // The destination URL can contain a token. Disable the factory's default + // request logger because it includes the complete request URI. + .RemoveAllLoggers() + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + // A redirect must not forward a secret-bearing webhook URL to another origin. + AllowAutoRedirect = false + }); + var contentTypeProvider = new FileExtensionContentTypeProvider(); contentTypeProvider.Mappings.Add(".mkv", "video/x-matroska"); builder.Services.AddSingleton(contentTypeProvider); @@ -216,12 +228,14 @@ // Persistent incident inbox and health probes. builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); //Add hosting services builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); @@ -277,6 +291,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/SecondDimensionWatcherReDive/Repositories/NotificationOutboxRepository.cs b/SecondDimensionWatcherReDive/Repositories/NotificationOutboxRepository.cs new file mode 100644 index 00000000..549630ab --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/NotificationOutboxRepository.cs @@ -0,0 +1,157 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using OutboxEntity = SecondDimensionWatcherReDive.Models.NotificationOutboxMessage; + +namespace SecondDimensionWatcherReDive.Repositories; + +public sealed class NotificationOutboxRepository(Models.ApplicationContext context) + : INotificationOutboxRepository +{ + public async Task EnqueueAsync( + NotificationOutboxMessage message, + CancellationToken cancellationToken) + { + await context.NotificationOutboxMessages.AddAsync(ToEntity(message), cancellationToken); + try + { + await context.SaveChangesAsync(cancellationToken); + return true; + } + catch (DbUpdateException exception) when ( + exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + { + context.ChangeTracker.Clear(); + return false; + } + } + + public async Task> ClaimDueAsync( + DateTimeOffset now, + DateTimeOffset leaseUntil, + int take, + CancellationToken cancellationToken) + { + var candidateIds = await context.NotificationOutboxMessages + .AsNoTracking() + .Where(message => + (message.Status == NotificationDeliveryStatus.Pending + || message.Status == NotificationDeliveryStatus.Processing) + && message.NextAttemptAt <= now) + .OrderBy(message => message.NextAttemptAt) + .ThenBy(message => message.OccurredAt) + .Select(message => message.Id) + .Take(take) + .ToListAsync(cancellationToken); + + var claimedIds = new List(candidateIds.Count); + foreach (var id in candidateIds) + { + var affected = await context.NotificationOutboxMessages + .Where(message => message.Id == id + && (message.Status == NotificationDeliveryStatus.Pending + || message.Status == NotificationDeliveryStatus.Processing) + && message.NextAttemptAt <= now) + .ExecuteUpdateAsync(setters => setters + .SetProperty(message => message.Status, NotificationDeliveryStatus.Processing) + .SetProperty(message => message.NextAttemptAt, leaseUntil), cancellationToken); + if (affected == 1) claimedIds.Add(id); + } + + if (claimedIds.Count == 0) return []; + return (await context.NotificationOutboxMessages + .AsNoTracking() + .Where(message => claimedIds.Contains(message.Id)) + .OrderBy(message => message.OccurredAt) + .ToListAsync(cancellationToken)) + .Select(ToRecord) + .ToList(); + } + + public Task MarkDeliveredAsync( + Guid id, + DateTimeOffset deliveredAt, + CancellationToken cancellationToken) => + context.NotificationOutboxMessages + .Where(message => message.Id == id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(message => message.Status, NotificationDeliveryStatus.Delivered) + .SetProperty(message => message.AttemptCount, message => message.AttemptCount + 1) + .SetProperty(message => message.LastAttemptAt, deliveredAt) + .SetProperty(message => message.DeliveredAt, deliveredAt) + .SetProperty(message => message.LastError, (string?)null), cancellationToken); + + public Task MarkFailedAsync( + Guid id, + int attemptCount, + DateTimeOffset attemptedAt, + DateTimeOffset? nextAttemptAt, + string error, + CancellationToken cancellationToken) => + context.NotificationOutboxMessages + .Where(message => message.Id == id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(message => message.Status, + nextAttemptAt.HasValue + ? NotificationDeliveryStatus.Pending + : NotificationDeliveryStatus.Failed) + .SetProperty(message => message.AttemptCount, attemptCount) + .SetProperty(message => message.LastAttemptAt, attemptedAt) + .SetProperty(message => message.NextAttemptAt, nextAttemptAt ?? attemptedAt) + .SetProperty(message => message.LastError, error), cancellationToken); + + public Task RescheduleAsync( + Guid id, + DateTimeOffset nextAttemptAt, + CancellationToken cancellationToken) => + context.NotificationOutboxMessages + .Where(message => message.Id == id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(message => message.Status, NotificationDeliveryStatus.Pending) + .SetProperty(message => message.NextAttemptAt, nextAttemptAt), cancellationToken); + + public async Task> GetRecentAsync( + int take, + CancellationToken cancellationToken) => + (await context.NotificationOutboxMessages + .AsNoTracking() + .OrderByDescending(message => message.OccurredAt) + .Take(take) + .ToListAsync(cancellationToken)) + .Select(ToRecord) + .ToList(); + + private static NotificationOutboxMessage ToRecord(OutboxEntity message) => new( + message.Id, + message.DeduplicationKey, + message.Type, + message.Title, + message.Body, + message.DeepLink, + message.PayloadJson, + message.OccurredAt, + message.Status, + message.AttemptCount, + message.NextAttemptAt, + message.LastAttemptAt, + message.DeliveredAt, + message.LastError); + + private static OutboxEntity ToEntity(NotificationOutboxMessage message) => new() + { + Id = message.Id, + DeduplicationKey = message.DeduplicationKey, + Type = message.Type, + Title = message.Title, + Body = message.Body, + DeepLink = message.DeepLink, + PayloadJson = message.PayloadJson, + OccurredAt = message.OccurredAt, + Status = message.Status, + AttemptCount = message.AttemptCount, + NextAttemptAt = message.NextAttemptAt, + LastAttemptAt = message.LastAttemptAt, + DeliveredAt = message.DeliveredAt, + LastError = message.LastError + }; +} diff --git a/SecondDimensionWatcherReDive/Repositories/TodoRepository.cs b/SecondDimensionWatcherReDive/Repositories/TodoRepository.cs new file mode 100644 index 00000000..55b55d9c --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/TodoRepository.cs @@ -0,0 +1,173 @@ +using Microsoft.EntityFrameworkCore; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Repositories; + +public sealed class TodoRepository(Models.ApplicationContext context) : ITodoRepository +{ + public async Task GetAsync( + bool includeRead, + bool includeSnoozed, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var automation = await context.AnimationInfo + .AsNoTracking() + .Where(info => info.AutomationDisposition == SubscriptionAutomationDisposition.Notified + || info.AutomationDisposition == SubscriptionAutomationDisposition.PendingConfirmation + || info.AutomationDisposition == SubscriptionAutomationDisposition.AutoDownloadFailed) + .Select(info => new + { + info.Id, + info.Title, + info.PublishTime, + info.AutomationDisposition + }) + .ToListAsync(cancellationToken); + + var incidents = await context.Incidents + .AsNoTracking() + .Where(incident => incident.ResolvedAt == null) + .Select(incident => new + { + incident.Id, + incident.Type, + incident.Severity, + incident.Title, + incident.Detail, + incident.DetectedAt + }) + .ToListAsync(cancellationToken); + + var metadata = await context.AnimationInfo + .AsNoTracking() + .Where(info => info.MetadataStatus == MetadataReviewStatus.LowConfidence + || info.MetadataStatus == MetadataReviewStatus.Failed) + .Select(info => new + { + info.Id, + info.Title, + info.MetadataStatus, + info.MetadataLastError, + info.PublishTime + }) + .ToListAsync(cancellationToken); + + var keys = automation.Select(info => $"automation:{info.Id}") + .Concat(incidents.Select(incident => $"incident:{incident.Id}")) + .Concat(metadata.Select(info => $"metadata:{info.Id}")) + .ToArray(); + var states = await context.TodoItemStates + .AsNoTracking() + .Where(state => keys.Contains(state.Key)) + .ToDictionaryAsync(state => state.Key, cancellationToken); + + var items = new List(keys.Length); + foreach (var info in automation) + { + var key = $"automation:{info.Id}"; + var (type, priority, detail) = info.AutomationDisposition switch + { + SubscriptionAutomationDisposition.PendingConfirmation => + (TodoItemType.DownloadPendingConfirmation, TodoPriority.High, + "A matched release is waiting for download confirmation."), + SubscriptionAutomationDisposition.AutoDownloadFailed => + (TodoItemType.DownloadFailed, TodoPriority.Critical, + "Automatic download could not be started. Review and retry it."), + _ => (TodoItemType.ReleaseMatched, TodoPriority.Normal, + "A notify-only subscription matched this release.") + }; + items.Add(Create( + key, type, priority, info.Title, detail, + $"/todo?focus={Uri.EscapeDataString(key)}", info.Id, info.PublishTime, states)); + } + + foreach (var incident in incidents) + { + var key = $"incident:{incident.Id}"; + var disk = incident.Type == IncidentType.DiskSpaceLow; + items.Add(Create( + key, + disk ? TodoItemType.DiskSpaceLow : TodoItemType.Incident, + incident.Severity == IncidentSeverity.Critical + ? TodoPriority.Critical + : TodoPriority.High, + incident.Title, + incident.Detail, + disk ? "/incidents?type=diskSpaceLow" : $"/incidents?focus={incident.Id}", + incident.Id, + incident.DetectedAt, + states)); + } + + foreach (var info in metadata) + { + var key = $"metadata:{info.Id}"; + items.Add(Create( + key, + TodoItemType.MetadataReview, + info.MetadataStatus == MetadataReviewStatus.Failed + ? TodoPriority.High + : TodoPriority.Normal, + info.Title, + info.MetadataLastError ?? "Metadata confidence is low and needs review.", + $"/metadata-review?status={(info.MetadataStatus == MetadataReviewStatus.Failed ? "failed" : "lowConfidence")}&focus={info.Id}", + info.Id, + info.PublishTime, + states)); + } + + var unreadCount = items.Count(item => item.ReadAt is null + && (item.SnoozedUntil is null || item.SnoozedUntil <= now)); + var filtered = items + .Where(item => includeRead || item.ReadAt is null) + .Where(item => includeSnoozed || item.SnoozedUntil is null || item.SnoozedUntil <= now) + .OrderByDescending(item => item.Priority) + .ThenByDescending(item => item.OccurredAt) + .ToList(); + return new TodoPage(filtered, filtered.Count, unreadCount); + } + + public async Task SetStateAsync( + IReadOnlyCollection keys, + DateTimeOffset? readAt, + bool updateReadAt, + DateTimeOffset? snoozedUntil, + bool updateSnoozedUntil, + CancellationToken cancellationToken) + { + var existing = await context.TodoItemStates + .Where(state => keys.Contains(state.Key)) + .ToDictionaryAsync(state => state.Key, cancellationToken); + var now = DateTimeOffset.UtcNow; + foreach (var key in keys) + { + if (!existing.TryGetValue(key, out var state)) + { + state = new Models.TodoItemState { Key = key }; + await context.TodoItemStates.AddAsync(state, cancellationToken); + } + if (updateReadAt) state.ReadAt = readAt; + if (updateSnoozedUntil) state.SnoozedUntil = snoozedUntil; + state.UpdatedAt = now; + } + await context.SaveChangesAsync(cancellationToken); + } + + private static TodoItem Create( + string key, + TodoItemType type, + TodoPriority priority, + string title, + string detail, + string deepLink, + Guid resourceId, + DateTimeOffset occurredAt, + IReadOnlyDictionary states) + { + states.TryGetValue(key, out var state); + return new TodoItem( + key, type, priority, title, detail, deepLink, resourceId, + occurredAt, state?.ReadAt, state?.SnoozedUntil); + } +} diff --git a/SecondDimensionWatcherReDive/Services/CompleteDownloadBackgroundService.cs b/SecondDimensionWatcherReDive/Services/CompleteDownloadBackgroundService.cs index d6f2eb9f..49ca2ac4 100644 --- a/SecondDimensionWatcherReDive/Services/CompleteDownloadBackgroundService.cs +++ b/SecondDimensionWatcherReDive/Services/CompleteDownloadBackgroundService.cs @@ -2,6 +2,7 @@ using SecondDimensionWatcherReDive.Data; using SecondDimensionWatcherReDive.Framework.PluginParams; using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.Plugin; using SecondDimensionWatcherReDive.Utils.FileStore; using SecondDimensionWatcherReDive.Utils.Incidents; @@ -12,7 +13,8 @@ public partial class CompleteDownloadBackgroundService( Channel downloadCompleteRequest, IServiceScopeFactory scopeFactory, ILogger logger, - IIncidentReporter? incidentReporter = null) + IIncidentReporter? incidentReporter = null, + INotificationPublisher? notificationPublisher = null) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken cancellationToken) @@ -77,6 +79,16 @@ internal async Task ProcessRequestAsync( } LogDownloadMarkedFinished(logger, request.ItemId, info.Title); + if (notificationPublisher is not null) + { + await notificationPublisher.PublishAsync(new NotificationEvent( + NotificationEventType.DownloadCompleted, + $"download-completed:{info.Id}:{request.DownloadAttemptId?.ToString() ?? "legacy"}", + "Download completed", + info.Title, + "/downloaded"), cancellationToken); + } + if (incidentReporter is not null) { await incidentReporter.ResolveAsync( diff --git a/SecondDimensionWatcherReDive/Services/InferAnimationMetadata.cs b/SecondDimensionWatcherReDive/Services/InferAnimationMetadata.cs index 517f3228..2018e10f 100644 --- a/SecondDimensionWatcherReDive/Services/InferAnimationMetadata.cs +++ b/SecondDimensionWatcherReDive/Services/InferAnimationMetadata.cs @@ -1,6 +1,7 @@ using SecondDimensionWatcherReDive.Framework.Inference; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.Tasks; +using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.AI.Abstractions; using SecondDimensionWatcherReDive.Inference.AI.Tools; using SecondDimensionWatcherReDive.Utils.FileStore; @@ -17,7 +18,8 @@ public partial class InferAnimationMetadata( TmdbTool tmdbTool, ILogger logger, IIncidentReporter? incidentReporter = null, - IAIEngineStatus? aiEngineStatus = null) + IAIEngineStatus? aiEngineStatus = null, + INotificationPublisher? notificationPublisher = null) : ScheduledTaskBase { private const int MaxRetryCount = 3; @@ -143,6 +145,17 @@ private async Task ProcessItem( LogInferenceCompleted(logger, item.Id, item.Title); + if (item.MetadataStatus == MetadataReviewStatus.LowConfidence + && notificationPublisher is not null) + { + await notificationPublisher.PublishAsync(new NotificationEvent( + NotificationEventType.MetadataNeedsReview, + $"metadata-review:{item.Id}:{item.StateVersion + 1}", + "Metadata needs review", + item.Title, + $"/metadata-review?status=lowConfidence&focus={item.Id}"), cancellationToken); + } + if (incidentReporter is not null) { await incidentReporter.ResolveAsync( @@ -217,6 +230,15 @@ await incidentReporter.ReportAsync(new IncidentReport( } LogInferenceFailed(logger, ex, item.Id, item.Title, item.AiRetryCount, MaxRetryCount); + if (retryCount >= MaxRetryCount && notificationPublisher is not null) + { + await notificationPublisher.PublishAsync(new NotificationEvent( + NotificationEventType.MetadataNeedsReview, + $"metadata-review-failed:{item.Id}:{item.StateVersion + 1}", + "Metadata inference failed", + item.Title, + $"/metadata-review?status=failed&focus={item.Id}"), cancellationToken); + } if (retryCount >= MaxRetryCount && incidentReporter is not null) { await incidentReporter.ReportAsync(new IncidentReport( diff --git a/SecondDimensionWatcherReDive/Services/SyncFeed.cs b/SecondDimensionWatcherReDive/Services/SyncFeed.cs index 6acd879a..cc683247 100644 --- a/SecondDimensionWatcherReDive/Services/SyncFeed.cs +++ b/SecondDimensionWatcherReDive/Services/SyncFeed.cs @@ -7,6 +7,7 @@ using SecondDimensionWatcherReDive.Framework.FileDownload; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.Tasks; +using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.Utils.Incidents; namespace SecondDimensionWatcherReDive.Services; @@ -20,7 +21,8 @@ public partial class SyncFeed( IHttpClientFactory httpClientFactory, IServiceScopeFactory scopeFactory, ISubscriptionAutomationMatcher automationMatcher, - IIncidentReporter? incidentReporter = null) + IIncidentReporter? incidentReporter = null, + INotificationPublisher? notificationPublisher = null) : ScheduledTaskBase { private readonly HttpClient _httpClient = httpClientFactory.CreateClient("Feed"); @@ -177,6 +179,28 @@ request.ContentLength is { } advertisedSize && : JsonSerializer.Serialize(evaluation.Explanations, ExplanationJsonOptions)); await animationInfoRepository.AddAsync(info, cancellationToken); + if (notificationPublisher is not null) + { + if (policy?.Mode == SubscriptionAutomationMode.NotifyOnly) + { + await notificationPublisher.PublishAsync(new NotificationEvent( + NotificationEventType.ReleaseMatched, + $"release-matched:{info.Id}", + "Subscription release matched", + info.Title, + $"/todo?focus=automation:{info.Id}"), cancellationToken); + } + else if (policy?.Mode == SubscriptionAutomationMode.ManualConfirm) + { + await notificationPublisher.PublishAsync(new NotificationEvent( + NotificationEventType.DownloadPendingConfirmation, + $"download-pending-confirmation:{info.Id}", + "Download confirmation required", + info.Title, + $"/todo?focus=automation:{info.Id}"), cancellationToken); + } + } + if (incidentReporter is not null) await incidentReporter.ResolveAsync( IncidentType.FeedFailure, @@ -184,11 +208,22 @@ await incidentReporter.ResolveAsync( cancellationToken); if (policy?.Mode == SubscriptionAutomationMode.AutoDownload) - await QueueAutomaticDownloadAsync( + { + var started = await QueueAutomaticDownloadAsync( info, animationInfoRepository, scope.ServiceProvider.GetRequiredService(), cancellationToken); + if (!started && notificationPublisher is not null) + { + await notificationPublisher.PublishAsync(new NotificationEvent( + NotificationEventType.DownloadFailed, + $"auto-download-failed:{info.Id}", + "Automatic download failed", + info.Title, + $"/todo?focus=automation:{info.Id}"), cancellationToken); + } + } } catch (InvalidTorrentDataException e) { @@ -207,7 +242,7 @@ await incidentReporter.ReportAsync(new IncidentReport( } } - private async Task QueueAutomaticDownloadAsync( + private async Task QueueAutomaticDownloadAsync( AnimationInfo info, IAnimationInfoRepository animationInfoRepository, IFileDownloadClientProvider downloadClientProvider, @@ -226,7 +261,7 @@ private async Task QueueAutomaticDownloadAsync( cancellationToken)) { LogAutomaticDownloadWarning(logger, info.Title, "download state changed"); - return; + return false; } submissionAttempted = true; @@ -244,7 +279,9 @@ await CompensateAutomaticStartAsync( downloadAttemptId, remoteMayHaveAccepted: false); LogAutomaticDownloadWarning(logger, info.Title, "download client rejected the task"); + return false; } + return true; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -279,6 +316,7 @@ await CompensateAutomaticStartAsync( // Keep the original automatic-download failure in the log. } LogAutomaticDownloadWarning(logger, info.Title, exception.Message); + return false; } } diff --git a/SecondDimensionWatcherReDive/Utils/Incidents/IncidentReporter.cs b/SecondDimensionWatcherReDive/Utils/Incidents/IncidentReporter.cs index 57c024f2..f2c7f1be 100644 --- a/SecondDimensionWatcherReDive/Utils/Incidents/IncidentReporter.cs +++ b/SecondDimensionWatcherReDive/Utils/Incidents/IncidentReporter.cs @@ -1,12 +1,14 @@ using System.Security.Cryptography; using System.Text; using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; namespace SecondDimensionWatcherReDive.Utils.Incidents; public sealed partial class IncidentReporter( IServiceScopeFactory scopeFactory, - ILogger logger) : IIncidentReporter + ILogger logger, + INotificationPublisher? notificationPublisher = null) : IIncidentReporter { private const int MaxTitleLength = 256; private const int MaxDetailLength = 2048; @@ -37,7 +39,23 @@ public async Task ReportAsync( { await using var scope = scopeFactory.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); - return await repository.UpsertAsync(incident, cancellationToken); + var saved = await repository.UpsertAsync(incident, cancellationToken); + if (notificationPublisher is not null) + { + var isDiskSpaceLow = saved.Type == IncidentType.DiskSpaceLow; + var notificationType = isDiskSpaceLow + ? NotificationEventType.DiskSpaceLow + : NotificationEventType.IncidentOpened; + await notificationPublisher.PublishAsync(new NotificationEvent( + notificationType, + $"{(isDiskSpaceLow ? "disk-space-low" : "incident-opened")}:{saved.Id}", + saved.Title, + saved.Detail, + isDiskSpaceLow + ? "/incidents?type=diskSpaceLow" + : $"/incidents?focus={saved.Id}"), cancellationToken); + } + return saved; } catch (OperationCanceledException) { diff --git a/SecondDimensionWatcherReDive/Utils/Notifications/NotificationDeliveryBackgroundService.cs b/SecondDimensionWatcherReDive/Utils/Notifications/NotificationDeliveryBackgroundService.cs new file mode 100644 index 00000000..d5bb944e --- /dev/null +++ b/SecondDimensionWatcherReDive/Utils/Notifications/NotificationDeliveryBackgroundService.cs @@ -0,0 +1,201 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Utils.Notifications; + +public sealed partial class NotificationDeliveryBackgroundService( + IServiceScopeFactory scopeFactory, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILogger logger) : BackgroundService +{ + private const int MaxAttempts = 8; + private const int BatchSize = 20; + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2); + private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(2); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + var processed = await DeliverBatchAsync(stoppingToken); + if (processed == 0) + await Task.Delay(PollInterval, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + LogBatchFailed(logger, exception); + await Task.Delay(PollInterval, stoppingToken); + } + } + } + + internal async Task DeliverBatchAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var now = DateTimeOffset.UtcNow; + var messages = await repository.ClaimDueAsync( + now, now + LeaseDuration, BatchSize, cancellationToken); + foreach (var message in messages) + await DeliverAsync(repository, message, cancellationToken); + return messages.Count; + } + + private async Task DeliverAsync( + INotificationOutboxRepository repository, + NotificationOutboxMessage message, + CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + if (IsQuietHours(now)) + { + await repository.RescheduleAsync(message.Id, now.AddMinutes(15), cancellationToken); + return; + } + + var endpoint = configuration["Notifications:Webhook:Url"]; + if (!configuration.GetValue("Notifications:Webhook:Enabled") + || string.IsNullOrWhiteSpace(endpoint)) + { + await repository.RescheduleAsync(message.Id, now.AddMinutes(5), cancellationToken); + return; + } + + var attempt = message.AttemptCount + 1; + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + request.Headers.TryAddWithoutValidation("X-SDW-Event-Id", message.Id.ToString("D")); + request.Content = new StringContent(CreatePayload(message), Encoding.UTF8, "application/json"); + using var response = await httpClientFactory.CreateClient("NotificationWebhook") + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (response.IsSuccessStatusCode) + { + await repository.MarkDeliveredAsync(message.Id, now, cancellationToken); + LogDelivered(logger, message.Id, message.Type); + return; + } + + var retry = IsRetryable(response.StatusCode) && attempt < MaxAttempts; + await repository.MarkFailedAsync( + message.Id, + attempt, + now, + retry ? now + RetryDelay(attempt) : null, + $"HTTP {(int)response.StatusCode}", + cancellationToken); + LogDeliveryRejected(logger, message.Id, message.Type, (int)response.StatusCode, retry); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + var retry = attempt < MaxAttempts; + await repository.MarkFailedAsync( + message.Id, + attempt, + now, + retry ? now + RetryDelay(attempt) : null, + exception.GetType().Name, + cancellationToken); + // HttpClient exception text can contain the request URI. The webhook URL may + // carry an access token, so only log the exception type here. + LogDeliveryFailed(logger, message.Id, message.Type, exception.GetType().Name, retry); + } + } + + private static string CreatePayload(NotificationOutboxMessage message) + { + JsonElement? payload = null; + if (!string.IsNullOrWhiteSpace(message.PayloadJson)) + { + using var document = JsonDocument.Parse(message.PayloadJson); + payload = document.RootElement.Clone(); + } + + return JsonSerializer.Serialize(new + { + eventId = message.Id, + type = char.ToLowerInvariant(message.Type.ToString()[0]) + message.Type.ToString()[1..], + message.Title, + message.Body, + message.DeepLink, + message.OccurredAt, + payload + }, JsonOptions); + } + + private bool IsQuietHours(DateTimeOffset now) + { + var start = configuration.GetValue("Notifications:QuietHours:Start"); + var end = configuration.GetValue("Notifications:QuietHours:End"); + if (!start.HasValue || !end.HasValue || start == end) return false; + + TimeZoneInfo zone; + try + { + zone = TimeZoneInfo.FindSystemTimeZoneById( + configuration["Notifications:QuietHours:TimeZone"] ?? "UTC"); + } + catch (TimeZoneNotFoundException) + { + return false; + } + catch (InvalidTimeZoneException) + { + return false; + } + + var local = TimeZoneInfo.ConvertTime(now, zone).TimeOfDay; + return start < end + ? local >= start && local < end + : local >= start || local < end; + } + + private static bool IsRetryable(HttpStatusCode statusCode) => + statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests + || (int)statusCode >= 500; + + private static TimeSpan RetryDelay(int attempt) => + TimeSpan.FromSeconds(Math.Min(30 * Math.Pow(2, attempt - 1), 6 * 60 * 60)); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Notification delivery batch failed")] + private static partial void LogBatchFailed(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Information, + Message = "Delivered notification {NotificationId} ({NotificationType})")] + private static partial void LogDelivered( + ILogger logger, + Guid notificationId, + Framework.Notifications.NotificationEventType notificationType); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Webhook rejected notification {NotificationId} ({NotificationType}) with HTTP {StatusCode}; retry={Retry}")] + private static partial void LogDeliveryRejected( + ILogger logger, + Guid notificationId, + Framework.Notifications.NotificationEventType notificationType, + int statusCode, + bool retry); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Failed to deliver notification {NotificationId} ({NotificationType}) with {ErrorType}; retry={Retry}")] + private static partial void LogDeliveryFailed( + ILogger logger, + Guid notificationId, + Framework.Notifications.NotificationEventType notificationType, + string errorType, + bool retry); +} diff --git a/SecondDimensionWatcherReDive/Utils/Notifications/NotificationPublisher.cs b/SecondDimensionWatcherReDive/Utils/Notifications/NotificationPublisher.cs new file mode 100644 index 00000000..a878c021 --- /dev/null +++ b/SecondDimensionWatcherReDive/Utils/Notifications/NotificationPublisher.cs @@ -0,0 +1,88 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; + +namespace SecondDimensionWatcherReDive.Utils.Notifications; + +public sealed partial class NotificationPublisher( + IServiceScopeFactory scopeFactory, + IConfiguration configuration, + ILogger logger) : INotificationPublisher +{ + public async Task PublishAsync( + NotificationEvent notificationEvent, + CancellationToken cancellationToken) + { + if (!configuration.GetValue("Notifications:Webhook:Enabled")) + return; + if (notificationEvent.Type != NotificationEventType.Test + && !SubscribedEvents(configuration["Notifications:Events"]) + .Contains(notificationEvent.Type)) + return; + + var occurredAt = notificationEvent.OccurredAt ?? DateTimeOffset.UtcNow; + var message = new NotificationOutboxMessage( + notificationEvent.Id ?? Guid.NewGuid(), + notificationEvent.DeduplicationKey, + notificationEvent.Type, + Limit(notificationEvent.Title, 256), + Limit(notificationEvent.Body, 2048), + Limit(notificationEvent.DeepLink, 2048), + notificationEvent.PayloadJson, + occurredAt, + NotificationDeliveryStatus.Pending, + 0, + occurredAt, + null, + null, + null); + + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider + .GetRequiredService(); + if (!await repository.EnqueueAsync(message, cancellationToken)) + LogDuplicateSkipped(logger, notificationEvent.Type); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + // Notification persistence is deliberately isolated from the core operation. + LogEnqueueFailed(logger, exception, notificationEvent.Type); + } + } + + internal static IReadOnlySet SubscribedEvents(string? value) + { + var events = new HashSet(); + foreach (var item in (value ?? string.Empty) + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (Enum.TryParse(item, true, out var type)) + events.Add(type); + } + return events; + } + + private static string Limit(string value, int maxLength) + { + var normalized = string.IsNullOrWhiteSpace(value) ? "Notification" : value.Trim(); + return normalized.Length <= maxLength ? normalized : normalized[..maxLength]; + } + + [LoggerMessage(Level = LogLevel.Debug, + Message = "Skipped duplicate notification {NotificationType}")] + private static partial void LogDuplicateSkipped( + ILogger logger, + NotificationEventType notificationType); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Failed to enqueue notification {NotificationType}; the core operation remains successful")] + private static partial void LogEnqueueFailed( + ILogger logger, + Exception exception, + NotificationEventType notificationType); +} diff --git a/SecondDimensionWatcherReDive/appsettings.example.json b/SecondDimensionWatcherReDive/appsettings.example.json index da9d5ab6..59baf108 100644 --- a/SecondDimensionWatcherReDive/appsettings.example.json +++ b/SecondDimensionWatcherReDive/appsettings.example.json @@ -76,6 +76,21 @@ } }, + // Durable notification outbox. The full webhook URL may contain a token and is + // therefore treated as a secret; prefer configuring it from the web settings page. + "Notifications": { + "Webhook": { + "Enabled": false, + "Url": "" + }, + "Events": "ReleaseMatched,DownloadPendingConfirmation,DownloadCompleted,DownloadFailed,IncidentOpened,MetadataNeedsReview,DiskSpaceLow", + "QuietHours": { + "Start": null, + "End": null, + "TimeZone": "UTC" + } + }, + // Read-only NFSv4 export over the virtual filesystem (optional, disabled by default) // Mount with: sudo mount -t nfs4 -o vers=4,nolock,port=2049 host:/ /mnt // On Linux port 2049 is privileged; in containers expose it explicitly or pick a non-privileged port. diff --git a/packaging/appsettings.yml b/packaging/appsettings.yml index 4cf660fa..24b3eee5 100644 --- a/packaging/appsettings.yml +++ b/packaging/appsettings.yml @@ -78,6 +78,18 @@ Incidents: MinimumAvailableBytes: 5368709120 # 5 GiB MinimumAvailablePercent: 5 +# 通知 Outbox(默认关闭)。Webhook URL 可能包含访问令牌,建议在网页设置中填写, +# 服务端会使用 Data Protection 加密保存且 API 不会回显明文。 +Notifications: + Webhook: + Enabled: false + Url: "" + Events: "ReleaseMatched,DownloadPendingConfirmation,DownloadCompleted,DownloadFailed,IncidentOpened,MetadataNeedsReview,DiskSpaceLow" + QuietHours: + Start: + End: + TimeZone: "UTC" + # NFSv4 只读导出(可选,默认关闭) # 客户端示例: sudo mount -t nfs4 -o vers=4,nolock,port=2049 host:/ /mnt # Linux 上 2049 为特权端口,容器中需显式发布或改为非特权端口 From a2130b8cf8d3238bad84dc965d70278e9032cf96 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sun, 30 Aug 2026 11:08:17 +0800 Subject: [PATCH 2/7] fix: make recurring todos actionable --- .../package.json | 2 +- .../src/components/AppHeader.tsx | 2 +- .../src/i18n/locales/en/todos.json | 1 + .../src/i18n/locales/ja/todos.json | 1 + .../src/i18n/locales/zh-CN/todos.json | 1 + .../src/pages/TodoPage.tsx | 264 ++-- .../src/todos/hooks.ts | 4 + .../src/todos/state.test.ts | 18 + .../src/todos/state.ts | 9 + .../DataRepository/ITodoRepository.cs | 2 + .../DataRepository/Incident.cs | 3 +- .../FileMappingRepositoryPostgreSqlTests.cs | 128 ++ .../IncidentReporterTests.cs | 56 +- .../TodosControllerTests.cs | 65 + .../Controllers/TodosController.cs | 37 +- ...0030124_AddIncidentOccurrences.Designer.cs | 1079 +++++++++++++++++ .../20260830030124_AddIncidentOccurrences.cs | 38 + .../ApplicationContextModelSnapshot.cs | 10 +- .../Models/ApplicationContext.cs | 9 + .../Models/Incident.cs | 1 + .../Repositories/IncidentRepository.cs | 42 +- .../Repositories/TodoRepository.cs | 247 ++-- .../TodoRepositoryPostgreSqlTestFixture.cs | 107 ++ .../Utils/Incidents/IncidentReporter.cs | 8 +- 24 files changed, 1880 insertions(+), 254 deletions(-) create mode 100644 SecondDimensionWatcherReDive.Client/src/todos/state.test.ts create mode 100644 SecondDimensionWatcherReDive.Client/src/todos/state.ts create mode 100644 SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.Designer.cs create mode 100644 SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.cs create mode 100644 SecondDimensionWatcherReDive/Repositories/TodoRepositoryPostgreSqlTestFixture.cs diff --git a/SecondDimensionWatcherReDive.Client/package.json b/SecondDimensionWatcherReDive.Client/package.json index 5f71a25b..0d970fe4 100644 --- a/SecondDimensionWatcherReDive.Client/package.json +++ b/SecondDimensionWatcherReDive.Client/package.json @@ -52,7 +52,7 @@ "build": "rimraf dist && parcel build --no-source-maps", "mock": "node mock-server.mjs", "dev": "node mock-server.mjs & parcel --no-cache", - "test": "tsx --test src/playback/mkv/*.test.ts" + "test": "tsx --test src/playback/mkv/*.test.ts src/todos/*.test.ts" }, "source": "src/index.html", "@parcel/resolver-default": { diff --git a/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx b/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx index 0aa302fb..ac09d797 100644 --- a/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx +++ b/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx @@ -232,7 +232,7 @@ export const AppHeader: React.FC = () => { const { t } = useTranslation(); const { data: status } = useLoginStatus(); const { data: incidents } = useIncidents({ take: 1 }); - const { data: todos } = useTodos(); + const { data: todos } = useTodos({ take: 1 }); const navigate = useNavigate(); const items = createNavItems(incidents?.openCount, todos?.unreadCount); diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/todos.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/todos.json index 0910272a..28cb8acb 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/todos.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/todos.json @@ -17,6 +17,7 @@ "markRead": "Mark read", "markUnread": "Mark unread", "snooze": "Snooze 1 hour", + "unsnooze": "Remind me now", "download": "Download / retry", "open": "Open details" }, diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/todos.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/todos.json index 2d243323..0a3bb0cf 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/todos.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/todos.json @@ -16,6 +16,7 @@ "markRead": "既読にする", "markUnread": "未読にする", "snooze": "1 時間スヌーズ", + "unsnooze": "今すぐ通知", "download": "ダウンロード / 再試行", "open": "詳細を開く" }, diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/todos.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/todos.json index 1eb39946..02502520 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/todos.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/todos.json @@ -16,6 +16,7 @@ "markRead": "标为已读", "markUnread": "标为未读", "snooze": "稍后 1 小时提醒", + "unsnooze": "立即提醒", "download": "下载 / 重试", "open": "查看详情" }, diff --git a/SecondDimensionWatcherReDive.Client/src/pages/TodoPage.tsx b/SecondDimensionWatcherReDive.Client/src/pages/TodoPage.tsx index a8d6b571..bed4e369 100644 --- a/SecondDimensionWatcherReDive.Client/src/pages/TodoPage.tsx +++ b/SecondDimensionWatcherReDive.Client/src/pages/TodoPage.tsx @@ -4,6 +4,7 @@ import { useNavigate, useSearchParams } from "react-router"; import { AlertTriangle, + BellRing, CheckCheck, Clock3, Download, @@ -19,6 +20,7 @@ import { Spinner } from "../components/ui/Spinner"; import { cn } from "../lib/cn"; import { updateTodoState } from "../todos/api"; import { useTodos } from "../todos/hooks"; +import { getTodoSnoozeAction } from "../todos/state"; import { TodoItem, TodoPriority } from "../todos/types"; import { PageTemplate } from "./PageTemplate"; @@ -28,17 +30,35 @@ const priorityClass: Record = { Critical: "border-error/40 bg-error/5", }; +const PAGE_SIZE = 50; + export const TodoPage: React.FC = () => { - const { t, i18n } = useTranslation(["todos", "errors"]); + const { t, i18n } = useTranslation(["todos", "errors", "common"]); const { addToast } = useToast(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const focus = searchParams.get("focus"); const [includeRead, setIncludeRead] = React.useState(false); const [includeSnoozed, setIncludeSnoozed] = React.useState(false); + const [page, setPage] = React.useState(0); const [selected, setSelected] = React.useState>(new Set()); const [busy, setBusy] = React.useState(false); - const { data, error, mutate } = useTodos({ includeRead, includeSnoozed }); + const { data, error, mutate } = useTodos({ + includeRead, + includeSnoozed, + skip: page * PAGE_SIZE, + take: PAGE_SIZE, + }); + + React.useEffect(() => { + setPage(0); + setSelected(new Set()); + }, [includeRead, includeSnoozed]); + + React.useEffect(() => { + if (!data || page === 0 || page * PAGE_SIZE < data.totalCount) return; + setPage(Math.max(0, Math.ceil(data.totalCount / PAGE_SIZE) - 1)); + }, [data, page]); React.useEffect(() => { if (!focus || !data) return; @@ -189,122 +209,158 @@ export const TodoPage: React.FC = () => { body={

{t("todos:empty.body")}

} /> ) : ( -
    - {items.map((item) => { - const automation = - item.type === "ReleaseMatched" || - item.type === "DownloadPendingConfirmation" || - item.type === "DownloadFailed"; - const detail = automation - ? t(`todos:details.${item.type}`) - : item.detail; - return ( -
  • -
    - - setSelected((current) => { - const next = new Set(current); - if (event.target.checked) next.add(item.key); - else next.delete(item.key); - return next; - }) - } - /> -
    -
    -
    -
    - - {t(`todos:types.${item.type}`)} - - - {t(`todos:priorities.${item.priority}`)} - +
    +
      + {items.map((item) => { + const automation = + item.type === "ReleaseMatched" || + item.type === "DownloadPendingConfirmation" || + item.type === "DownloadFailed"; + const detail = automation + ? t(`todos:details.${item.type}`) + : item.detail; + const snoozeAction = getTodoSnoozeAction(item.snoozedUntil); + const isSnoozed = snoozeAction === "unsnooze"; + return ( +
    • +
      + + setSelected((current) => { + const next = new Set(current); + if (event.target.checked) next.add(item.key); + else next.delete(item.key); + return next; + }) + } + /> +
      +
      +
      +
      + + {t(`todos:types.${item.type}`)} + + + {t(`todos:priorities.${item.priority}`)} + +
      +

      + {item.title} +

      -

      - {item.title} -

      +
      -
      -
    -
  • - ); - })} -
+ + ); + })} + + {data.totalCount > PAGE_SIZE ? ( + + ) : null} + )} ); diff --git a/SecondDimensionWatcherReDive.Client/src/todos/hooks.ts b/SecondDimensionWatcherReDive.Client/src/todos/hooks.ts index 8c4ee423..1123b156 100644 --- a/SecondDimensionWatcherReDive.Client/src/todos/hooks.ts +++ b/SecondDimensionWatcherReDive.Client/src/todos/hooks.ts @@ -6,10 +6,14 @@ import { TodoList } from "./types"; export const useTodos = (options?: { includeRead?: boolean; includeSnoozed?: boolean; + skip?: number; + take?: number; }) => { const params = new URLSearchParams(); if (options?.includeRead) params.set("includeRead", "true"); if (options?.includeSnoozed) params.set("includeSnoozed", "true"); + if (options?.skip) params.set("skip", String(options.skip)); + if (options?.take) params.set("take", String(options.take)); const query = params.toString(); return useSWR(`/api/todos${query ? `?${query}` : ""}`, fetcher); }; diff --git a/SecondDimensionWatcherReDive.Client/src/todos/state.test.ts b/SecondDimensionWatcherReDive.Client/src/todos/state.test.ts new file mode 100644 index 00000000..4a28e277 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/todos/state.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { getTodoSnoozeAction } from "./state"; + +describe("getTodoSnoozeAction", () => { + const now = Date.parse("2026-08-30T00:00:00Z"); + + it("offers unsnooze while the wake time is still in the future", () => { + assert.equal(getTodoSnoozeAction("2026-08-30T01:00:00Z", now), "unsnooze"); + }); + + it("offers snooze for expired, absent, or invalid wake times", () => { + assert.equal(getTodoSnoozeAction("2026-08-29T23:00:00Z", now), "snooze"); + assert.equal(getTodoSnoozeAction(null, now), "snooze"); + assert.equal(getTodoSnoozeAction("invalid", now), "snooze"); + }); +}); diff --git a/SecondDimensionWatcherReDive.Client/src/todos/state.ts b/SecondDimensionWatcherReDive.Client/src/todos/state.ts new file mode 100644 index 00000000..271df052 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/todos/state.ts @@ -0,0 +1,9 @@ +import type { TodoStateAction } from "./types"; + +export const getTodoSnoozeAction = ( + snoozedUntil: string | null, + now = Date.now(), +): Extract => + snoozedUntil !== null && new Date(snoozedUntil).getTime() > now + ? "unsnooze" + : "snooze"; diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/ITodoRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/ITodoRepository.cs index 7be1cc10..84e074de 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/ITodoRepository.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/ITodoRepository.cs @@ -40,6 +40,8 @@ Task GetAsync( bool includeRead, bool includeSnoozed, DateTimeOffset now, + int skip, + int take, CancellationToken cancellationToken); Task SetStateAsync( diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/Incident.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/Incident.cs index 91b46a0e..4808301b 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/Incident.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/Incident.cs @@ -29,7 +29,8 @@ public sealed record Incident( DateTimeOffset? ResolvedAt, int RetryCount, DateTimeOffset? LastRetryAt, - string? LastRetryError); + string? LastRetryError, + int Occurrence = 1); public sealed record IncidentPage( IReadOnlyList Items, diff --git a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs index 1a064019..1461825f 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs @@ -19,6 +19,7 @@ public sealed class FileMappingRepositoryPostgreSqlTests .Build(); private static FileMappingRepositoryPostgreSqlTestFixture Fixture = null!; + private static TodoRepositoryPostgreSqlTestFixture TodoFixture = null!; [ClassInitialize] public static async Task InitializeAsync(TestContext _) @@ -26,6 +27,7 @@ public static async Task InitializeAsync(TestContext _) await Database.StartAsync(); Fixture = new FileMappingRepositoryPostgreSqlTestFixture(Database.GetConnectionString()); await Fixture.InitializeAsync(CancellationToken.None); + TodoFixture = new TodoRepositoryPostgreSqlTestFixture(Database.GetConnectionString()); } [ClassCleanup] @@ -35,6 +37,7 @@ public static async Task InitializeAsync(TestContext _) public async Task ResetDatabaseAsync() { await Fixture.ResetAsync(CancellationToken.None); + await TodoFixture.ResetAsync(CancellationToken.None); } [TestMethod] @@ -88,6 +91,131 @@ static async Task WriteAsync(FileMappingRepositoryPostgreSqlTestFixture fi CollectionAssert.AreEquivalent(new long[] { 0, 1 }, versions); } + [TestMethod] + public async Task RecurringIncident_GetsFreshTodoState_WhileFirstOccurrenceKeepsLegacyKey() + { + var now = DateTimeOffset.UtcNow; + var incidentId = Guid.NewGuid(); + var first = await TodoFixture.UpsertIncidentAsync( + Incident(incidentId, now), + CancellationToken.None); + + Assert.AreEqual(1, first.Occurrence); + var firstKey = $"incident:{incidentId}"; + var initialTodos = await TodoFixture.GetTodosAsync( + false, false, now, 0, 10, CancellationToken.None); + Assert.HasCount(1, initialTodos.Items); + Assert.AreEqual(firstKey, initialTodos.Items[0].Key); + + await TodoFixture.SetTodoStateAsync( + [firstKey], + now, + true, + now.AddHours(1), + true, + CancellationToken.None); + var hidden = await TodoFixture.GetTodosAsync( + false, false, now, 0, 10, CancellationToken.None); + Assert.AreEqual(0, hidden.TotalCount); + Assert.AreEqual(0, hidden.UnreadCount); + + await TodoFixture.ResolveIncidentAsync( + first.Fingerprint, + now.AddMinutes(1), + CancellationToken.None); + var reopened = await TodoFixture.UpsertIncidentAsync( + Incident(Guid.NewGuid(), now.AddMinutes(2)), + CancellationToken.None); + var duplicateReport = await TodoFixture.UpsertIncidentAsync( + Incident(Guid.NewGuid(), now.AddMinutes(3)), + CancellationToken.None); + + Assert.AreEqual(incidentId, reopened.Id); + Assert.AreEqual(2, reopened.Occurrence); + Assert.AreEqual(2, duplicateReport.Occurrence); + var currentTodos = await TodoFixture.GetTodosAsync( + false, false, now.AddMinutes(3), 0, 10, CancellationToken.None); + Assert.HasCount(1, currentTodos.Items); + Assert.AreEqual($"incident:{incidentId}:2", currentTodos.Items[0].Key); + Assert.IsNull(currentTodos.Items[0].ReadAt); + Assert.IsNull(currentTodos.Items[0].SnoozedUntil); + Assert.AreEqual(1, currentTodos.UnreadCount); + Assert.AreEqual(1, await TodoFixture.GetTodoStateCountAsync(CancellationToken.None)); + } + + [TestMethod] + public async Task TodoQuery_FiltersCountsAndPaginatesAcrossSourcesInPostgreSql() + { + var now = DateTimeOffset.UtcNow; + var automationIds = new List(); + for (var index = 0; index < 4; index++) + { + automationIds.Add(await TodoFixture.SeedAnimationInfoAsync( + $"automation-{index}", + now.AddMinutes(-index), + SubscriptionAutomationDisposition.Notified, + MetadataReviewStatus.Identified, + CancellationToken.None)); + } + var metadataId = await TodoFixture.SeedAnimationInfoAsync( + "metadata", + now.AddMinutes(-5), + null, + MetadataReviewStatus.LowConfidence, + CancellationToken.None); + var incident = await TodoFixture.UpsertIncidentAsync( + Incident(Guid.NewGuid(), now.AddMinutes(-6)), + CancellationToken.None); + + await TodoFixture.SetTodoStateAsync( + [$"automation:{automationIds[0]}"], + now, + true, + null, + false, + CancellationToken.None); + await TodoFixture.SetTodoStateAsync( + [$"metadata:{metadataId}"], + null, + false, + now.AddHours(1), + true, + CancellationToken.None); + + var firstPage = await TodoFixture.GetTodosAsync( + false, false, now, 0, 2, CancellationToken.None); + var secondPage = await TodoFixture.GetTodosAsync( + false, false, now, 2, 2, CancellationToken.None); + var allStates = await TodoFixture.GetTodosAsync( + true, true, now, 0, 10, CancellationToken.None); + + Assert.AreEqual(4, firstPage.TotalCount); + Assert.AreEqual(4, firstPage.UnreadCount); + Assert.HasCount(2, firstPage.Items); + Assert.HasCount(2, secondPage.Items); + Assert.AreEqual(6, allStates.TotalCount); + Assert.AreEqual(4, allStates.UnreadCount); + Assert.HasCount(6, allStates.Items); + CollectionAssert.Contains( + allStates.Items.Select(item => item.Key).ToList(), + $"incident:{incident.Id}"); + } + private static FileMapping Mapping(Guid animationInfoId, string virtualPath) => new(Guid.NewGuid(), animationInfoId, virtualPath, "/physical/" + Guid.NewGuid(), "local"); + + private static Incident Incident(Guid id, DateTimeOffset occurredAt) => new( + id, + "feedfailure:integration-test", + IncidentType.FeedFailure, + IncidentSeverity.Error, + "Feed failed", + "The feed could not be loaded.", + "https://example.test/feed", + occurredAt, + occurredAt, + null, + 0, + null, + null); } diff --git a/SecondDimensionWatcherReDive.Test/IncidentReporterTests.cs b/SecondDimensionWatcherReDive.Test/IncidentReporterTests.cs index 0ba77937..b74c9204 100644 --- a/SecondDimensionWatcherReDive.Test/IncidentReporterTests.cs +++ b/SecondDimensionWatcherReDive.Test/IncidentReporterTests.cs @@ -52,11 +52,13 @@ await reporter.ReportAsync(new IncidentReport( [TestMethod] public async Task ReportAsync_DiskSpaceLow_PublishesOnlySpecificEvent() { + var incidentId = Guid.NewGuid(); var repository = new Mock(); repository.Setup(candidate => candidate.UpsertAsync( It.IsAny(), CancellationToken.None)) - .ReturnsAsync((Incident incident, CancellationToken _) => incident); + .ReturnsAsync((Incident incident, CancellationToken _) => + incident with { Id = incidentId, Occurrence = 2 }); var provider = new Mock(); provider.Setup(candidate => candidate.GetService(typeof(IIncidentRepository))) .Returns(repository.Object); @@ -80,8 +82,60 @@ await reporter.ReportAsync(new IncidentReport( notifications.Verify(candidate => candidate.PublishAsync( It.Is(notification => notification.Type == NotificationEventType.DiskSpaceLow + && notification.DeduplicationKey == $"disk-space-low:{incidentId}:2" && notification.DeepLink == "/incidents?type=diskSpaceLow"), CancellationToken.None), Times.Once); notifications.VerifyNoOtherCalls(); } + + [TestMethod] + public async Task ReportAsync_RecurringIncident_DeduplicatesWithinEachOccurrence() + { + var incidentId = Guid.NewGuid(); + var occurrences = new Queue([1, 2, 2]); + var repository = new Mock(); + repository.Setup(candidate => candidate.UpsertAsync( + It.IsAny(), + CancellationToken.None)) + .ReturnsAsync((Incident incident, CancellationToken _) => + incident with { Id = incidentId, Occurrence = occurrences.Dequeue() }); + var provider = new Mock(); + provider.Setup(candidate => candidate.GetService(typeof(IIncidentRepository))) + .Returns(repository.Object); + var scope = new Mock(); + scope.SetupGet(candidate => candidate.ServiceProvider).Returns(provider.Object); + var factory = new Mock(); + factory.Setup(candidate => candidate.CreateScope()).Returns(scope.Object); + var published = new List(); + var notifications = new Mock(); + notifications.Setup(candidate => candidate.PublishAsync( + It.IsAny(), + CancellationToken.None)) + .Callback((notification, _) => + published.Add(notification)) + .Returns(Task.CompletedTask); + var reporter = new IncidentReporter( + factory.Object, + Mock.Of>(), + notifications.Object); + var report = new IncidentReport( + IncidentType.FeedFailure, + IncidentSeverity.Error, + "Feed failed", + "The feed could not be loaded.", + "https://example.test/feed"); + + await reporter.ReportAsync(report, CancellationToken.None); + await reporter.ReportAsync(report, CancellationToken.None); + await reporter.ReportAsync(report, CancellationToken.None); + + CollectionAssert.AreEqual( + new[] + { + $"incident-opened:{incidentId}", + $"incident-opened:{incidentId}:2", + $"incident-opened:{incidentId}:2" + }, + published.Select(notification => notification.DeduplicationKey).ToArray()); + } } diff --git a/SecondDimensionWatcherReDive.Test/TodosControllerTests.cs b/SecondDimensionWatcherReDive.Test/TodosControllerTests.cs index 3c1f4a64..c2cb1cca 100644 --- a/SecondDimensionWatcherReDive.Test/TodosControllerTests.cs +++ b/SecondDimensionWatcherReDive.Test/TodosControllerTests.cs @@ -9,6 +9,50 @@ namespace SecondDimensionWatcherReDive.Test; [TestClass] public sealed class TodosControllerTests { + [TestMethod] + public async Task GetAsync_ForwardsDatabasePagination() + { + var repository = new Mock(); + repository.Setup(candidate => candidate.GetAsync( + true, + true, + It.IsAny(), + 25, + 10, + CancellationToken.None)) + .ReturnsAsync(new TodoPage([], 120, 7)); + var controller = new TodosController(repository.Object); + + var result = await controller.GetAsync( + true, + true, + 25, + 10, + CancellationToken.None); + + var ok = Assert.IsInstanceOfType(result.Result); + var response = Assert.IsInstanceOfType(ok.Value); + Assert.AreEqual(120, response.TotalCount); + Assert.AreEqual(7, response.UnreadCount); + } + + [TestMethod] + public async Task GetAsync_InvalidPagination_IsRejectedBeforeRepositoryQuery() + { + var repository = new Mock(); + var controller = new TodosController(repository.Object); + + var result = await controller.GetAsync( + false, + false, + 0, + 201, + CancellationToken.None); + + Assert.IsInstanceOfType(result.Result); + repository.VerifyNoOtherCalls(); + } + [TestMethod] public async Task UpdateStateAsync_MarkRead_OnlyPersistsPresentationState() { @@ -46,4 +90,25 @@ public async Task UpdateStateAsync_SnoozeWithoutFutureTime_IsRejected() Assert.IsInstanceOfType(result); repository.VerifyNoOtherCalls(); } + + [TestMethod] + public async Task UpdateStateAsync_RecurringIncidentKey_IsAccepted() + { + var key = $"incident:{Guid.NewGuid()}:2"; + var repository = new Mock(); + var controller = new TodosController(repository.Object); + + var result = await controller.UpdateStateAsync( + new UpdateTodoStateRequest([key], TodoStateAction.Unsnooze, null), + CancellationToken.None); + + Assert.IsInstanceOfType(result); + repository.Verify(candidate => candidate.SetStateAsync( + It.Is>(keys => keys.Single() == key), + null, + false, + null, + true, + CancellationToken.None), Times.Once); + } } diff --git a/SecondDimensionWatcherReDive/Controllers/TodosController.cs b/SecondDimensionWatcherReDive/Controllers/TodosController.cs index f109eedb..dc5dcbc1 100644 --- a/SecondDimensionWatcherReDive/Controllers/TodosController.cs +++ b/SecondDimensionWatcherReDive/Controllers/TodosController.cs @@ -15,10 +15,23 @@ internal sealed class TodosController(ITodoRepository todoRepository) : Controll public async Task> GetAsync( [FromQuery] bool includeRead = false, [FromQuery] bool includeSnoozed = false, + [FromQuery] int skip = 0, + [FromQuery] int take = 50, CancellationToken cancellationToken = default) { + if (skip < 0 || take is < 1 or > 200) + return BadRequest(new + { + message = "skip must be non-negative and take must be between 1 and 200." + }); + var page = await todoRepository.GetAsync( - includeRead, includeSnoozed, DateTimeOffset.UtcNow, cancellationToken); + includeRead, + includeSnoozed, + DateTimeOffset.UtcNow, + skip, + take, + cancellationToken); return Ok(new TodoListResponse( page.Items.Select(item => new TodoItemResponse( item.Key, @@ -85,11 +98,19 @@ await todoRepository.SetStateAsync( return NoContent(); } - private static bool IsValidKey(string? key) => - key is not null - && key.Length <= 128 - && (key.StartsWith("automation:", StringComparison.Ordinal) - || key.StartsWith("incident:", StringComparison.Ordinal) - || key.StartsWith("metadata:", StringComparison.Ordinal)) - && Guid.TryParse(key[(key.IndexOf(':') + 1)..], out _); + private static bool IsValidKey(string? key) + { + if (key is null || key.Length > 128) return false; + + var parts = key.Split(':'); + if (parts.Length == 2 + && (parts[0] is "automation" or "incident" or "metadata")) + return Guid.TryParse(parts[1], out _); + + return parts.Length == 3 + && parts[0] == "incident" + && Guid.TryParse(parts[1], out _) + && int.TryParse(parts[2], out var occurrence) + && occurrence > 1; + } } diff --git a/SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.Designer.cs b/SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.Designer.cs new file mode 100644 index 00000000..8ee82991 --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.Designer.cs @@ -0,0 +1,1079 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SecondDimensionWatcherReDive.Models; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260830030124_AddIncidentOccurrences")] + partial class AddIncidentOccurrences + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Animation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PosterPath") + .HasColumnType("text"); + + b.Property("TmdbId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("Animations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AnimationGroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalDownloadInfo") + .IsRequired() + .HasColumnType("text"); + + b.Property("AiRetryCount") + .HasColumnType("integer"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("AutomationDisposition") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AutomationExplanationJson") + .HasColumnType("text"); + + b.Property("CachedDownloadData") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("CurrentMetadataReviewOperationId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadAttemptId") + .HasColumnType("uuid"); + + b.Property("DownloadCancellationId") + .HasColumnType("uuid"); + + b.Property("DownloadEndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadType") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("FileStore") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAiProcessed") + .HasColumnType("boolean"); + + b.Property("IsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("IsDownloadTracked") + .HasColumnType("boolean"); + + b.Property("MediaLibraryMissingSince") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaLibrarySourceId") + .HasColumnType("uuid"); + + b.Property("MetadataConfidence") + .HasColumnType("double precision"); + + b.Property("MetadataLastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MetadataStatus") + .HasColumnType("integer"); + + b.Property("PublishTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleaseSizeBytes") + .HasColumnType("bigint"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SourceFeedId") + .HasColumnType("uuid"); + + b.Property("StateVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("StorePath") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AnimationId"); + + b.HasIndex("CurrentMetadataReviewOperationId") + .IsUnique(); + + b.HasIndex("GroupId"); + + b.HasIndex("MediaLibrarySourceId"); + + b.HasIndex("SourceFeedId"); + + b.HasIndex("FileStore", "StorePath") + .IsUnique() + .HasFilter("\"DownloadType\" = 'http://schemas.hcgstudio.com/ws/2023/06/sdw/downloadtype/media-library-import'"); + + b.HasIndex("MetadataStatus", "PublishTime"); + + b.ToTable("AnimationInfo", t => + { + t.HasCheckConstraint("CK_AnimationInfo_MetadataConfidence_Range", "\"MetadataConfidence\" IS NULL OR (\"MetadataConfidence\" >= 0 AND \"MetadataConfidence\" <= 1)"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ApplicationSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ProtectedSecrets") + .HasColumnType("text"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings", t => + { + t.HasCheckConstraint("CK_ApplicationSettings_Revision_Positive", "\"Revision\" > 0"); + + t.HasCheckConstraint("CK_ApplicationSettings_Singleton", "\"Id\" = 1"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MikanSubgroupId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SeasonBangumiId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SeasonBangumiId", "MikanSubgroupId") + .IsUnique(); + + b.ToTable("BangumiSubgroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ChatConversations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToolCallId") + .HasColumnType("text"); + + b.Property("ToolCallsJson") + .HasColumnType("text"); + + b.Property("ToolName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Feed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Feeds"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("VirtualPath") + .IsUnique(); + + b.ToTable("FileMappings"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("AnimationId", "CreatedAt"); + + b.HasIndex("AnimationId", "Pattern") + .IsUnique(); + + b.ToTable("FileNameRegexRules"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Incident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DetectedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastRetryError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Occurrence") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Fingerprint") + .IsUnique(); + + b.HasIndex("ResolvedAt", "Type", "UpdatedAt"); + + b.ToTable("Incidents", t => + { + t.HasCheckConstraint("CK_Incidents_Occurrence_Positive", "\"Occurrence\" > 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MediaLibrarySource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsMonitoring") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastImportedCount") + .HasColumnType("integer"); + + b.Property("LastRemovedCount") + .HasColumnType("integer"); + + b.Property("LastScanAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSkippedCount") + .HasColumnType("integer"); + + b.Property("LastUpdatedCount") + .HasColumnType("integer"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("MediaLibrarySources"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OperationId", "Kind", "VirtualPath") + .IsUnique(); + + b.ToTable("MetadataReviewMappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AppliedVersion") + .HasColumnType("bigint"); + + b.Property("BaseFileStore") + .HasColumnType("text"); + + b.Property("BaseIsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("BaseStorePath") + .HasColumnType("text"); + + b.Property("BaseVersion") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousAiRetryCount") + .HasColumnType("integer"); + + b.Property("PreviousAnimationId") + .HasColumnType("uuid"); + + b.Property("PreviousConfidence") + .HasColumnType("double precision"); + + b.Property("PreviousCurrentOperationId") + .HasColumnType("uuid"); + + b.Property("PreviousDescription") + .HasColumnType("text"); + + b.Property("PreviousEpisode") + .HasColumnType("integer"); + + b.Property("PreviousGroupId") + .HasColumnType("uuid"); + + b.Property("PreviousIsAiProcessed") + .HasColumnType("boolean"); + + b.Property("PreviousLastError") + .HasColumnType("text"); + + b.Property("PreviousMetadataStatus") + .HasColumnType("integer"); + + b.Property("PreviousReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousSeason") + .HasColumnType("integer"); + + b.Property("ProposedAnimationName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationOriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationPosterPath") + .HasColumnType("text"); + + b.Property("ProposedAnimationTmdbId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedEpisode") + .HasColumnType("integer"); + + b.Property("ProposedGroupName") + .HasColumnType("text"); + + b.Property("ProposedSeason") + .HasColumnType("integer"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("UndoneAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId", "AppliedVersion") + .IsUnique(); + + b.HasIndex("AnimationInfoId", "State"); + + b.HasIndex("State", "ExpiresAt"); + + b.ToTable("MetadataReviewOperations", t => + { + t.HasCheckConstraint("CK_MetadataReviewOperations_Expiry", "\"ExpiresAt\" > \"CreatedAt\""); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MigrationMarker", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("MigrationMarkers"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.NotificationOutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DeduplicationKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DeepLink") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DeliveredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PayloadJson") + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.HasKey("Id"); + + b.HasIndex("DeduplicationKey") + .IsUnique(); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("NotificationOutboxMessages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AutoPlayNext") + .HasColumnType("boolean"); + + b.Property("SubtitleLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SubtitleTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("PlaybackPreferences"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsWatched") + .HasColumnType("boolean"); + + b.Property("PositionSeconds") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VirtualPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("WatchedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("UserId", "AnimationInfoId", "VirtualPath") + .IsUnique(); + + b.HasIndex("UserId", "IsWatched", "UpdatedAt"); + + b.ToTable("PlaybackProgresses", t => + { + t.HasCheckConstraint("CK_PlaybackProgresses_Duration_NonNegative", "\"DurationSeconds\" >= 0"); + + t.HasCheckConstraint("CK_PlaybackProgresses_Position_NonNegative", "\"PositionSeconds\" >= 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("MikanId") + .HasColumnType("integer"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MikanId") + .IsUnique(); + + b.ToTable("SeasonBangumis"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.Property("FeedId") + .HasColumnType("uuid"); + + b.PrimitiveCollection("Codecs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection("ExcludedKeywords") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("Languages") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("MaxSizeBytes") + .HasColumnType("bigint"); + + b.Property("MinSizeBytes") + .HasColumnType("bigint"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.PrimitiveCollection("Resolutions") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("SubtitleGroups") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("FeedId"); + + b.HasIndex("UpdatedAt"); + + b.ToTable("SubscriptionAutomationPolicies"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.TodoItemState", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SnoozedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("TodoItemStates"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebDavToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("WebDavTokens"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", "Animation") + .WithMany() + .HasForeignKey("AnimationId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationGroup", "Group") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.MediaLibrarySource", null) + .WithMany() + .HasForeignKey("MediaLibrarySourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", null) + .WithMany() + .HasForeignKey("SourceFeedId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Animation"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.SeasonBangumi", "SeasonBangumi") + .WithMany("Subgroups") + .HasForeignKey("SeasonBangumiId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SeasonBangumi"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.ChatConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", null) + .WithMany() + .HasForeignKey("AnimationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", "Operation") + .WithMany("MappingSnapshots") + .HasForeignKey("OperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", "Feed") + .WithOne() + .HasForeignKey("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", "FeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Feed"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Navigation("MappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Navigation("Subgroups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.cs b/SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.cs new file mode 100644 index 00000000..7b7ca2e0 --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + /// + public partial class AddIncidentOccurrences : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Occurrence", + table: "Incidents", + type: "integer", + nullable: false, + defaultValue: 1); + + migrationBuilder.AddCheckConstraint( + name: "CK_Incidents_Occurrence_Positive", + table: "Incidents", + sql: "\"Occurrence\" > 0"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "CK_Incidents_Occurrence_Positive", + table: "Incidents"); + + migrationBuilder.DropColumn( + name: "Occurrence", + table: "Incidents"); + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs index 5e7a1090..62458355 100644 --- a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs +++ b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs @@ -430,6 +430,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(2048) .HasColumnType("character varying(2048)"); + b.Property("Occurrence") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + b.Property("ResolvedAt") .HasColumnType("timestamp with time zone"); @@ -462,7 +467,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ResolvedAt", "Type", "UpdatedAt"); - b.ToTable("Incidents"); + b.ToTable("Incidents", t => + { + t.HasCheckConstraint("CK_Incidents_Occurrence_Positive", "\"Occurrence\" > 0"); + }); }); modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MediaLibrarySource", b => diff --git a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs index ccb9065b..22d092db 100644 --- a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs +++ b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs @@ -217,6 +217,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .Property(incident => incident.LastRetryError) .HasMaxLength(2048); + modelBuilder.Entity() + .Property(incident => incident.Occurrence) + .HasDefaultValue(1); + + modelBuilder.Entity() + .ToTable(table => table.HasCheckConstraint( + "CK_Incidents_Occurrence_Positive", + "\"Occurrence\" > 0")); + modelBuilder.Entity() .HasIndex(t => t.Username) .IsUnique(); diff --git a/SecondDimensionWatcherReDive/Models/Incident.cs b/SecondDimensionWatcherReDive/Models/Incident.cs index 01b3b06f..85476681 100644 --- a/SecondDimensionWatcherReDive/Models/Incident.cs +++ b/SecondDimensionWatcherReDive/Models/Incident.cs @@ -17,4 +17,5 @@ public class Incident public int RetryCount { get; set; } public DateTimeOffset? LastRetryAt { get; set; } public string? LastRetryError { get; set; } + public int Occurrence { get; set; } = 1; } diff --git a/SecondDimensionWatcherReDive/Repositories/IncidentRepository.cs b/SecondDimensionWatcherReDive/Repositories/IncidentRepository.cs index 1e7d645b..443e29d8 100644 --- a/SecondDimensionWatcherReDive/Repositories/IncidentRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/IncidentRepository.cs @@ -76,15 +76,7 @@ public async Task UpsertAsync(Incident incident, CancellationToken can } else { - entity.Type = incident.Type; - entity.Severity = incident.Severity; - entity.Title = incident.Title; - entity.Detail = incident.Detail; - entity.SourceId = incident.SourceId; - entity.UpdatedAt = incident.UpdatedAt; - // A recurring fault reopens the same logical incident, preserving its - // first-seen time and retry history. - entity.ResolvedAt = null; + ApplyReport(entity, incident); } try @@ -100,13 +92,7 @@ public async Task UpsertAsync(Incident incident, CancellationToken can context.Entry(entity).State = EntityState.Detached; entity = await context.Incidents .FirstAsync(candidate => candidate.Fingerprint == incident.Fingerprint, cancellationToken); - entity.Type = incident.Type; - entity.Severity = incident.Severity; - entity.Title = incident.Title; - entity.Detail = incident.Detail; - entity.SourceId = incident.SourceId; - entity.UpdatedAt = incident.UpdatedAt; - entity.ResolvedAt = null; + ApplyReport(entity, incident); await context.SaveChangesAsync(cancellationToken); } return ToRecord(entity); @@ -177,7 +163,8 @@ public async Task UpsertAsync(Incident incident, CancellationToken can entity.ResolvedAt, entity.RetryCount, entity.LastRetryAt, - entity.LastRetryError); + entity.LastRetryError, + entity.Occurrence); private static IncidentEntity ToEntity(Incident record) => new() { @@ -193,6 +180,25 @@ public async Task UpsertAsync(Incident incident, CancellationToken can ResolvedAt = record.ResolvedAt, RetryCount = record.RetryCount, LastRetryAt = record.LastRetryAt, - LastRetryError = record.LastRetryError + LastRetryError = record.LastRetryError, + Occurrence = Math.Max(1, record.Occurrence) }; + + private static void ApplyReport(IncidentEntity entity, Incident incident) + { + var isReopening = entity.ResolvedAt is not null; + entity.Type = incident.Type; + entity.Severity = incident.Severity; + entity.Title = incident.Title; + entity.Detail = incident.Detail; + entity.SourceId = incident.SourceId; + entity.UpdatedAt = incident.UpdatedAt; + // Keep the logical incident and its first-seen/retry history, but give + // each resolved -> open transition a stable occurrence discriminator. + // Concurrent reporters calculate the same next number, so the outbox's + // unique key still coalesces duplicate reports for that occurrence. + if (isReopening) + entity.Occurrence = Math.Max(1, entity.Occurrence) + 1; + entity.ResolvedAt = null; + } } diff --git a/SecondDimensionWatcherReDive/Repositories/TodoRepository.cs b/SecondDimensionWatcherReDive/Repositories/TodoRepository.cs index 55b55d9c..2458902c 100644 --- a/SecondDimensionWatcherReDive/Repositories/TodoRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/TodoRepository.cs @@ -9,123 +9,137 @@ public async Task GetAsync( bool includeRead, bool includeSnoozed, DateTimeOffset now, + int skip, + int take, CancellationToken cancellationToken) { - var automation = await context.AnimationInfo - .AsNoTracking() - .Where(info => info.AutomationDisposition == SubscriptionAutomationDisposition.Notified - || info.AutomationDisposition == SubscriptionAutomationDisposition.PendingConfirmation - || info.AutomationDisposition == SubscriptionAutomationDisposition.AutoDownloadFailed) - .Select(info => new + var automation = + from info in context.AnimationInfo.AsNoTracking() + where info.AutomationDisposition == SubscriptionAutomationDisposition.Notified + || info.AutomationDisposition == SubscriptionAutomationDisposition.PendingConfirmation + || info.AutomationDisposition == SubscriptionAutomationDisposition.AutoDownloadFailed + let key = "automation:" + info.Id.ToString() + join candidateState in context.TodoItemStates.AsNoTracking() + on key equals candidateState.Key into candidateStates + from state in candidateStates.DefaultIfEmpty() + select new TodoQueryRow { - info.Id, - info.Title, - info.PublishTime, - info.AutomationDisposition - }) - .ToListAsync(cancellationToken); - - var incidents = await context.Incidents - .AsNoTracking() - .Where(incident => incident.ResolvedAt == null) - .Select(incident => new - { - incident.Id, - incident.Type, - incident.Severity, - incident.Title, - incident.Detail, - incident.DetectedAt - }) - .ToListAsync(cancellationToken); - - var metadata = await context.AnimationInfo - .AsNoTracking() - .Where(info => info.MetadataStatus == MetadataReviewStatus.LowConfidence - || info.MetadataStatus == MetadataReviewStatus.Failed) - .Select(info => new - { - info.Id, - info.Title, - info.MetadataStatus, - info.MetadataLastError, - info.PublishTime - }) - .ToListAsync(cancellationToken); - - var keys = automation.Select(info => $"automation:{info.Id}") - .Concat(incidents.Select(incident => $"incident:{incident.Id}")) - .Concat(metadata.Select(info => $"metadata:{info.Id}")) - .ToArray(); - var states = await context.TodoItemStates - .AsNoTracking() - .Where(state => keys.Contains(state.Key)) - .ToDictionaryAsync(state => state.Key, cancellationToken); - - var items = new List(keys.Length); - foreach (var info in automation) - { - var key = $"automation:{info.Id}"; - var (type, priority, detail) = info.AutomationDisposition switch - { - SubscriptionAutomationDisposition.PendingConfirmation => - (TodoItemType.DownloadPendingConfirmation, TodoPriority.High, - "A matched release is waiting for download confirmation."), - SubscriptionAutomationDisposition.AutoDownloadFailed => - (TodoItemType.DownloadFailed, TodoPriority.Critical, - "Automatic download could not be started. Review and retry it."), - _ => (TodoItemType.ReleaseMatched, TodoPriority.Normal, - "A notify-only subscription matched this release.") + Key = key, + Type = info.AutomationDisposition == SubscriptionAutomationDisposition.PendingConfirmation + ? TodoItemType.DownloadPendingConfirmation + : info.AutomationDisposition == SubscriptionAutomationDisposition.AutoDownloadFailed + ? TodoItemType.DownloadFailed + : TodoItemType.ReleaseMatched, + Priority = info.AutomationDisposition == SubscriptionAutomationDisposition.AutoDownloadFailed + ? TodoPriority.Critical + : info.AutomationDisposition == SubscriptionAutomationDisposition.PendingConfirmation + ? TodoPriority.High + : TodoPriority.Normal, + Title = info.Title, + Detail = info.AutomationDisposition == SubscriptionAutomationDisposition.PendingConfirmation + ? "A matched release is waiting for download confirmation." + : info.AutomationDisposition == SubscriptionAutomationDisposition.AutoDownloadFailed + ? "Automatic download could not be started. Review and retry it." + : "A notify-only subscription matched this release.", + DeepLink = "/todo?focus=" + key, + ResourceId = info.Id, + OccurredAt = info.PublishTime, + ReadAt = state == null ? null : state.ReadAt, + SnoozedUntil = state == null ? null : state.SnoozedUntil }; - items.Add(Create( - key, type, priority, info.Title, detail, - $"/todo?focus={Uri.EscapeDataString(key)}", info.Id, info.PublishTime, states)); - } - foreach (var incident in incidents) - { - var key = $"incident:{incident.Id}"; - var disk = incident.Type == IncidentType.DiskSpaceLow; - items.Add(Create( - key, - disk ? TodoItemType.DiskSpaceLow : TodoItemType.Incident, - incident.Severity == IncidentSeverity.Critical + var incidents = + from incident in context.Incidents.AsNoTracking() + where incident.ResolvedAt == null + let baseKey = "incident:" + incident.Id.ToString() + let key = incident.Occurrence <= 1 + ? baseKey + : baseKey + ":" + incident.Occurrence.ToString() + join candidateState in context.TodoItemStates.AsNoTracking() + on key equals candidateState.Key into candidateStates + from state in candidateStates.DefaultIfEmpty() + select new TodoQueryRow + { + Key = key, + Type = incident.Type == IncidentType.DiskSpaceLow + ? TodoItemType.DiskSpaceLow + : TodoItemType.Incident, + Priority = incident.Severity == IncidentSeverity.Critical ? TodoPriority.Critical : TodoPriority.High, - incident.Title, - incident.Detail, - disk ? "/incidents?type=diskSpaceLow" : $"/incidents?focus={incident.Id}", - incident.Id, - incident.DetectedAt, - states)); - } + Title = incident.Title, + Detail = incident.Detail, + DeepLink = incident.Type == IncidentType.DiskSpaceLow + ? "/incidents?type=diskSpaceLow" + : "/incidents?focus=" + incident.Id.ToString(), + ResourceId = incident.Id, + OccurredAt = incident.UpdatedAt, + ReadAt = state == null ? null : state.ReadAt, + SnoozedUntil = state == null ? null : state.SnoozedUntil + }; - foreach (var info in metadata) - { - var key = $"metadata:{info.Id}"; - items.Add(Create( - key, - TodoItemType.MetadataReview, - info.MetadataStatus == MetadataReviewStatus.Failed + var metadata = + from info in context.AnimationInfo.AsNoTracking() + where info.MetadataStatus == MetadataReviewStatus.LowConfidence + || info.MetadataStatus == MetadataReviewStatus.Failed + let key = "metadata:" + info.Id.ToString() + join candidateState in context.TodoItemStates.AsNoTracking() + on key equals candidateState.Key into candidateStates + from state in candidateStates.DefaultIfEmpty() + select new TodoQueryRow + { + Key = key, + Type = TodoItemType.MetadataReview, + Priority = info.MetadataStatus == MetadataReviewStatus.Failed ? TodoPriority.High : TodoPriority.Normal, - info.Title, - info.MetadataLastError ?? "Metadata confidence is low and needs review.", - $"/metadata-review?status={(info.MetadataStatus == MetadataReviewStatus.Failed ? "failed" : "lowConfidence")}&focus={info.Id}", - info.Id, - info.PublishTime, - states)); - } + Title = info.Title, + Detail = info.MetadataLastError ?? "Metadata confidence is low and needs review.", + DeepLink = info.MetadataStatus == MetadataReviewStatus.Failed + ? "/metadata-review?status=failed&focus=" + info.Id.ToString() + : "/metadata-review?status=lowConfidence&focus=" + info.Id.ToString(), + ResourceId = info.Id, + OccurredAt = info.PublishTime, + ReadAt = state == null ? null : state.ReadAt, + SnoozedUntil = state == null ? null : state.SnoozedUntil + }; + + var allItems = automation.Concat(incidents).Concat(metadata); + var unreadCount = await allItems.CountAsync( + item => item.ReadAt == null + && (item.SnoozedUntil == null || item.SnoozedUntil <= now), + cancellationToken); - var unreadCount = items.Count(item => item.ReadAt is null - && (item.SnoozedUntil is null || item.SnoozedUntil <= now)); - var filtered = items - .Where(item => includeRead || item.ReadAt is null) - .Where(item => includeSnoozed || item.SnoozedUntil is null || item.SnoozedUntil <= now) + var visibleItems = allItems; + if (!includeRead) + visibleItems = visibleItems.Where(item => item.ReadAt == null); + if (!includeSnoozed) + visibleItems = visibleItems.Where( + item => item.SnoozedUntil == null || item.SnoozedUntil <= now); + + var totalCount = await visibleItems.CountAsync(cancellationToken); + var rows = await visibleItems .OrderByDescending(item => item.Priority) .ThenByDescending(item => item.OccurredAt) - .ToList(); - return new TodoPage(filtered, filtered.Count, unreadCount); + .ThenBy(item => item.Key) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + + return new TodoPage( + rows.Select(item => new TodoItem( + item.Key, + item.Type, + item.Priority, + item.Title, + item.Detail, + item.DeepLink, + item.ResourceId, + item.OccurredAt, + item.ReadAt, + item.SnoozedUntil)).ToList(), + totalCount, + unreadCount); } public async Task SetStateAsync( @@ -154,20 +168,17 @@ public async Task SetStateAsync( await context.SaveChangesAsync(cancellationToken); } - private static TodoItem Create( - string key, - TodoItemType type, - TodoPriority priority, - string title, - string detail, - string deepLink, - Guid resourceId, - DateTimeOffset occurredAt, - IReadOnlyDictionary states) + private sealed class TodoQueryRow { - states.TryGetValue(key, out var state); - return new TodoItem( - key, type, priority, title, detail, deepLink, resourceId, - occurredAt, state?.ReadAt, state?.SnoozedUntil); + public string Key { get; init; } = string.Empty; + public TodoItemType Type { get; init; } + public TodoPriority Priority { get; init; } + public string Title { get; init; } = string.Empty; + public string Detail { get; init; } = string.Empty; + public string DeepLink { get; init; } = string.Empty; + public Guid? ResourceId { get; init; } + public DateTimeOffset OccurredAt { get; init; } + public DateTimeOffset? ReadAt { get; init; } + public DateTimeOffset? SnoozedUntil { get; init; } } } diff --git a/SecondDimensionWatcherReDive/Repositories/TodoRepositoryPostgreSqlTestFixture.cs b/SecondDimensionWatcherReDive/Repositories/TodoRepositoryPostgreSqlTestFixture.cs new file mode 100644 index 00000000..42695b06 --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/TodoRepositoryPostgreSqlTestFixture.cs @@ -0,0 +1,107 @@ +using Microsoft.EntityFrameworkCore; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Repositories; + +/// +/// Owns PostgreSQL setup and seed data for todo/incident repository integration +/// tests without exposing the EF context outside the repository boundary. +/// +internal sealed class TodoRepositoryPostgreSqlTestFixture(string connectionString) +{ + private readonly DbContextOptions _contextOptions = + new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .Options; + + public async Task ResetAsync(CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + await context.Database.ExecuteSqlRawAsync( + "TRUNCATE TABLE \"TodoItemStates\", \"Incidents\", \"AnimationInfo\" RESTART IDENTITY CASCADE", + cancellationToken); + } + + public async Task SeedAnimationInfoAsync( + string title, + DateTimeOffset publishTime, + SubscriptionAutomationDisposition? automationDisposition, + MetadataReviewStatus metadataStatus, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var entity = new Models.AnimationInfo + { + Id = Guid.NewGuid(), + Title = title, + PublishTime = publishTime, + AutomationDisposition = automationDisposition, + MetadataStatus = metadataStatus + }; + await context.AnimationInfo.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + return entity.Id; + } + + public async Task UpsertIncidentAsync( + Incident incident, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new IncidentRepository(context).UpsertAsync(incident, cancellationToken); + } + + public async Task ResolveIncidentAsync( + string fingerprint, + DateTimeOffset resolvedAt, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new IncidentRepository(context).ResolveByFingerprintAsync( + fingerprint, + resolvedAt, + cancellationToken); + } + + public async Task GetTodosAsync( + bool includeRead, + bool includeSnoozed, + DateTimeOffset now, + int skip, + int take, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new TodoRepository(context).GetAsync( + includeRead, + includeSnoozed, + now, + skip, + take, + cancellationToken); + } + + public async Task SetTodoStateAsync( + IReadOnlyCollection keys, + DateTimeOffset? readAt, + bool updateReadAt, + DateTimeOffset? snoozedUntil, + bool updateSnoozedUntil, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + await new TodoRepository(context).SetStateAsync( + keys, + readAt, + updateReadAt, + snoozedUntil, + updateSnoozedUntil, + cancellationToken); + } + + public async Task GetTodoStateCountAsync(CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await context.TodoItemStates.CountAsync(cancellationToken); + } +} diff --git a/SecondDimensionWatcherReDive/Utils/Incidents/IncidentReporter.cs b/SecondDimensionWatcherReDive/Utils/Incidents/IncidentReporter.cs index f2c7f1be..aef0db80 100644 --- a/SecondDimensionWatcherReDive/Utils/Incidents/IncidentReporter.cs +++ b/SecondDimensionWatcherReDive/Utils/Incidents/IncidentReporter.cs @@ -46,9 +46,15 @@ public async Task ReportAsync( var notificationType = isDiskSpaceLow ? NotificationEventType.DiskSpaceLow : NotificationEventType.IncidentOpened; + var deduplicationKey = + $"{(isDiskSpaceLow ? "disk-space-low" : "incident-opened")}:{saved.Id}"; + // Occurrence 1 deliberately retains the pre-occurrence key so an + // upgrade does not redeliver notifications already in the outbox. + if (saved.Occurrence > 1) + deduplicationKey += $":{saved.Occurrence}"; await notificationPublisher.PublishAsync(new NotificationEvent( notificationType, - $"{(isDiskSpaceLow ? "disk-space-low" : "incident-opened")}:{saved.Id}", + deduplicationKey, saved.Title, saved.Detail, isDiskSpaceLow From 50feba8993cca999a736e5f28d54e7d443396b6b Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Mon, 31 Aug 2026 13:12:59 +0800 Subject: [PATCH 3/7] fix: harden notification and todo delivery --- README.md | 10 +- .../mock-server.mjs | 151 ++- .../settings/NotificationSettingsSection.tsx | 264 +++- .../src/i18n/locales/en/settings.json | 24 +- .../src/i18n/locales/ja/settings.json | 24 +- .../src/i18n/locales/zh-CN/settings.json | 24 +- .../src/incidents/hooks.ts | 3 + .../src/metadataReview/api.ts | 7 +- .../src/notifications/api.ts | 36 + .../src/notifications/hooks.ts | 8 + .../src/notifications/pushServiceWorker.js | 51 + .../src/notifications/types.ts | 17 + .../src/notifications/webPush.ts | 71 + .../src/pages/IncidentsPage.tsx | 13 +- .../src/pages/MetadataReviewPage.tsx | 18 +- .../src/pages/TodoPage.tsx | 17 +- .../src/settings/systemTypes.ts | 7 +- .../src/todos/hooks.ts | 6 +- .../src/todos/state.test.ts | 18 - .../IMetadataReviewRepository.cs | 1 + .../INotificationOutboxRepository.cs | 21 +- .../DataRepository/ITodoRepository.cs | 1 + .../IWebPushSubscriptionRepository.cs | 43 + .../Notifications/NotificationEvent.cs | 2 +- .../FileMappingRepositoryPostgreSqlTests.cs | 1 - .../CompleteDownloadBackgroundServiceTests.cs | 10 +- .../IncidentReporterTests.cs | 91 -- .../NotificationPipelineTests.cs | 226 --- .../RuntimeSettingsServiceTests.cs | 45 - .../SyncFeedTests.cs | 21 +- .../TodosControllerTests.cs | 114 -- .../Configuration/RuntimeSettingsModels.cs | 124 +- .../Configuration/RuntimeSettingsService.cs | 56 +- .../Controllers/AnimationInfoController.cs | 23 +- .../External/AppJsonSerializerContext.cs | 5 + .../External/ApplicationSettings.cs | 91 +- .../Controllers/External/Notifications.cs | 28 + .../Controllers/External/Todos.cs | 4 +- .../Controllers/IncidentsController.cs | 13 +- .../Controllers/MetadataReviewController.cs | 2 + .../Controllers/NotificationsController.cs | 26 +- .../Controllers/SettingsController.cs | 22 +- .../Controllers/TodosController.cs | 4 + .../WebPushSubscriptionsController.cs | 177 +++ ..._AddNotificationsAndTodoCenter.Designer.cs | 2 + ...829135234_AddNotificationsAndTodoCenter.cs | 9 + ...0030124_AddIncidentOccurrences.Designer.cs | 2 + ...045113_AddWebPushNotifications.Designer.cs | 1206 +++++++++++++++++ .../20260831045113_AddWebPushNotifications.cs | 148 ++ .../ApplicationContextModelSnapshot.cs | 64 + .../Models/ApplicationContext.cs | 40 + .../Models/NotificationOutboxMessage.cs | 3 + .../Models/WebPushSubscription.cs | 15 + SecondDimensionWatcherReDive/Program.cs | 56 +- .../Repositories/AnimationInfoRepository.cs | 90 +- .../Repositories/IncidentRepository.cs | 33 + .../Repositories/MetadataReviewRepository.cs | 19 + .../NotificationOutboxRepository.cs | 103 +- .../Repositories/TodoRepository.cs | 137 +- .../TodoRepositoryPostgreSqlTestFixture.cs | 107 -- .../WebPushSubscriptionRepository.cs | 162 +++ .../SecondDimensionWatcherReDive.csproj | 1 + .../FetchRemoteTorrentBackgroundService.cs | 13 +- .../Services/SyncFeed.cs | 22 +- .../Utils/Incidents/IncidentReporter.cs | 2 +- .../NotificationDeliveryBackgroundService.cs | 424 +++++- .../Notifications/NotificationPublisher.cs | 234 +++- .../appsettings.example.json | 15 +- THIRD_PARTY_NOTICES.md | 1 + packaging/appsettings.yml | 10 +- 70 files changed, 3944 insertions(+), 894 deletions(-) create mode 100644 SecondDimensionWatcherReDive.Client/src/notifications/pushServiceWorker.js create mode 100644 SecondDimensionWatcherReDive.Client/src/notifications/webPush.ts delete mode 100644 SecondDimensionWatcherReDive.Client/src/todos/state.test.ts create mode 100644 SecondDimensionWatcherReDive.Framework/DataRepository/IWebPushSubscriptionRepository.cs delete mode 100644 SecondDimensionWatcherReDive.Test/NotificationPipelineTests.cs delete mode 100644 SecondDimensionWatcherReDive.Test/TodosControllerTests.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/WebPushSubscriptionsController.cs create mode 100644 SecondDimensionWatcherReDive/Migrations/20260831045113_AddWebPushNotifications.Designer.cs create mode 100644 SecondDimensionWatcherReDive/Migrations/20260831045113_AddWebPushNotifications.cs create mode 100644 SecondDimensionWatcherReDive/Models/WebPushSubscription.cs delete mode 100644 SecondDimensionWatcherReDive/Repositories/TodoRepositoryPostgreSqlTestFixture.cs create mode 100644 SecondDimensionWatcherReDive/Repositories/WebPushSubscriptionRepository.cs diff --git a/README.md b/README.md index 6162b9f5..117891f8 100644 --- a/README.md +++ b/README.md @@ -107,8 +107,10 @@ bash <(curl -fsSL https://raw.githubusercontent.com/HCGStudio/SecondDimensionWat | `AI:Anthropic:ApiKey` / `BaseUrl` / `Model` / `MaxTokens` / `ApiVersion` | Anthropic 端点 | | `AI:CodexAppServer:Endpoint` / `BearerToken` / `Model` / `PermissionProfile` / `TimeoutSeconds` | Codex app-server WebSocket 端点;空模型使用服务端默认模型;权限配置默认 `:read-only`,也可填写管理员定义的 profile id | | `Inference:RateLimitDelayMs` | 推断 API 调用最小间隔(毫秒,默认 1000) | -| `Notifications:Webhook:Enabled` / `Url` | 通用 Webhook 通知渠道;完整 URL 按敏感配置处理,建议从网页设置中保存 | -| `Notifications:Events` / `QuietHours` | 允许投递的领域事件与可选免打扰时段;事件先写入持久化 Outbox,再异步重试投递 | +| `Notifications:Webhook:Enabled` / `Url` | 通用 Webhook 通知渠道;完整 URL 按敏感配置处理,建议从网页设置中保存;远端地址必须使用 HTTPS | +| `Notifications:WebPush:Enabled` / `Subject` / `VapidPublicKey` / `VapidPrivateKey` | 浏览器 Web Push;首次在网页启用时可由服务端生成 VAPID 密钥对,私钥加密保存;浏览器订阅需要 HTTPS 或 localhost 安全来源 | +| `Notifications:Events` / `QuietHours` | 允许投递的领域事件与可选免打扰时段;启用且选中的事件会在核心操作完成后尽力写入持久化 Outbox,再异步重试投递 | +| `OutboundHttp:AllowedPrivateHosts` / `AllowedPrivateNetworks` | RSS、Webhook 与 Web Push 端点默认拒绝 loopback/私网目的地;确有需要时仅精确放行目标主机或 CIDR | | `Valkey:ConnectionString` | Valkey / Redis 连接(单副本可选;多副本必须共享同一实例) | | `ReverseProxy:KnownProxies` / `KnownNetworks` | 非 loopback 反向代理的受信地址/CIDR;仅填写代理,不填写客户端网段 | @@ -120,9 +122,9 @@ bash <(curl -fsSL https://raw.githubusercontent.com/HCGStudio/SecondDimensionWat ### 网页运行时设置 -登录后打开「设置」,可修改 AI 执行模式与 Provider、AI/TMDB 密钥、qBittorrent、媒体库扫描、异常检测、通知和 NFS。保存值存入 PostgreSQL,并覆盖部署文件或环境变量中的默认值;密钥、密码和 Webhook URL 使用持久化 Data Protection 密钥环加密,API 不会回显明文。可对单个敏感项选择保留、替换、清除或恢复部署默认值。 +登录后打开「设置」,可修改 AI 执行模式与 Provider、AI/TMDB 密钥、qBittorrent、媒体库扫描、异常检测、通知和 NFS。保存值存入 PostgreSQL,并覆盖部署文件或环境变量中的默认值;密钥、密码、Webhook URL、VAPID 私钥及浏览器 PushSubscription 能力凭据使用持久化 Data Protection 密钥环加密,API 不会回显明文。可对单个敏感项选择保留、替换、清除或恢复部署默认值。 -通知先以唯一去重键写入 PostgreSQL Outbox,再由后台服务投递。Webhook 请求带有稳定的 `X-SDW-Event-Id`,接收端应以此做幂等;5xx、408、429 和网络错误会指数退避重试,永久失败可在「设置 → 通知」查看,且任何投递失败都不会回滚订阅、下载、推断或异常处理。顶栏「待办中心」会按风险汇总待确认下载、异常、低置信度/失败元数据和磁盘预警,并支持已读、稍后提醒及无副作用批量操作。 +启用且订阅的通知会在核心操作完成后,以唯一去重键尽力写入 PostgreSQL Outbox,再由后台服务按至少一次语义投递。Webhook 和每个 Web Push 浏览器订阅拥有独立投递行、租约与重试状态,一个渠道失败不会重复投递另一个渠道;Webhook 请求带有稳定的 `X-SDW-Event-Id`,Web Push 也使用同一事件 ID 作为通知标签,接收端仍应按事件 ID 幂等。5xx、408、429 和网络错误会指数退避重试,失效的浏览器订阅会在 404/410 后撤销,永久失败可在「设置 → 通知」查看,且任何投递或入队失败都不会回滚订阅、下载、推断或异常处理。顶栏「待办中心」会按风险汇总待确认下载、异常、低置信度/失败元数据和磁盘预警,并支持已读、稍后提醒及无副作用批量操作。 数据库连接、JWT、下载存储根目录、登录密码文件、CORS 和 Valkey 仍属于启动/基础设施配置,不允许从网页修改。NFS 监听地址、端口和启用状态会保存,但需要重启应用才能切换;其余上述设置对后续请求和新任务热生效。后台定时任务的间隔变更不会中断已经开始的等待,最迟会在当前等待周期结束后采用新值。 diff --git a/SecondDimensionWatcherReDive.Client/mock-server.mjs b/SecondDimensionWatcherReDive.Client/mock-server.mjs index 735e99a0..eaab686e 100644 --- a/SecondDimensionWatcherReDive.Client/mock-server.mjs +++ b/SecondDimensionWatcherReDive.Client/mock-server.mjs @@ -1081,6 +1081,10 @@ let systemSettings = { }, notifications: { webhookEnabled: false, + webPushEnabled: false, + webPushSubject: "", + vapidPublicKey: "", + vapidPrivateKey: { isConfigured: false, source: "none" }, events: [ "releaseMatched", "downloadPendingConfirmation", @@ -1107,6 +1111,9 @@ const deploymentSecrets = { }; let notificationDeliveries = []; +let webPushSubscriptions = []; +const mockVapidPublicKey = + "BGb1EKTo02dge1GKm7kU8hSQowk4T8Qnpl8dOB1nrnSQJnrhc6OdQ3a4gtyGTkera6bMWIp9cKAlEdN_BA6gGQM"; function applySecretMutation(current, mutation, deploymentValue) { if (!mutation || mutation.operation === "keep") return current; @@ -1765,8 +1772,19 @@ async function route(method, pathname, searchParams, req, res) { } if (body.notifications) { + const generateVapidKeys = + body.notifications.generateVapidKeys && + !systemSettings.notifications.vapidPrivateKey.isConfigured; systemSettings.notifications = { webhookEnabled: body.notifications.webhookEnabled, + webPushEnabled: body.notifications.webPushEnabled, + webPushSubject: body.notifications.webPushSubject, + vapidPublicKey: generateVapidKeys + ? mockVapidPublicKey + : systemSettings.notifications.vapidPublicKey, + vapidPrivateKey: generateVapidKeys + ? { isConfigured: true, source: "runtime" } + : systemSettings.notifications.vapidPrivateKey, events: [...body.notifications.events], quietHoursStart: body.notifications.quietHoursStart, quietHoursEnd: body.notifications.quietHoursEnd, @@ -1786,23 +1804,112 @@ async function route(method, pathname, searchParams, req, res) { } } + if ( + method === "GET" && + pathname === "/api/notifications/web-push/config" + ) { + return json(res, { + enabled: systemSettings.notifications.webPushEnabled, + vapidPublicKey: systemSettings.notifications.vapidPublicKey, + }); + } + + if ( + method === "GET" && + pathname === "/api/notifications/web-push/subscriptions" + ) { + return json( + res, + webPushSubscriptions.map(({ endpoint: _endpoint, ...summary }) => summary), + ); + } + + if ( + method === "POST" && + pathname === "/api/notifications/web-push/subscriptions" + ) { + if (!systemSettings.notifications.webPushEnabled) + return json(res, { message: "Enable Web Push first" }, 409); + const body = await readBody(req); + const now = new Date().toISOString(); + let subscription = webPushSubscriptions.find( + (item) => item.endpoint === body.endpoint, + ); + if (subscription) { + subscription.updatedAt = now; + subscription.lastError = null; + } else { + subscription = { + id: randomUUID(), + endpoint: body.endpoint, + endpointOrigin: new URL(body.endpoint).origin, + createdAt: now, + updatedAt: now, + lastSuccessAt: null, + lastFailureAt: null, + lastError: null, + }; + webPushSubscriptions.unshift(subscription); + } + const { endpoint: _endpoint, ...summary } = subscription; + return json(res, summary); + } + + if ( + method === "POST" && + pathname === + "/api/notifications/web-push/subscriptions/remove-current" + ) { + const body = await readBody(req); + webPushSubscriptions = webPushSubscriptions.filter( + (item) => item.endpoint !== body.endpoint, + ); + res.writeHead(204); + return res.end(); + } + + const webPushDeleteMatch = pathname.match( + /^\/api\/notifications\/web-push\/subscriptions\/([^/]+)$/, + ); + if (method === "DELETE" && webPushDeleteMatch) { + const before = webPushSubscriptions.length; + webPushSubscriptions = webPushSubscriptions.filter( + (item) => item.id !== webPushDeleteMatch[1], + ); + res.writeHead(before === webPushSubscriptions.length ? 404 : 204); + return res.end(); + } + if (method === "POST" && pathname === "/api/notifications/test") { - if ( - !systemSettings.notifications.webhookEnabled || - !systemSettings.notifications.webhookUrl.isConfigured - ) - return json(res, { message: "Configure the webhook first" }, 409); + const webhookReady = + systemSettings.notifications.webhookEnabled && + systemSettings.notifications.webhookUrl.isConfigured; + const webPushReady = + systemSettings.notifications.webPushEnabled && + webPushSubscriptions.length > 0; + if (!webhookReady && !webPushReady) + return json(res, { message: "Configure a destination first" }, 409); const eventId = randomUUID(); - notificationDeliveries.unshift({ - id: eventId, - type: "test", - status: "Delivered", - attemptCount: 1, - occurredAt: new Date().toISOString(), - lastAttemptAt: new Date().toISOString(), - deliveredAt: new Date().toISOString(), - lastError: null, - }); + const channels = [ + ...(webhookReady ? ["Webhook"] : []), + ...webPushSubscriptions + .filter(() => webPushReady) + .map(() => "WebPush"), + ]; + notificationDeliveries.unshift( + ...channels.map((channel, index) => ({ + id: index === 0 ? eventId : randomUUID(), + eventId, + channel, + type: "test", + status: "Delivered", + attemptCount: 1, + occurredAt: new Date().toISOString(), + lastAttemptAt: new Date().toISOString(), + deliveredAt: new Date().toISOString(), + lastError: null, + })), + ); return json(res, { eventId }, 202); } @@ -1817,6 +1924,12 @@ async function route(method, pathname, searchParams, req, res) { if (method === "GET" && pathname === "/api/todos") { const includeRead = searchParams.get("includeRead") === "true"; const includeSnoozed = searchParams.get("includeSnoozed") === "true"; + const skip = Math.max(0, Number(searchParams.get("skip")) || 0); + const take = Math.min( + 200, + Math.max(1, Number(searchParams.get("take")) || 50), + ); + const focus = searchParams.get("focus"); const now = Date.now(); const all = currentMockTodos(); const unreadCount = all.filter( @@ -1824,14 +1937,18 @@ async function route(method, pathname, searchParams, req, res) { !item.readAt && (!item.snoozedUntil || new Date(item.snoozedUntil) <= now), ).length; - const items = all.filter( + const visible = all.filter( (item) => (includeRead || !item.readAt) && (includeSnoozed || !item.snoozedUntil || new Date(item.snoozedUntil) <= now), ); - return json(res, { items, totalCount: items.length, unreadCount }); + const items = visible.slice(skip, skip + take); + const focused = focus && all.find((item) => item.key === focus); + if (focused && !items.some((item) => item.key === focused.key)) + items.unshift(focused); + return json(res, { items, totalCount: visible.length, unreadCount }); } if (method === "PATCH" && pathname === "/api/todos/state") { diff --git a/SecondDimensionWatcherReDive.Client/src/components/settings/NotificationSettingsSection.tsx b/SecondDimensionWatcherReDive.Client/src/components/settings/NotificationSettingsSection.tsx index 4a705cc5..6e164801 100644 --- a/SecondDimensionWatcherReDive.Client/src/components/settings/NotificationSettingsSection.tsx +++ b/SecondDimensionWatcherReDive.Client/src/components/settings/NotificationSettingsSection.tsx @@ -1,13 +1,34 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { BellRing, Clock3, History, Send, Webhook } from "lucide-react"; +import { + BellRing, + Clock3, + History, + MonitorSmartphone, + Send, + Trash2, + Webhook, +} from "lucide-react"; -import { sendTestNotification } from "../../notifications/api"; -import { useNotificationDeliveries } from "../../notifications/hooks"; +import { + removeWebPushSubscription, + sendTestNotification, +} from "../../notifications/api"; +import { + useNotificationDeliveries, + useWebPushSubscriptions, +} from "../../notifications/hooks"; +import { + disableWebPushForCurrentDevice, + enableWebPushForCurrentDevice, + getCurrentWebPushSubscription, + isWebPushSupported, +} from "../../notifications/webPush"; import { NotificationEventType, NotificationSettings, + NotificationSettingsPatch, SecretDraft, SystemSettings, createSecretDraft, @@ -38,9 +59,7 @@ const eventTypes: NotificationEventType[] = [ export interface NotificationSettingsSectionProps { value: NotificationSettings; onSave: (patch: { - notifications: Omit & { - webhookUrl: ReturnType; - }; + notifications: NotificationSettingsPatch; }) => Promise; } @@ -51,6 +70,8 @@ export const NotificationSettingsSection: React.FC< const { addToast } = useToast(); const { data: deliveries, mutate: mutateDeliveries } = useNotificationDeliveries(); + const { data: subscriptions, mutate: mutateSubscriptions } = + useWebPushSubscriptions(); const [draft, setDraft] = React.useState(() => ({ ...value, events: [...value.events], @@ -59,8 +80,24 @@ export const NotificationSettingsSection: React.FC< React.useState(createSecretDraft); const [saving, setSaving] = React.useState(false); const [testing, setTesting] = React.useState(false); + const [webPushBusy, setWebPushBusy] = React.useState(false); + const [deviceSubscribed, setDeviceSubscribed] = React.useState(false); const [saved, setSaved] = React.useState(false); + React.useEffect(() => { + let active = true; + void getCurrentWebPushSubscription() + .then((subscription) => { + if (active) setDeviceSubscribed(subscription !== null); + }) + .catch(() => { + if (active) setDeviceSubscribed(false); + }); + return () => { + active = false; + }; + }, []); + React.useEffect(() => { setDraft({ ...value, events: [...value.events] }); setUrlDraft(createSecretDraft()); @@ -80,6 +117,7 @@ export const NotificationSettingsSection: React.FC< !value.webhookUrl.isConfigured && !urlDraft.value.trim()) || (draft.webhookEnabled && urlDraft.operation === "clear"); + const webPushInvalid = draft.webPushEnabled && !draft.webPushSubject.trim(); const reset = React.useCallback(() => { setDraft({ ...value, events: [...value.events] }); @@ -88,8 +126,8 @@ export const NotificationSettingsSection: React.FC< }, [value]); const save = React.useCallback(async () => { - if (invalid || saving) { - if (invalid) + if (invalid || webPushInvalid || saving) { + if (invalid || webPushInvalid) addToast({ title: t("system.notifications.validationFailed"), color: "warning", @@ -102,11 +140,16 @@ export const NotificationSettingsSection: React.FC< await onSave({ notifications: { webhookEnabled: draft.webhookEnabled, + webPushEnabled: draft.webPushEnabled, + webPushSubject: draft.webPushSubject, events: draft.events, quietHoursStart: draft.quietHoursStart || null, quietHoursEnd: draft.quietHoursEnd || null, timeZoneId: draft.timeZoneId, webhookUrl: secretMutation, + generateVapidKeys: + draft.webPushEnabled && + (!value.vapidPublicKey || !value.vapidPrivateKey.isConfigured), }, }); setSaved(true); @@ -125,7 +168,83 @@ export const NotificationSettingsSection: React.FC< } finally { setSaving(false); } - }, [addToast, draft, invalid, onSave, saving, secretMutation, t]); + }, [ + addToast, + draft, + invalid, + onSave, + saving, + secretMutation, + t, + value.vapidPrivateKey.isConfigured, + value.vapidPublicKey, + webPushInvalid, + ]); + + const enableCurrentDevice = React.useCallback(async () => { + if (webPushBusy || !value.vapidPublicKey) return; + setWebPushBusy(true); + try { + await enableWebPushForCurrentDevice(value.vapidPublicKey); + setDeviceSubscribed(true); + await mutateSubscriptions(); + addToast({ + title: t("system.notifications.webPush.deviceEnabled"), + color: "success", + }); + } catch { + addToast({ + title: t("system.notifications.webPush.deviceFailed"), + color: "danger", + }); + } finally { + setWebPushBusy(false); + } + }, [addToast, mutateSubscriptions, t, value.vapidPublicKey, webPushBusy]); + + const disableCurrentDevice = React.useCallback(async () => { + if (webPushBusy) return; + setWebPushBusy(true); + try { + await disableWebPushForCurrentDevice(); + setDeviceSubscribed(false); + await mutateSubscriptions(); + addToast({ + title: t("system.notifications.webPush.deviceDisabled"), + color: "success", + }); + } catch { + addToast({ + title: t("system.notifications.webPush.deviceFailed"), + color: "danger", + }); + } finally { + setWebPushBusy(false); + } + }, [addToast, mutateSubscriptions, t, webPushBusy]); + + const revokeSubscription = React.useCallback( + async (id: string) => { + if (webPushBusy) return; + setWebPushBusy(true); + try { + await removeWebPushSubscription(id); + await mutateSubscriptions(); + addToast({ + title: t("system.notifications.webPush.subscriptionRevoked"), + color: "success", + }); + } catch { + addToast({ + title: t("system.notifications.webPush.deviceFailed"), + color: "danger", + }); + } finally { + setWebPushBusy(false); + } + }, + [addToast, mutateSubscriptions, t, webPushBusy], + ); const test = React.useCallback(async () => { setTesting(true); @@ -176,20 +295,120 @@ export const NotificationSettingsSection: React.FC< help={t("system.notifications.webhook.secretHelp")} onChange={setUrlDraft} /> - + } + title={t("system.notifications.webPush.title")} + description={t("system.notifications.webPush.description")} + > +
+ + setDraft((current) => ({ ...current, webPushEnabled })) + } + /> + + + setDraft((current) => ({ + ...current, + webPushSubject: event.target.value, + })) + } + /> + +

+ {value.vapidPrivateKey.isConfigured + ? t("system.notifications.webPush.keysConfigured") + : t("system.notifications.webPush.keysGeneratedOnSave")} +

+
+ + {!isWebPushSupported() ? ( + + {t("system.notifications.webPush.unsupported")} + + ) : null} +
+ {subscriptions?.length ? ( +
    + {subscriptions.map((subscription) => ( +
  • +
    +

    + {subscription.endpointOrigin} +

    +

    + {subscription.lastError ?? + t("system.notifications.webPush.subscriptionActive")} +

    +
    + +
  • + ))} +
+ ) : null} +
+
+ +
+ +
+ } @@ -284,6 +503,11 @@ export const NotificationSettingsSection: React.FC< defaultValue: delivery.type, })} + + {t( + `system.notifications.delivery.channel.${delivery.channel}`, + )} + {delivery.lastError ? (

{delivery.lastError}

) : null} diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json index 50180c5a..b4154c40 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json @@ -171,7 +171,7 @@ "title": "Notifications", "description": "Deliver selected domain events without delaying downloads, inference, or incident handling.", "saved": "Notification settings saved", - "validationFailed": "Configure a webhook, at least one event, and valid quiet hours", + "validationFailed": "Configure a notification channel, at least one event, and valid quiet hours", "testQueued": "Test notification queued", "testFailed": "Test notification could not be queued", "webhook": { @@ -184,6 +184,24 @@ "test": "Send test", "testing": "Queuing test…" }, + "webPush": { + "title": "Browser Web Push", + "description": "Send encrypted notifications to browsers that explicitly subscribe on a secure origin.", + "enabled": "Enable Web Push delivery", + "enabledHelp": "Each browser subscription has independent durable retry and can be revoked without exposing its capability URL.", + "subject": "VAPID contact (mailto: or HTTPS URL)", + "keysConfigured": "The VAPID private key is encrypted at rest. The public key is shared only for browser subscription.", + "keysGeneratedOnSave": "A VAPID key pair will be generated on the server and the private key will be encrypted when you save.", + "enableDevice": "Enable on this device", + "disableDevice": "Disable on this device", + "unsupported": "Web Push requires a supported browser and a secure HTTPS or localhost origin.", + "deviceEnabled": "Web Push enabled on this device", + "deviceDisabled": "Web Push disabled on this device", + "deviceFailed": "Could not update this Web Push subscription", + "subscriptionActive": "Active subscription", + "subscriptionRevoked": "Web Push subscription revoked", + "revokeSubscription": "Revoke subscription" + }, "events": { "title": "Event subscriptions", "description": "Choose which events may create an outbound delivery.", @@ -209,6 +227,10 @@ "title": "Recent delivery records", "description": "Inspect queued, successful, retried, and permanently failed deliveries without exposing the destination.", "empty": "No delivery attempts yet.", + "channel": { + "Webhook": "Webhook", + "WebPush": "Web Push" + }, "status": { "Pending": "Pending", "Processing": "Delivering", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json index fee853d9..2148b0b2 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json @@ -171,7 +171,7 @@ "title": "通知", "description": "ダウンロード、推論、障害処理を妨げずに選択したイベントを配信します。", "saved": "通知設定を保存しました", - "validationFailed": "Webhook、1 件以上のイベント、有効な静穏時間を設定してください", + "validationFailed": "通知チャネル、1 件以上のイベント、有効な静穏時間を設定してください", "testQueued": "テスト通知をキューに追加しました", "testFailed": "テスト通知を追加できませんでした", "webhook": { @@ -184,6 +184,24 @@ "test": "テストを送信", "testing": "キューに追加中…" }, + "webPush": { + "title": "ブラウザー Web Push", + "description": "安全なオリジンで明示的に購読したブラウザーへ暗号化通知を送信します。", + "enabled": "Web Push 配信を有効化", + "enabledHelp": "ブラウザーごとに独立して永続的に再試行し、能力 URL を公開せずに取り消せます。", + "subject": "VAPID 連絡先(mailto: または HTTPS URL)", + "keysConfigured": "VAPID 秘密鍵は暗号化保存され、公開鍵はブラウザー購読にのみ使用されます。", + "keysGeneratedOnSave": "保存時にサーバーで VAPID 鍵ペアを生成し、秘密鍵を暗号化します。", + "enableDevice": "このデバイスで有効化", + "disableDevice": "このデバイスで無効化", + "unsupported": "Web Push には対応ブラウザーと安全な HTTPS または localhost オリジンが必要です。", + "deviceEnabled": "このデバイスで Web Push を有効にしました", + "deviceDisabled": "このデバイスで Web Push を無効にしました", + "deviceFailed": "Web Push 購読を更新できませんでした", + "subscriptionActive": "有効な購読", + "subscriptionRevoked": "Web Push 購読を取り消しました", + "revokeSubscription": "購読を取り消す" + }, "events": { "title": "イベント購読", "description": "外部配信するイベントを選択します。", @@ -209,6 +227,10 @@ "title": "最近の配信記録", "description": "送信先を表示せず、待機、成功、再試行、失敗を確認できます。", "empty": "配信記録はまだありません。", + "channel": { + "Webhook": "Webhook", + "WebPush": "Web Push" + }, "status": { "Pending": "待機中", "Processing": "配信中", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json index 1ca6dfe4..2abf774c 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json @@ -171,7 +171,7 @@ "title": "通知", "description": "在不阻塞下载、推断或异常处理的前提下投递所选领域事件。", "saved": "通知设置已保存", - "validationFailed": "请配置 Webhook、至少一个事件和有效的免打扰时段", + "validationFailed": "请配置通知渠道、至少一个事件和有效的免打扰时段", "testQueued": "测试通知已进入队列", "testFailed": "无法加入测试通知", "webhook": { @@ -184,6 +184,24 @@ "test": "发送测试", "testing": "正在加入队列…" }, + "webPush": { + "title": "浏览器 Web Push", + "description": "向在安全来源上明确订阅的浏览器发送端到端加密通知。", + "enabled": "启用 Web Push 投递", + "enabledHelp": "每个浏览器订阅独立持久重试,并可在不暴露能力 URL 的情况下撤销。", + "subject": "VAPID 联系方式(mailto: 或 HTTPS URL)", + "keysConfigured": "VAPID 私钥已加密保存;公钥只用于浏览器订阅。", + "keysGeneratedOnSave": "保存时会在服务端生成 VAPID 密钥对,并加密保存私钥。", + "enableDevice": "在此设备启用", + "disableDevice": "在此设备停用", + "unsupported": "Web Push 需要受支持的浏览器以及安全的 HTTPS 或 localhost 来源。", + "deviceEnabled": "已在此设备启用 Web Push", + "deviceDisabled": "已在此设备停用 Web Push", + "deviceFailed": "无法更新此 Web Push 订阅", + "subscriptionActive": "订阅有效", + "subscriptionRevoked": "已撤销 Web Push 订阅", + "revokeSubscription": "撤销订阅" + }, "events": { "title": "事件订阅", "description": "选择允许向外投递的事件。", @@ -209,6 +227,10 @@ "title": "最近投递记录", "description": "查看排队、成功、重试和永久失败记录,且不会暴露目标地址。", "empty": "还没有投递记录。", + "channel": { + "Webhook": "Webhook", + "WebPush": "Web Push" + }, "status": { "Pending": "等待中", "Processing": "投递中", diff --git a/SecondDimensionWatcherReDive.Client/src/incidents/hooks.ts b/SecondDimensionWatcherReDive.Client/src/incidents/hooks.ts index 06d3a020..5b0a7f4c 100644 --- a/SecondDimensionWatcherReDive.Client/src/incidents/hooks.ts +++ b/SecondDimensionWatcherReDive.Client/src/incidents/hooks.ts @@ -8,6 +8,7 @@ export interface IncidentQuery { skip?: number; take?: number; includeResolved?: boolean; + focus?: string | null; } export const incidentListKey = ({ @@ -15,6 +16,7 @@ export const incidentListKey = ({ skip = 0, take = 50, includeResolved = false, + focus, }: IncidentQuery = {}): string => { const params = new URLSearchParams({ skip: String(skip), @@ -22,6 +24,7 @@ export const incidentListKey = ({ includeResolved: String(includeResolved), }); if (type) params.set("type", type); + if (focus) params.set("focus", focus); return `/api/incidents?${params.toString()}`; }; diff --git a/SecondDimensionWatcherReDive.Client/src/metadataReview/api.ts b/SecondDimensionWatcherReDive.Client/src/metadataReview/api.ts index 940e3835..249b092c 100644 --- a/SecondDimensionWatcherReDive.Client/src/metadataReview/api.ts +++ b/SecondDimensionWatcherReDive.Client/src/metadataReview/api.ts @@ -11,13 +11,18 @@ import { export const METADATA_REVIEW_PAGE_SIZE = 20; -export function useMetadataReview(status: MetadataReviewStatus, page: number) { +export function useMetadataReview( + status: MetadataReviewStatus, + page: number, + focus?: string | null, +) { const skip = (page - 1) * METADATA_REVIEW_PAGE_SIZE; const query = new URLSearchParams({ status, skip: String(skip), take: String(METADATA_REVIEW_PAGE_SIZE), }); + if (focus) query.set("focus", focus); return useSWR( `/api/metadata-review?${query.toString()}`, diff --git a/SecondDimensionWatcherReDive.Client/src/notifications/api.ts b/SecondDimensionWatcherReDive.Client/src/notifications/api.ts index a1e9cb91..2f565650 100644 --- a/SecondDimensionWatcherReDive.Client/src/notifications/api.ts +++ b/SecondDimensionWatcherReDive.Client/src/notifications/api.ts @@ -1,4 +1,40 @@ import fetcher from "../auth/httpClient"; +import { WebPushConfiguration, WebPushSubscriptionSummary } from "./types"; export const sendTestNotification = () => fetcher<{ eventId: string }>("/api/notifications/test", { method: "POST" }); + +export const getWebPushConfiguration = () => + fetcher("/api/notifications/web-push/config"); + +export const registerWebPushSubscription = (subscription: PushSubscription) => { + const serialized = subscription.toJSON(); + if (!serialized.endpoint || !serialized.keys?.p256dh || !serialized.keys.auth) + throw new Error("The browser returned an incomplete PushSubscription"); + return fetcher( + "/api/notifications/web-push/subscriptions", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + endpoint: serialized.endpoint, + keys: { + p256dh: serialized.keys.p256dh, + auth: serialized.keys.auth, + }, + }), + }, + ); +}; + +export const removeCurrentWebPushSubscription = (endpoint: string) => + fetcher("/api/notifications/web-push/subscriptions/remove-current", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint }), + }); + +export const removeWebPushSubscription = (id: string) => + fetcher(`/api/notifications/web-push/subscriptions/${id}`, { + method: "DELETE", + }); diff --git a/SecondDimensionWatcherReDive.Client/src/notifications/hooks.ts b/SecondDimensionWatcherReDive.Client/src/notifications/hooks.ts index 1c2c537d..b9e3d138 100644 --- a/SecondDimensionWatcherReDive.Client/src/notifications/hooks.ts +++ b/SecondDimensionWatcherReDive.Client/src/notifications/hooks.ts @@ -2,6 +2,7 @@ import useSWR from "swr"; import fetcher from "../auth/httpClient"; import { NotificationDelivery } from "./types"; +import { WebPushSubscriptionSummary } from "./types"; export const useNotificationDeliveries = () => useSWR( @@ -9,3 +10,10 @@ export const useNotificationDeliveries = () => fetcher, { refreshInterval: 5000 }, ); + +export const useWebPushSubscriptions = () => + useSWR( + "/api/notifications/web-push/subscriptions", + fetcher, + { refreshInterval: 15_000 }, + ); diff --git a/SecondDimensionWatcherReDive.Client/src/notifications/pushServiceWorker.js b/SecondDimensionWatcherReDive.Client/src/notifications/pushServiceWorker.js new file mode 100644 index 00000000..ae33a6a4 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/notifications/pushServiceWorker.js @@ -0,0 +1,51 @@ +const localTarget = (value) => { + try { + const target = new URL(value || "/todo", self.location.origin); + if (target.origin !== self.location.origin) return "/todo"; + return `${target.pathname}${target.search}${target.hash}`; + } catch { + return "/todo"; + } +}; + +const notificationIcon = new URL("../favicon.svg", import.meta.url).href; + +self.addEventListener("push", (event) => { + let message = {}; + try { + message = event.data?.json() ?? {}; + } catch { + message = {}; + } + const target = localTarget(message.deepLink); + event.waitUntil( + self.registration.showNotification( + message.title || "SecondDimensionWatcher Re:Dive", + { + body: message.body || "A notification needs your attention.", + data: { target }, + icon: notificationIcon, + tag: message.eventId || undefined, + }, + ), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const target = localTarget(event.notification.data?.target); + event.waitUntil( + self.clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then(async (windows) => { + const existing = windows.find( + (client) => new URL(client.url).origin === self.location.origin, + ); + if (existing) { + await existing.navigate(target); + return existing.focus(); + } + return self.clients.openWindow(target); + }), + ); +}); diff --git a/SecondDimensionWatcherReDive.Client/src/notifications/types.ts b/SecondDimensionWatcherReDive.Client/src/notifications/types.ts index 5126449c..d0d78ec1 100644 --- a/SecondDimensionWatcherReDive.Client/src/notifications/types.ts +++ b/SecondDimensionWatcherReDive.Client/src/notifications/types.ts @@ -1,5 +1,7 @@ export interface NotificationDelivery { id: string; + eventId: string; + channel: "Webhook" | "WebPush"; type: string; status: "Pending" | "Processing" | "Delivered" | "Failed"; attemptCount: number; @@ -8,3 +10,18 @@ export interface NotificationDelivery { deliveredAt: string | null; lastError: string | null; } + +export interface WebPushConfiguration { + enabled: boolean; + vapidPublicKey: string; +} + +export interface WebPushSubscriptionSummary { + id: string; + endpointOrigin: string; + createdAt: string; + updatedAt: string; + lastSuccessAt: string | null; + lastFailureAt: string | null; + lastError: string | null; +} diff --git a/SecondDimensionWatcherReDive.Client/src/notifications/webPush.ts b/SecondDimensionWatcherReDive.Client/src/notifications/webPush.ts new file mode 100644 index 00000000..d83c9c86 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/notifications/webPush.ts @@ -0,0 +1,71 @@ +import { + registerWebPushSubscription, + removeCurrentWebPushSubscription, +} from "./api"; + +export const isWebPushSupported = (): boolean => + window.isSecureContext && + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window; + +const decodeBase64Url = (value: string): Uint8Array => { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const binary = window.atob(padded); + const bytes = new Uint8Array(new ArrayBuffer(binary.length)); + for (let index = 0; index < binary.length; index += 1) + bytes[index] = binary.charCodeAt(index); + return bytes; +}; + +const keysEqual = ( + current: ArrayBuffer | null, + expected: Uint8Array, +): boolean => { + if (!current || current.byteLength !== expected.byteLength) return false; + const currentBytes = new Uint8Array(current); + return currentBytes.every((value, index) => value === expected[index]); +}; + +const getRegistration = () => navigator.serviceWorker.getRegistration("/"); + +export const getCurrentWebPushSubscription = async () => { + if (!isWebPushSupported()) return null; + const registration = await getRegistration(); + return registration?.pushManager.getSubscription() ?? null; +}; + +export const enableWebPushForCurrentDevice = async (vapidPublicKey: string) => { + if (!isWebPushSupported()) throw new Error("unsupported"); + const permission = await Notification.requestPermission(); + if (permission !== "granted") throw new Error("permissionDenied"); + + const workerUrl = new URL("./pushServiceWorker.js", import.meta.url); + const registration = await navigator.serviceWorker.register(workerUrl, { + scope: "/", + type: "module", + }); + const expectedKey = decodeBase64Url(vapidPublicKey); + let subscription = await registration.pushManager.getSubscription(); + if ( + subscription && + !keysEqual(subscription.options.applicationServerKey, expectedKey) + ) { + await removeCurrentWebPushSubscription(subscription.endpoint); + await subscription.unsubscribe(); + subscription = null; + } + subscription ??= await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: expectedKey, + }); + return registerWebPushSubscription(subscription); +}; + +export const disableWebPushForCurrentDevice = async (): Promise => { + const subscription = await getCurrentWebPushSubscription(); + if (!subscription) return false; + await removeCurrentWebPushSubscription(subscription.endpoint); + return subscription.unsubscribe(); +}; diff --git a/SecondDimensionWatcherReDive.Client/src/pages/IncidentsPage.tsx b/SecondDimensionWatcherReDive.Client/src/pages/IncidentsPage.tsx index 4b531131..648dce6b 100644 --- a/SecondDimensionWatcherReDive.Client/src/pages/IncidentsPage.tsx +++ b/SecondDimensionWatcherReDive.Client/src/pages/IncidentsPage.tsx @@ -149,6 +149,7 @@ export const IncidentsPage: React.FC = () => { ? (requestedType as IncidentType) : null; const focus = searchParams.get("focus"); + const focusedIdRef = React.useRef(null); const [type, setType] = React.useState(initialType); const [includeResolved, setIncludeResolved] = React.useState(false); const [page, setPage] = React.useState(0); @@ -159,6 +160,7 @@ export const IncidentsPage: React.FC = () => { includeResolved, skip: page * 50, take: 50, + focus, }); React.useEffect(() => { @@ -170,11 +172,18 @@ export const IncidentsPage: React.FC = () => { }, [initialType]); React.useEffect(() => { - if (!focus || !data) return; - document.getElementById(`incident-${focus}`)?.scrollIntoView({ + if (!focus) { + focusedIdRef.current = null; + return; + } + if (!data || focusedIdRef.current === focus) return; + const target = document.getElementById(`incident-${focus}`); + target?.scrollIntoView({ behavior: "smooth", block: "center", }); + target?.focus({ preventScroll: true }); + if (target) focusedIdRef.current = focus; }, [data, focus]); const selectType = React.useCallback( diff --git a/SecondDimensionWatcherReDive.Client/src/pages/MetadataReviewPage.tsx b/SecondDimensionWatcherReDive.Client/src/pages/MetadataReviewPage.tsx index 3f877ec4..8d1105d5 100644 --- a/SecondDimensionWatcherReDive.Client/src/pages/MetadataReviewPage.tsx +++ b/SecondDimensionWatcherReDive.Client/src/pages/MetadataReviewPage.tsx @@ -349,7 +349,12 @@ export const MetadataReviewPage: React.FC = () => { const status = parseStatus(searchParams.get("status")); const page = parsePage(searchParams.get("page")); const focus = searchParams.get("focus"); - const { data, error, isLoading, mutate } = useMetadataReview(status, page); + const focusedIdRef = React.useRef(null); + const { data, error, isLoading, mutate } = useMetadataReview( + status, + page, + focus, + ); const { addToast } = useToast(); const [selectedItem, setSelectedItem] = React.useState(null); @@ -377,11 +382,18 @@ export const MetadataReviewPage: React.FC = () => { }, [data, page, status, updateLocation]); React.useEffect(() => { - if (!focus || !data) return; - document.getElementById(`metadata-review-${focus}`)?.scrollIntoView({ + if (!focus) { + focusedIdRef.current = null; + return; + } + if (!data || focusedIdRef.current === focus) return; + const target = document.getElementById(`metadata-review-${focus}`); + target?.scrollIntoView({ behavior: "smooth", block: "center", }); + target?.focus({ preventScroll: true }); + if (target) focusedIdRef.current = focus; }, [data, focus]); const changeStatus = React.useCallback( diff --git a/SecondDimensionWatcherReDive.Client/src/pages/TodoPage.tsx b/SecondDimensionWatcherReDive.Client/src/pages/TodoPage.tsx index bed4e369..7ed48b45 100644 --- a/SecondDimensionWatcherReDive.Client/src/pages/TodoPage.tsx +++ b/SecondDimensionWatcherReDive.Client/src/pages/TodoPage.tsx @@ -38,6 +38,7 @@ export const TodoPage: React.FC = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const focus = searchParams.get("focus"); + const focusedKeyRef = React.useRef(null); const [includeRead, setIncludeRead] = React.useState(false); const [includeSnoozed, setIncludeSnoozed] = React.useState(false); const [page, setPage] = React.useState(0); @@ -48,6 +49,7 @@ export const TodoPage: React.FC = () => { includeSnoozed, skip: page * PAGE_SIZE, take: PAGE_SIZE, + focus, }); React.useEffect(() => { @@ -55,17 +57,28 @@ export const TodoPage: React.FC = () => { setSelected(new Set()); }, [includeRead, includeSnoozed]); + React.useEffect(() => { + setSelected(new Set()); + }, [page]); + React.useEffect(() => { if (!data || page === 0 || page * PAGE_SIZE < data.totalCount) return; setPage(Math.max(0, Math.ceil(data.totalCount / PAGE_SIZE) - 1)); }, [data, page]); React.useEffect(() => { - if (!focus || !data) return; - document.getElementById(`todo-${focus}`)?.scrollIntoView({ + if (!focus) { + focusedKeyRef.current = null; + return; + } + if (!data || focusedKeyRef.current === focus) return; + const target = document.getElementById(`todo-${focus}`); + target?.scrollIntoView({ behavior: "smooth", block: "center", }); + target?.focus({ preventScroll: true }); + if (target) focusedKeyRef.current = focus; }, [data, focus]); const apply = React.useCallback( diff --git a/SecondDimensionWatcherReDive.Client/src/settings/systemTypes.ts b/SecondDimensionWatcherReDive.Client/src/settings/systemTypes.ts index 0481ee0d..fc3756b2 100644 --- a/SecondDimensionWatcherReDive.Client/src/settings/systemTypes.ts +++ b/SecondDimensionWatcherReDive.Client/src/settings/systemTypes.ts @@ -104,6 +104,10 @@ export type NotificationEventType = export interface NotificationSettings { webhookEnabled: boolean; + webPushEnabled: boolean; + webPushSubject: string; + vapidPublicKey: string; + vapidPrivateKey: SecretState; events: NotificationEventType[]; quietHoursStart: string | null; quietHoursEnd: string | null; @@ -168,9 +172,10 @@ export type NfsSettingsPatch = Omit< export interface NotificationSettingsPatch extends Omit< NotificationSettings, - "webhookUrl" + "webhookUrl" | "vapidPublicKey" | "vapidPrivateKey" > { webhookUrl?: SecretMutation | null; + generateVapidKeys?: boolean; } export interface SystemSettingsPatch { diff --git a/SecondDimensionWatcherReDive.Client/src/todos/hooks.ts b/SecondDimensionWatcherReDive.Client/src/todos/hooks.ts index 1123b156..0054dc0a 100644 --- a/SecondDimensionWatcherReDive.Client/src/todos/hooks.ts +++ b/SecondDimensionWatcherReDive.Client/src/todos/hooks.ts @@ -8,12 +8,16 @@ export const useTodos = (options?: { includeSnoozed?: boolean; skip?: number; take?: number; + focus?: string | null; }) => { const params = new URLSearchParams(); if (options?.includeRead) params.set("includeRead", "true"); if (options?.includeSnoozed) params.set("includeSnoozed", "true"); if (options?.skip) params.set("skip", String(options.skip)); if (options?.take) params.set("take", String(options.take)); + if (options?.focus) params.set("focus", options.focus); const query = params.toString(); - return useSWR(`/api/todos${query ? `?${query}` : ""}`, fetcher); + return useSWR(`/api/todos${query ? `?${query}` : ""}`, fetcher, { + refreshInterval: 15_000, + }); }; diff --git a/SecondDimensionWatcherReDive.Client/src/todos/state.test.ts b/SecondDimensionWatcherReDive.Client/src/todos/state.test.ts deleted file mode 100644 index 4a28e277..00000000 --- a/SecondDimensionWatcherReDive.Client/src/todos/state.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; - -import { getTodoSnoozeAction } from "./state"; - -describe("getTodoSnoozeAction", () => { - const now = Date.parse("2026-08-30T00:00:00Z"); - - it("offers unsnooze while the wake time is still in the future", () => { - assert.equal(getTodoSnoozeAction("2026-08-30T01:00:00Z", now), "unsnooze"); - }); - - it("offers snooze for expired, absent, or invalid wake times", () => { - assert.equal(getTodoSnoozeAction("2026-08-29T23:00:00Z", now), "snooze"); - assert.equal(getTodoSnoozeAction(null, now), "snooze"); - assert.equal(getTodoSnoozeAction("invalid", now), "snooze"); - }); -}); diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/IMetadataReviewRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/IMetadataReviewRepository.cs index fd196df3..091bd146 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/IMetadataReviewRepository.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/IMetadataReviewRepository.cs @@ -6,6 +6,7 @@ Task GetQueueAsync( MetadataReviewStatus status, int skip, int take, + Guid? focusId, CancellationToken cancellationToken); Task SavePreviewAsync( diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/INotificationOutboxRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/INotificationOutboxRepository.cs index 25ebfbff..f6c0f89a 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/INotificationOutboxRepository.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/INotificationOutboxRepository.cs @@ -10,9 +10,18 @@ public enum NotificationDeliveryStatus Failed } +public enum NotificationChannel +{ + Webhook, + WebPush +} + public sealed record NotificationOutboxMessage( Guid Id, + Guid EventId, string DeduplicationKey, + NotificationChannel Channel, + Guid? WebPushSubscriptionId, NotificationEventType Type, string Title, string Body, @@ -33,26 +42,28 @@ Task EnqueueAsync( CancellationToken cancellationToken); Task> ClaimDueAsync( - DateTimeOffset now, - DateTimeOffset leaseUntil, + TimeSpan leaseDuration, int take, CancellationToken cancellationToken); - Task MarkDeliveredAsync( + Task MarkDeliveredAsync( Guid id, + DateTimeOffset expectedLeaseUntil, DateTimeOffset deliveredAt, CancellationToken cancellationToken); - Task MarkFailedAsync( + Task MarkFailedAsync( Guid id, + DateTimeOffset expectedLeaseUntil, int attemptCount, DateTimeOffset attemptedAt, DateTimeOffset? nextAttemptAt, string error, CancellationToken cancellationToken); - Task RescheduleAsync( + Task RescheduleAsync( Guid id, + DateTimeOffset expectedLeaseUntil, DateTimeOffset nextAttemptAt, CancellationToken cancellationToken); diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/ITodoRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/ITodoRepository.cs index 84e074de..bb14beaf 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/ITodoRepository.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/ITodoRepository.cs @@ -42,6 +42,7 @@ Task GetAsync( DateTimeOffset now, int skip, int take, + string? focusKey, CancellationToken cancellationToken); Task SetStateAsync( diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/IWebPushSubscriptionRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/IWebPushSubscriptionRepository.cs new file mode 100644 index 00000000..55ffff3c --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/IWebPushSubscriptionRepository.cs @@ -0,0 +1,43 @@ +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public sealed record WebPushSubscription( + Guid Id, + string Endpoint, + string P256Dh, + string Auth, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + DateTimeOffset? LastSuccessAt, + DateTimeOffset? LastFailureAt, + string? LastError); + +public interface IWebPushSubscriptionRepository +{ + Task UpsertAsync( + WebPushSubscription subscription, + CancellationToken cancellationToken); + + Task FindByIdAsync( + Guid id, + CancellationToken cancellationToken); + + Task> GetAllAsync( + CancellationToken cancellationToken); + + Task RemoveAsync(Guid id, CancellationToken cancellationToken); + + Task RemoveByEndpointAsync( + string endpoint, + CancellationToken cancellationToken); + + Task RecordSuccessAsync( + Guid id, + DateTimeOffset succeededAt, + CancellationToken cancellationToken); + + Task RecordFailureAsync( + Guid id, + DateTimeOffset failedAt, + string error, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/Notifications/NotificationEvent.cs b/SecondDimensionWatcherReDive.Framework/Notifications/NotificationEvent.cs index c4f1d784..c2fd5b29 100644 --- a/SecondDimensionWatcherReDive.Framework/Notifications/NotificationEvent.cs +++ b/SecondDimensionWatcherReDive.Framework/Notifications/NotificationEvent.cs @@ -35,7 +35,7 @@ public sealed record NotificationEvent( public interface INotificationPublisher { - Task PublishAsync( + Task PublishAsync( NotificationEvent notificationEvent, CancellationToken cancellationToken); } diff --git a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs index 7a84dd46..810fb321 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs @@ -109,5 +109,4 @@ public async Task PreviousSchema_UpgradesToLatest_WithoutLosingExistingData() private static FileMapping Mapping(Guid animationInfoId, string virtualPath) => new(Guid.NewGuid(), animationInfoId, virtualPath, "/physical/" + Guid.NewGuid(), "local"); - } diff --git a/SecondDimensionWatcherReDive.Test/CompleteDownloadBackgroundServiceTests.cs b/SecondDimensionWatcherReDive.Test/CompleteDownloadBackgroundServiceTests.cs index 762540c5..7c402db4 100644 --- a/SecondDimensionWatcherReDive.Test/CompleteDownloadBackgroundServiceTests.cs +++ b/SecondDimensionWatcherReDive.Test/CompleteDownloadBackgroundServiceTests.cs @@ -5,7 +5,6 @@ using SecondDimensionWatcherReDive.Data; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.PluginParams; -using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.Plugin; using SecondDimensionWatcherReDive.Services; using SecondDimensionWatcherReDive.Utils.FileStore; @@ -81,13 +80,11 @@ public async Task ProcessRequestAsync_TrackedDownload_CompletesBeforeMappingAndP CancellationToken.None)) .Returns(Task.CompletedTask); using var provider = CreateProvider(repository.Object, mapper.Object, plugin.Object); - var notifications = new Mock(); var service = new CompleteDownloadBackgroundService( Channel.CreateUnbounded(), provider.GetRequiredService(), Mock.Of>(), - Mock.Of(), - notifications.Object); + Mock.Of()); await service.ProcessRequestAsync(request, CancellationToken.None); @@ -99,11 +96,6 @@ public async Task ProcessRequestAsync_TrackedDownload_CompletesBeforeMappingAndP && parameter.StorePath == request.StorePath && parameter.FileStore == request.FileStore), CancellationToken.None), Times.Once); - notifications.Verify(candidate => candidate.PublishAsync( - It.Is(notification => - notification.Type == NotificationEventType.DownloadCompleted - && notification.DeduplicationKey.Contains(request.ItemId.ToString(), StringComparison.Ordinal)), - CancellationToken.None), Times.Once); } private static ServiceProvider CreateProvider( diff --git a/SecondDimensionWatcherReDive.Test/IncidentReporterTests.cs b/SecondDimensionWatcherReDive.Test/IncidentReporterTests.cs index b74c9204..1f22c9b4 100644 --- a/SecondDimensionWatcherReDive.Test/IncidentReporterTests.cs +++ b/SecondDimensionWatcherReDive.Test/IncidentReporterTests.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.Logging; using Moq; using SecondDimensionWatcherReDive.Framework.DataRepository; -using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.Utils.Incidents; namespace SecondDimensionWatcherReDive.Test; @@ -48,94 +47,4 @@ await reporter.ReportAsync(new IncidentReport( Assert.AreEqual(persisted[0].Fingerprint, persisted[1].Fingerprint); Assert.AreNotEqual(persisted[0].Id, persisted[1].Id); } - - [TestMethod] - public async Task ReportAsync_DiskSpaceLow_PublishesOnlySpecificEvent() - { - var incidentId = Guid.NewGuid(); - var repository = new Mock(); - repository.Setup(candidate => candidate.UpsertAsync( - It.IsAny(), - CancellationToken.None)) - .ReturnsAsync((Incident incident, CancellationToken _) => - incident with { Id = incidentId, Occurrence = 2 }); - var provider = new Mock(); - provider.Setup(candidate => candidate.GetService(typeof(IIncidentRepository))) - .Returns(repository.Object); - var scope = new Mock(); - scope.SetupGet(candidate => candidate.ServiceProvider).Returns(provider.Object); - var factory = new Mock(); - factory.Setup(candidate => candidate.CreateScope()).Returns(scope.Object); - var notifications = new Mock(); - var reporter = new IncidentReporter( - factory.Object, - Mock.Of>(), - notifications.Object); - - await reporter.ReportAsync(new IncidentReport( - IncidentType.DiskSpaceLow, - IncidentSeverity.Critical, - "Disk space is low", - "Less than 5% is available.", - "/downloads"), CancellationToken.None); - - notifications.Verify(candidate => candidate.PublishAsync( - It.Is(notification => - notification.Type == NotificationEventType.DiskSpaceLow - && notification.DeduplicationKey == $"disk-space-low:{incidentId}:2" - && notification.DeepLink == "/incidents?type=diskSpaceLow"), - CancellationToken.None), Times.Once); - notifications.VerifyNoOtherCalls(); - } - - [TestMethod] - public async Task ReportAsync_RecurringIncident_DeduplicatesWithinEachOccurrence() - { - var incidentId = Guid.NewGuid(); - var occurrences = new Queue([1, 2, 2]); - var repository = new Mock(); - repository.Setup(candidate => candidate.UpsertAsync( - It.IsAny(), - CancellationToken.None)) - .ReturnsAsync((Incident incident, CancellationToken _) => - incident with { Id = incidentId, Occurrence = occurrences.Dequeue() }); - var provider = new Mock(); - provider.Setup(candidate => candidate.GetService(typeof(IIncidentRepository))) - .Returns(repository.Object); - var scope = new Mock(); - scope.SetupGet(candidate => candidate.ServiceProvider).Returns(provider.Object); - var factory = new Mock(); - factory.Setup(candidate => candidate.CreateScope()).Returns(scope.Object); - var published = new List(); - var notifications = new Mock(); - notifications.Setup(candidate => candidate.PublishAsync( - It.IsAny(), - CancellationToken.None)) - .Callback((notification, _) => - published.Add(notification)) - .Returns(Task.CompletedTask); - var reporter = new IncidentReporter( - factory.Object, - Mock.Of>(), - notifications.Object); - var report = new IncidentReport( - IncidentType.FeedFailure, - IncidentSeverity.Error, - "Feed failed", - "The feed could not be loaded.", - "https://example.test/feed"); - - await reporter.ReportAsync(report, CancellationToken.None); - await reporter.ReportAsync(report, CancellationToken.None); - await reporter.ReportAsync(report, CancellationToken.None); - - CollectionAssert.AreEqual( - new[] - { - $"incident-opened:{incidentId}", - $"incident-opened:{incidentId}:2", - $"incident-opened:{incidentId}:2" - }, - published.Select(notification => notification.DeduplicationKey).ToArray()); - } } diff --git a/SecondDimensionWatcherReDive.Test/NotificationPipelineTests.cs b/SecondDimensionWatcherReDive.Test/NotificationPipelineTests.cs deleted file mode 100644 index c0e94eb6..00000000 --- a/SecondDimensionWatcherReDive.Test/NotificationPipelineTests.cs +++ /dev/null @@ -1,226 +0,0 @@ -using System.Net; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Moq; -using SecondDimensionWatcherReDive.Framework.DataRepository; -using SecondDimensionWatcherReDive.Framework.Notifications; -using SecondDimensionWatcherReDive.Utils.Notifications; - -namespace SecondDimensionWatcherReDive.Test; - -[TestClass] -public sealed class NotificationPipelineTests -{ - [TestMethod] - public async Task PublishAsync_SubscribedEvent_EnqueuesStableOutboxEnvelope() - { - var repository = new Mock(); - repository.Setup(candidate => candidate.EnqueueAsync( - It.IsAny(), CancellationToken.None)) - .ReturnsAsync(true); - using var services = new ServiceCollection() - .AddScoped(_ => repository.Object) - .BuildServiceProvider(); - var configuration = Configuration(new Dictionary - { - ["Notifications:Webhook:Enabled"] = "true", - ["Notifications:Events"] = "ReleaseMatched,DownloadCompleted" - }); - var publisher = new NotificationPublisher( - services.GetRequiredService(), - configuration, - Mock.Of>()); - - await publisher.PublishAsync(new NotificationEvent( - NotificationEventType.ReleaseMatched, - "release:stable", - "Matched", - "Anime title", - "/todo?focus=automation:id"), CancellationToken.None); - - repository.Verify(candidate => candidate.EnqueueAsync( - It.Is(message => - message.DeduplicationKey == "release:stable" - && message.Type == NotificationEventType.ReleaseMatched - && message.Status == NotificationDeliveryStatus.Pending - && message.AttemptCount == 0), - CancellationToken.None), Times.Once); - } - - [TestMethod] - public async Task PublishAsync_PersistenceFailure_DoesNotEscapeIntoCoreOperation() - { - var repository = new Mock(); - repository.Setup(candidate => candidate.EnqueueAsync( - It.IsAny(), CancellationToken.None)) - .ThrowsAsync(new InvalidOperationException("database unavailable")); - using var services = new ServiceCollection() - .AddScoped(_ => repository.Object) - .BuildServiceProvider(); - var publisher = new NotificationPublisher( - services.GetRequiredService(), - Configuration(new Dictionary - { - ["Notifications:Webhook:Enabled"] = "true", - ["Notifications:Events"] = "IncidentOpened" - }), - Mock.Of>()); - - await publisher.PublishAsync(new NotificationEvent( - NotificationEventType.IncidentOpened, - "incident:stable", - "Incident", - "Detail", - "/incidents"), CancellationToken.None); - } - - [TestMethod] - public async Task DeliverBatchAsync_Success_SendsIdempotencyHeaderAndMarksDelivered() - { - var message = Message(); - var repository = new Mock(); - repository.Setup(candidate => candidate.ClaimDueAsync( - It.IsAny(), It.IsAny(), - It.IsAny(), CancellationToken.None)) - .ReturnsAsync([message]); - string? eventId = null; - string? body = null; - var handler = new DelegateHandler(async request => - { - eventId = request.Headers.GetValues("X-SDW-Event-Id").Single(); - body = await request.Content!.ReadAsStringAsync(); - return new HttpResponseMessage(HttpStatusCode.NoContent); - }); - - var service = DeliveryService(repository.Object, handler); - var count = await service.DeliverBatchAsync(CancellationToken.None); - - Assert.AreEqual(1, count); - Assert.AreEqual(message.Id.ToString("D"), eventId); - StringAssert.Contains(body!, "\"deepLink\":\"/todo?focus=automation:item\""); - repository.Verify(candidate => candidate.MarkDeliveredAsync( - message.Id, It.IsAny(), CancellationToken.None), Times.Once); - } - - [TestMethod] - public async Task DeliverBatchAsync_ServerFailure_RecordsRetryWithoutThrowing() - { - var message = Message(); - var repository = new Mock(); - repository.Setup(candidate => candidate.ClaimDueAsync( - It.IsAny(), It.IsAny(), - It.IsAny(), CancellationToken.None)) - .ReturnsAsync([message]); - var service = DeliveryService(repository.Object, - new DelegateHandler(_ => Task.FromResult( - new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)))); - - await service.DeliverBatchAsync(CancellationToken.None); - - repository.Verify(candidate => candidate.MarkFailedAsync( - message.Id, - 1, - It.IsAny(), - It.Is(next => next.HasValue), - "HTTP 503", - CancellationToken.None), Times.Once); - } - - [TestMethod] - public async Task DeliverBatchAsync_NetworkFailure_DoesNotLogSecretWebhookUrl() - { - var message = Message(); - var repository = new Mock(); - repository.Setup(candidate => candidate.ClaimDueAsync( - It.IsAny(), It.IsAny(), - It.IsAny(), CancellationToken.None)) - .ReturnsAsync([message]); - const string SecretUrl = "https://hooks.example.test/sdw?token=never-log-this"; - var logger = new CollectingLogger(); - var service = DeliveryService( - repository.Object, - new DelegateHandler(_ => throw new HttpRequestException(SecretUrl)), - logger, - SecretUrl); - - await service.DeliverBatchAsync(CancellationToken.None); - - Assert.IsTrue(logger.Entries.Any(entry => entry.Contains( - nameof(HttpRequestException), StringComparison.Ordinal))); - Assert.IsFalse(logger.Entries.Any(entry => entry.Contains( - "never-log-this", StringComparison.Ordinal))); - } - - private static NotificationDeliveryBackgroundService DeliveryService( - INotificationOutboxRepository repository, - HttpMessageHandler handler, - ILogger? logger = null, - string endpoint = "https://hooks.example.test/sdw") - { - var scopeServices = new ServiceCollection() - .AddScoped(_ => repository) - .BuildServiceProvider(); - var clients = new Mock(); - clients.Setup(candidate => candidate.CreateClient("NotificationWebhook")) - .Returns(new HttpClient(handler)); - return new NotificationDeliveryBackgroundService( - scopeServices.GetRequiredService(), - clients.Object, - Configuration(new Dictionary - { - ["Notifications:Webhook:Enabled"] = "true", - ["Notifications:Webhook:Url"] = endpoint, - ["Notifications:QuietHours:TimeZone"] = "UTC" - }), - logger ?? Mock.Of>()); - } - - private static NotificationOutboxMessage Message() => new( - Guid.NewGuid(), - "release:stable", - NotificationEventType.ReleaseMatched, - "Matched", - "Anime title", - "/todo?focus=automation:item", - "{\"animationId\":\"item\"}", - DateTimeOffset.UtcNow, - NotificationDeliveryStatus.Pending, - 0, - DateTimeOffset.UtcNow, - null, - null, - null); - - private static IConfiguration Configuration(IReadOnlyDictionary values) => - new ConfigurationBuilder().AddInMemoryCollection(values).Build(); - - private sealed class DelegateHandler( - Func> callback) : HttpMessageHandler - { - protected override Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) => callback(request); - } - - private sealed class CollectingLogger : ILogger - { - public List Entries { get; } = []; - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - Entries.Add(formatter(state, exception)); - if (exception is not null) - Entries.Add(exception.ToString()); - } - } -} diff --git a/SecondDimensionWatcherReDive.Test/RuntimeSettingsServiceTests.cs b/SecondDimensionWatcherReDive.Test/RuntimeSettingsServiceTests.cs index cd950089..a7ff6ae1 100644 --- a/SecondDimensionWatcherReDive.Test/RuntimeSettingsServiceTests.cs +++ b/SecondDimensionWatcherReDive.Test/RuntimeSettingsServiceTests.cs @@ -50,7 +50,6 @@ public async Task GetSettings_RedactsEverySecret() Assert.DoesNotContain("deployment-codex-secret", json, StringComparison.Ordinal); Assert.DoesNotContain("deployment-tmdb-secret", json, StringComparison.Ordinal); Assert.DoesNotContain("deployment-torrent-secret", json, StringComparison.Ordinal); - Assert.DoesNotContain("deployment-webhook-secret", json, StringComparison.Ordinal); StringAssert.Contains(json, "\"isConfigured\":true"); StringAssert.Contains(json, "\"source\":\"deployment\""); StringAssert.Contains(json, "\"permissionProfile\":\":read-only\""); @@ -107,46 +106,6 @@ public async Task PatchSettings_ReturnsSecretMetadataWithoutEchoingNewValue() StringAssert.Contains(json, "\"source\":\"runtime\""); } - [TestMethod] - public async Task WebhookUrl_IsEncryptedAtRestAndNeverReturnedBySettingsApi() - { - await using var host = await SettingsTestHost.CreateAsync( - configurationOverrides: new Dictionary - { - [RuntimeSecretKeys.NotificationWebhookUrl] = null - }); - var initial = await host.RuntimeSettings.GetAsync(CancellationToken.None); - const string WebhookUrl = "https://hooks.example.test/sdw?token=must-remain-secret"; - var result = await host.RuntimeSettings.UpdateAsync( - new RuntimeSettingsPatch( - initial.Revision, - Ai: null, - Tmdb: null, - Torrent: null, - MediaLibrary: null, - Incidents: null, - Nfs: null, - Notifications: new NotificationSettingsUpdate( - initial.Desired.Notifications with { WebhookEnabled = true }, - new SecretMutation(SecretMutationOperation.Set, WebhookUrl))), - CancellationToken.None); - - Assert.AreEqual(RuntimeSettingsUpdateStatus.Saved, result.Status); - Assert.AreEqual(WebhookUrl, host.Configuration[RuntimeSecretKeys.NotificationWebhookUrl]); - Assert.DoesNotContain( - WebhookUrl, - host.Repository.Document?.ProtectedSecrets ?? string.Empty, - StringComparison.Ordinal); - - var controller = new SettingsController(host.RuntimeSettings); - var action = await controller.GetSettingsAsync(CancellationToken.None); - var json = JsonSerializer.Serialize( - ((OkObjectResult)action.Result!).Value, - new JsonSerializerOptions(JsonSerializerDefaults.Web)); - Assert.DoesNotContain(WebhookUrl, json, StringComparison.Ordinal); - StringAssert.Contains(json, "\"webhookUrl\":{\"isConfigured\":true,\"source\":\"runtime\"}"); - } - [TestMethod] public async Task SetSecret_EncryptsPersistence_AndPublishesRuntimeValue() { @@ -824,10 +783,6 @@ public async ValueTask DisposeAsync() ["Incidents:ReconciliationInterval"] = "00:05:00", ["Incidents:Disk:MinimumAvailableBytes"] = "5368709120", ["Incidents:Disk:MinimumAvailablePercent"] = "5", - ["Notifications:Webhook:Enabled"] = "false", - ["Notifications:Webhook:Url"] = "https://hooks.example.test/delivery?token=deployment-webhook-secret", - ["Notifications:Events"] = "ReleaseMatched,DownloadCompleted", - ["Notifications:QuietHours:TimeZone"] = "UTC", ["Nfs:Enabled"] = "false", ["Nfs:Port"] = "2049", ["Nfs:BindAddress"] = "127.0.0.1", diff --git a/SecondDimensionWatcherReDive.Test/SyncFeedTests.cs b/SecondDimensionWatcherReDive.Test/SyncFeedTests.cs index 420c2c97..6c778941 100644 --- a/SecondDimensionWatcherReDive.Test/SyncFeedTests.cs +++ b/SecondDimensionWatcherReDive.Test/SyncFeedTests.cs @@ -7,7 +7,6 @@ using SecondDimensionWatcherReDive.Framework.Feed; using SecondDimensionWatcherReDive.Framework.FileDownload; using SecondDimensionWatcherReDive.Framework.DataRepository; -using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.Services; using SecondDimensionWatcherReDive.Utils.Feed; using SecondDimensionWatcherReDive.Utils.Http; @@ -21,7 +20,6 @@ public class SyncFeedTests private Mock _mockPolicyRepo = null!; private Mock _mockDownloadProvider = null!; private Mock _mockDownloadClient = null!; - private Mock _mockNotificationPublisher = null!; private SyncFeed _syncFeed = null!; private MethodInfo _processSingleMethod = null!; @@ -32,7 +30,6 @@ public void Setup() _mockPolicyRepo = new Mock(); _mockDownloadProvider = new Mock(); _mockDownloadClient = new Mock(); - _mockNotificationPublisher = new Mock(); _mockRepo.Setup(repository => repository.AddAsync( It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); @@ -64,8 +61,7 @@ public void Setup() Mock.Of>(), outboundFetcher.Object, mockScopeFactory.Object, - new SubscriptionAutomationMatcher(new SubscriptionReleaseMetadataExtractor()), - notificationPublisher: _mockNotificationPublisher.Object); + new SubscriptionAutomationMatcher(new SubscriptionReleaseMetadataExtractor())); _processSingleMethod = typeof(SyncFeed) .GetMethod("ProcessSingle", BindingFlags.NonPublic | BindingFlags.Instance)!; @@ -175,12 +171,6 @@ public async Task ProcessSingle_NotifyOnly_PersistsNotifiedOutcomeAndExplanation StringAssert.Contains(added.AutomationExplanationJson!, "\"passed\":true"); _mockRepo.Verify(repository => repository.UpdateAsync( It.IsAny(), It.IsAny()), Times.Never); - _mockNotificationPublisher.Verify(publisher => publisher.PublishAsync( - It.Is(notification => - notification.Type == NotificationEventType.ReleaseMatched - && notification.DeduplicationKey == $"release-matched:{added.Id}" - && notification.DeepLink.Contains(added.Id.ToString(), StringComparison.Ordinal)), - It.IsAny()), Times.Once); } [TestMethod] @@ -200,10 +190,6 @@ public async Task ProcessSingle_ManualConfirm_PersistsPendingConfirmationWithout _mockDownloadClient.Verify(client => client.SubmitDownloadTaskAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - _mockNotificationPublisher.Verify(publisher => publisher.PublishAsync( - It.Is(notification => - notification.Type == NotificationEventType.DownloadPendingConfirmation), - It.IsAny()), Times.Once); } [TestMethod] @@ -270,11 +256,6 @@ public async Task ProcessSingle_AutoDownloadRejected_MarksFailed() SubscriptionAutomationDisposition.AutoDownloadFailed, It.Is(token => token.CanBeCanceled && !token.IsCancellationRequested)), Times.Once); - _mockNotificationPublisher.Verify(publisher => publisher.PublishAsync( - It.Is(notification => - notification.Type == NotificationEventType.DownloadFailed - && notification.DeduplicationKey.StartsWith("auto-download-failed:", StringComparison.Ordinal)), - It.IsAny()), Times.Once); } [TestMethod] diff --git a/SecondDimensionWatcherReDive.Test/TodosControllerTests.cs b/SecondDimensionWatcherReDive.Test/TodosControllerTests.cs deleted file mode 100644 index c2cb1cca..00000000 --- a/SecondDimensionWatcherReDive.Test/TodosControllerTests.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Moq; -using SecondDimensionWatcherReDive.Controllers; -using SecondDimensionWatcherReDive.Controllers.External; -using SecondDimensionWatcherReDive.Framework.DataRepository; - -namespace SecondDimensionWatcherReDive.Test; - -[TestClass] -public sealed class TodosControllerTests -{ - [TestMethod] - public async Task GetAsync_ForwardsDatabasePagination() - { - var repository = new Mock(); - repository.Setup(candidate => candidate.GetAsync( - true, - true, - It.IsAny(), - 25, - 10, - CancellationToken.None)) - .ReturnsAsync(new TodoPage([], 120, 7)); - var controller = new TodosController(repository.Object); - - var result = await controller.GetAsync( - true, - true, - 25, - 10, - CancellationToken.None); - - var ok = Assert.IsInstanceOfType(result.Result); - var response = Assert.IsInstanceOfType(ok.Value); - Assert.AreEqual(120, response.TotalCount); - Assert.AreEqual(7, response.UnreadCount); - } - - [TestMethod] - public async Task GetAsync_InvalidPagination_IsRejectedBeforeRepositoryQuery() - { - var repository = new Mock(); - var controller = new TodosController(repository.Object); - - var result = await controller.GetAsync( - false, - false, - 0, - 201, - CancellationToken.None); - - Assert.IsInstanceOfType(result.Result); - repository.VerifyNoOtherCalls(); - } - - [TestMethod] - public async Task UpdateStateAsync_MarkRead_OnlyPersistsPresentationState() - { - var id = Guid.NewGuid(); - var repository = new Mock(); - var controller = new TodosController(repository.Object); - - var result = await controller.UpdateStateAsync( - new UpdateTodoStateRequest([$"automation:{id}"], TodoStateAction.MarkRead, null), - CancellationToken.None); - - Assert.IsInstanceOfType(result); - repository.Verify(candidate => candidate.SetStateAsync( - It.Is>(keys => keys.Single() == $"automation:{id}"), - It.Is(value => value.HasValue), - true, - null, - false, - CancellationToken.None), Times.Once); - } - - [TestMethod] - public async Task UpdateStateAsync_SnoozeWithoutFutureTime_IsRejected() - { - var repository = new Mock(); - var controller = new TodosController(repository.Object); - - var result = await controller.UpdateStateAsync( - new UpdateTodoStateRequest( - [$"incident:{Guid.NewGuid()}"], - TodoStateAction.Snooze, - DateTimeOffset.UtcNow.AddMinutes(-1)), - CancellationToken.None); - - Assert.IsInstanceOfType(result); - repository.VerifyNoOtherCalls(); - } - - [TestMethod] - public async Task UpdateStateAsync_RecurringIncidentKey_IsAccepted() - { - var key = $"incident:{Guid.NewGuid()}:2"; - var repository = new Mock(); - var controller = new TodosController(repository.Object); - - var result = await controller.UpdateStateAsync( - new UpdateTodoStateRequest([key], TodoStateAction.Unsnooze, null), - CancellationToken.None); - - Assert.IsInstanceOfType(result); - repository.Verify(candidate => candidate.SetStateAsync( - It.Is>(keys => keys.Single() == key), - null, - false, - null, - true, - CancellationToken.None), Times.Once); - } -} diff --git a/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsModels.cs b/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsModels.cs index bd9bfc25..6972cd31 100644 --- a/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsModels.cs +++ b/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsModels.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Net; +using System.Security.Cryptography; using System.Text.Json.Serialization; using Microsoft.Extensions.Configuration; using SecondDimensionWatcherReDive.Framework.Notifications; @@ -141,6 +142,9 @@ internal sealed record NfsSettingsValues( internal sealed record NotificationSettingsValues( bool WebhookEnabled, + bool WebPushEnabled, + string WebPushSubject, + string VapidPublicKey, IReadOnlyList Events, TimeSpan? QuietHoursStart, TimeSpan? QuietHoursEnd, @@ -194,8 +198,15 @@ internal sealed record TorrentSettingsUpdate( SecretMutation? Password); internal sealed record NotificationSettingsUpdate( - NotificationSettingsValues Values, - SecretMutation? WebhookUrl); + bool WebhookEnabled, + bool WebPushEnabled, + string WebPushSubject, + IReadOnlyList Events, + TimeSpan? QuietHoursStart, + TimeSpan? QuietHoursEnd, + string TimeZoneId, + SecretMutation? WebhookUrl, + bool GenerateVapidKeys); internal sealed record RuntimeSettingsPatch( long ExpectedRevision, @@ -238,6 +249,7 @@ internal static class RuntimeSecretKeys public const string TmdbApiKey = "TmdbApiKey"; public const string TorrentPassword = "Torrent:Remote:Password"; public const string NotificationWebhookUrl = "Notifications:Webhook:Url"; + public const string NotificationVapidPrivateKey = "Notifications:WebPush:VapidPrivateKey"; public static readonly string[] All = [ @@ -246,7 +258,8 @@ internal static class RuntimeSecretKeys CodexToken, TmdbApiKey, TorrentPassword, - NotificationWebhookUrl + NotificationWebhookUrl, + NotificationVapidPrivateKey ]; } @@ -286,6 +299,9 @@ public static RuntimeSettingsValues FromConfiguration(IConfiguration configurati }, new NotificationSettingsValues( configuration.GetValue("Notifications:Webhook:Enabled") ?? false, + configuration.GetValue("Notifications:WebPush:Enabled") ?? false, + configuration["Notifications:WebPush:Subject"] ?? string.Empty, + configuration["Notifications:WebPush:VapidPublicKey"] ?? string.Empty, ReadNotificationEvents(configuration["Notifications:Events"]), configuration.GetValue("Notifications:QuietHours:Start"), configuration.GetValue("Notifications:QuietHours:End"), @@ -487,6 +503,26 @@ public static IReadOnlyDictionary Validate( if (secrets[RuntimeSecretKeys.NotificationWebhookUrl] is { IsConfigured: true, Value: { } webhookUrl }) ValidateWebhookUri(errors, "notifications.webhook.url", webhookUrl); + var vapidPrivateKey = secrets[RuntimeSecretKeys.NotificationVapidPrivateKey]; + var hasVapidPublicKey = !string.IsNullOrWhiteSpace(values.Notifications.VapidPublicKey); + if (values.Notifications.WebPushEnabled) + { + if (!IsValidVapidSubject(values.Notifications.WebPushSubject)) + Add(errors, "notifications.webPush.subject", + "The VAPID subject must be a contact mailto: URI or an HTTPS URL."); + if (!hasVapidPublicKey || !vapidPrivateKey.IsConfigured) + Add(errors, "notifications.webPush.vapidKeys", + "A VAPID key pair is required when Web Push is enabled."); + } + if (hasVapidPublicKey != vapidPrivateKey.IsConfigured) + Add(errors, "notifications.webPush.vapidKeys", + "The VAPID public and private keys must be configured together."); + if (hasVapidPublicKey && vapidPrivateKey is { IsConfigured: true, Value: { } privateKey }) + ValidateVapidKeyPair( + errors, + values.Notifications.VapidPublicKey, + privateKey); + foreach (var key in RuntimeSecretKeys.All) { if (secrets.TryGetValue(key, out var secret) @@ -538,12 +574,89 @@ private static void ValidateWebhookUri( string key, string value) { + if (value.Length > 2048) + { + Add(errors, key, "The webhook URL cannot exceed 2048 characters."); + return; + } if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) || !string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.Fragment)) + { Add(errors, key, "The webhook must be an absolute HTTP or HTTPS URL without user information or a fragment."); + return; + } + if (uri.Scheme == Uri.UriSchemeHttp && !uri.IsLoopback) + Add(errors, key, "Plain HTTP is allowed only for loopback webhook endpoints; use HTTPS remotely."); + } + + private static bool IsValidVapidSubject(string value) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) + return false; + if (uri.Scheme == Uri.UriSchemeHttps) + return !string.IsNullOrWhiteSpace(uri.IdnHost) + && string.IsNullOrEmpty(uri.UserInfo) + && string.IsNullOrEmpty(uri.Fragment); + return uri.Scheme == Uri.UriSchemeMailto + && value.Length > "mailto:".Length + && string.IsNullOrEmpty(uri.Fragment); + } + + private static void ValidateVapidKeyPair( + Dictionary> errors, + string publicKey, + string privateKey) + { + if (!TryDecodeBase64Url(publicKey, out var publicBytes) + || publicBytes.Length != 65 + || publicBytes[0] != 0x04 + || !TryDecodeBase64Url(privateKey, out var privateBytes) + || privateBytes.Length != 32) + { + Add(errors, "notifications.webPush.vapidKeys", "The VAPID key pair is invalid."); + return; + } + + try + { + using var ecdsa = ECDsa.Create(new ECParameters + { + Curve = ECCurve.NamedCurves.nistP256, + D = privateBytes + }); + var derived = ecdsa.ExportParameters(includePrivateParameters: false); + if (derived.Q.X is null + || derived.Q.Y is null + || !CryptographicOperations.FixedTimeEquals( + publicBytes.AsSpan(1, 32), derived.Q.X) + || !CryptographicOperations.FixedTimeEquals( + publicBytes.AsSpan(33, 32), derived.Q.Y)) + Add(errors, "notifications.webPush.vapidKeys", "The VAPID public and private keys do not match."); + } + catch (Exception exception) when ( + exception is CryptographicException or ArgumentException) + { + Add(errors, "notifications.webPush.vapidKeys", "The VAPID key pair is invalid."); + } + } + + private static bool TryDecodeBase64Url(string value, out byte[] bytes) + { + try + { + var normalized = value.Replace('-', '+').Replace('_', '/'); + normalized = normalized.PadRight((normalized.Length + 3) / 4 * 4, '='); + bytes = Convert.FromBase64String(normalized); + return true; + } + catch (FormatException) + { + bytes = []; + return false; + } } private static void ValidateUserAgent( @@ -604,6 +717,7 @@ private static void RequireNonNegative( RuntimeSecretKeys.TmdbApiKey => "tmdb.apiKey", RuntimeSecretKeys.TorrentPassword => "torrent.password", RuntimeSecretKeys.NotificationWebhookUrl => "notifications.webhook.url", + RuntimeSecretKeys.NotificationVapidPrivateKey => "notifications.webPush.vapidPrivateKey", _ => key }; @@ -688,6 +802,10 @@ internal static class RuntimeSettingsFlattener flattened["Notifications:Webhook:Enabled"] = values.Notifications.WebhookEnabled.ToString(CultureInfo.InvariantCulture); + flattened["Notifications:WebPush:Enabled"] = + values.Notifications.WebPushEnabled.ToString(CultureInfo.InvariantCulture); + flattened["Notifications:WebPush:Subject"] = values.Notifications.WebPushSubject; + flattened["Notifications:WebPush:VapidPublicKey"] = values.Notifications.VapidPublicKey; flattened["Notifications:Events"] = string.Join(',', values.Notifications.Events); flattened["Notifications:QuietHours:Start"] = values.Notifications.QuietHoursStart?.ToString("c", CultureInfo.InvariantCulture); diff --git a/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsService.cs b/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsService.cs index 9df524ac..33328ce6 100644 --- a/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsService.cs +++ b/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsService.cs @@ -5,6 +5,7 @@ using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.Networking; using SecondDimensionWatcherReDive.Repositories; +using WebPush; namespace SecondDimensionWatcherReDive.Configuration; @@ -137,11 +138,37 @@ async Task IRuntimeSettingsService.UpdateAsync( CreateState(), mutationErrors); - var candidateOverrides = ApplyValues(_persistedOverrides, patch); - var candidateSecrets = ApplySecrets(_secretOverrides, patch); var deploymentValues = DeploymentValues(); var deploymentSecrets = DeploymentSecrets(); var currentValues = Merge(deploymentValues, _persistedOverrides); + var currentSecrets = ResolveSecrets(_secretOverrides, deploymentSecrets); + VapidDetails? generatedVapidKeys = null; + if (patch.Notifications?.GenerateVapidKeys is true) + { + if (!string.IsNullOrWhiteSpace(currentValues.Notifications.VapidPublicKey) + || currentSecrets[RuntimeSecretKeys.NotificationVapidPrivateKey].IsConfigured) + { + return new RuntimeSettingsUpdateResult( + RuntimeSettingsUpdateStatus.Invalid, + CreateState(), + new Dictionary(StringComparer.Ordinal) + { + ["notifications.webPush.vapidKeys"] = + ["VAPID keys are already configured and cannot be rotated implicitly."] + }); + } + generatedVapidKeys = VapidHelper.GenerateVapidKeys(); + } + + var candidateOverrides = ApplyValues( + _persistedOverrides, + patch, + currentValues.Notifications, + generatedVapidKeys?.PublicKey); + var candidateSecrets = ApplySecrets( + _secretOverrides, + patch, + generatedVapidKeys?.PrivateKey); var desiredValues = Merge(deploymentValues, candidateOverrides); candidateSecrets = PinEmptyCredentialsAcrossOriginChanges( candidateSecrets, @@ -331,7 +358,9 @@ private static IEnumerable NormalizeNetworks(IEnumerable network private static RuntimeSettingsOverrides ApplyValues( RuntimeSettingsOverrides current, - RuntimeSettingsPatch patch) => + RuntimeSettingsPatch patch, + NotificationSettingsValues currentNotifications, + string? generatedVapidPublicKey) => current with { Ai = patch.Ai?.Values ?? current.Ai, @@ -339,12 +368,23 @@ current with MediaLibrary = patch.MediaLibrary ?? current.MediaLibrary, Incidents = patch.Incidents ?? current.Incidents, Nfs = patch.Nfs ?? current.Nfs, - Notifications = patch.Notifications?.Values ?? current.Notifications + Notifications = patch.Notifications is null + ? current.Notifications + : new NotificationSettingsValues( + patch.Notifications.WebhookEnabled, + patch.Notifications.WebPushEnabled, + patch.Notifications.WebPushSubject, + generatedVapidPublicKey ?? currentNotifications.VapidPublicKey, + patch.Notifications.Events, + patch.Notifications.QuietHoursStart, + patch.Notifications.QuietHoursEnd, + patch.Notifications.TimeZoneId) }; private static RuntimeSecretOverrides ApplySecrets( RuntimeSecretOverrides current, - RuntimeSettingsPatch patch) + RuntimeSettingsPatch patch, + string? generatedVapidPrivateKey) { var values = new Dictionary(current.Values, StringComparer.Ordinal); ApplySecret(values, RuntimeSecretKeys.OpenAiApiKey, patch.Ai?.OpenAiApiKey); @@ -353,6 +393,12 @@ private static RuntimeSecretOverrides ApplySecrets( ApplySecret(values, RuntimeSecretKeys.TmdbApiKey, patch.Tmdb?.ApiKey); ApplySecret(values, RuntimeSecretKeys.TorrentPassword, patch.Torrent?.Password); ApplySecret(values, RuntimeSecretKeys.NotificationWebhookUrl, patch.Notifications?.WebhookUrl); + ApplySecret( + values, + RuntimeSecretKeys.NotificationVapidPrivateKey, + generatedVapidPrivateKey is null + ? null + : new SecretMutation(SecretMutationOperation.Set, generatedVapidPrivateKey)); return new RuntimeSecretOverrides { Values = values }; } diff --git a/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs b/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs index ba3498dd..7f96b565 100644 --- a/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs +++ b/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs @@ -132,17 +132,25 @@ await CompensateFailedStartAsync( return Ok(); } - private Task PublishDownloadFailureAsync( + private async Task PublishDownloadFailureAsync( Framework.DataRepository.AnimationInfo info, Guid downloadAttemptId, - CancellationToken cancellationToken) => - notificationPublisher?.PublishAsync(new NotificationEvent( + CancellationToken cancellationToken) + { + if (notificationPublisher is null) + return; + var current = await animationInfoRepository.FindByIdAsync( + info.Id, + cancellationToken); + if (current is null || current.IsDownloadTracked || current.IsDownloadFinished) + return; + await notificationPublisher.PublishAsync(new NotificationEvent( NotificationEventType.DownloadFailed, $"download-failed:{info.Id}:{downloadAttemptId}", "Download failed to start", info.Title, - info.Animation is null ? "/" : $"/anime/{info.Animation.TmdbId}"), cancellationToken) - ?? Task.CompletedTask; + info.Animation is null ? "/" : $"/anime/{info.Animation.TmdbId}"), cancellationToken); + } [HttpPost("pause/{id:guid}")] public async Task PauseDownload([FromRoute] Guid id, CancellationToken cancellationToken) @@ -286,7 +294,10 @@ private async Task CompensateFailedStartAsync( await animationInfoRepository.TryCancelDownloadAsync( info.Id, downloadAttemptId, - terminalDisposition: null, + terminalDisposition: info.AutomationDisposition == + SubscriptionAutomationDisposition.AutoDownloadFailed + ? SubscriptionAutomationDisposition.AutoDownloadFailed + : null, cleanup.Token); } diff --git a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs index d8983e25..f1cd86b3 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs @@ -69,6 +69,11 @@ namespace SecondDimensionWatcherReDive.Controllers.External; [JsonSerializable(typeof(NotificationDeliveryItem))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(TestNotificationResponse))] +[JsonSerializable(typeof(WebPushConfigurationResponse))] +[JsonSerializable(typeof(RegisterWebPushSubscriptionRequest))] +[JsonSerializable(typeof(RemoveWebPushSubscriptionRequest))] +[JsonSerializable(typeof(WebPushSubscriptionSummary))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(TodoListResponse))] [JsonSerializable(typeof(UpdateTodoStateRequest))] [JsonSerializable(typeof(MigrationExecutionResponse))] diff --git a/SecondDimensionWatcherReDive/Controllers/External/ApplicationSettings.cs b/SecondDimensionWatcherReDive/Controllers/External/ApplicationSettings.cs index fd5ea33e..a59d925d 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/ApplicationSettings.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/ApplicationSettings.cs @@ -77,6 +77,10 @@ internal sealed record NfsSettingsResponse( internal sealed record NotificationSettingsResponse( bool WebhookEnabled, + bool WebPushEnabled, + string WebPushSubject, + string VapidPublicKey, + SecretStateResponse VapidPrivateKey, IReadOnlyList Events, TimeSpan? QuietHoursStart, TimeSpan? QuietHoursEnd, @@ -95,82 +99,85 @@ internal sealed record ApplicationSettingsResponse( NotificationSettingsResponse Notifications); internal sealed record SecretMutationRequest( - [property: Required] SecretMutationOperation? Operation, + [Required] SecretMutationOperation? Operation, string? Value); internal sealed record OpenAiSettingsPatchRequest( - [property: Required] string? BaseUrl, - [property: Required] OpenAiApiMode? ApiMode, - [property: Required] string? Model, - [property: Required] int? MaxTokens, + [Required] string? BaseUrl, + [Required] OpenAiApiMode? ApiMode, + [Required] string? Model, + [Required] int? MaxTokens, SecretMutationRequest? ApiKey); internal sealed record AnthropicSettingsPatchRequest( - [property: Required] string? BaseUrl, - [property: Required] string? Model, - [property: Required] int? MaxTokens, - [property: Required] string? ApiVersion, + [Required] string? BaseUrl, + [Required] string? Model, + [Required] int? MaxTokens, + [Required] string? ApiVersion, SecretMutationRequest? ApiKey); internal sealed record CodexAppServerSettingsPatchRequest( - [property: Required] string? Endpoint, + [Required] string? Endpoint, string? Model, - [property: Required] string? PermissionProfile, - [property: Required] int? TimeoutSeconds, + [Required] string? PermissionProfile, + [Required] int? TimeoutSeconds, SecretMutationRequest? Token); internal sealed record InferenceSettingsPatchRequest( - [property: Required] int? RateLimitDelayMs); + [Required] int? RateLimitDelayMs); internal sealed record AiSettingsPatchRequest( - [property: Required] AiExecutionMode? ExecutionMode, - [property: Required] BuiltInAiProvider? Provider, - [property: Required] OpenAiSettingsPatchRequest? OpenAI, - [property: Required] AnthropicSettingsPatchRequest? Anthropic, - [property: Required] CodexAppServerSettingsPatchRequest? CodexAppServer, - [property: Required] InferenceSettingsPatchRequest? Inference); + [Required] AiExecutionMode? ExecutionMode, + [Required] BuiltInAiProvider? Provider, + [Required] OpenAiSettingsPatchRequest? OpenAI, + [Required] AnthropicSettingsPatchRequest? Anthropic, + [Required] CodexAppServerSettingsPatchRequest? CodexAppServer, + [Required] InferenceSettingsPatchRequest? Inference); internal sealed record TmdbSettingsPatchRequest(SecretMutationRequest? ApiKey); internal sealed record TorrentSettingsPatchRequest( - [property: Required] string? Url, + [Required] string? Url, string? UserName, string? UserAgent, SecretMutationRequest? Password); internal sealed record MediaLibrarySettingsPatchRequest( - [property: Required] IReadOnlyList? AllowedRoots, - [property: Required] TimeSpan? ScanInterval, - [property: Required] TimeSpan? SettlingPeriod, - [property: Required] TimeSpan? MissingGracePeriod); + [Required] IReadOnlyList? AllowedRoots, + [Required] TimeSpan? ScanInterval, + [Required] TimeSpan? SettlingPeriod, + [Required] TimeSpan? MissingGracePeriod); internal sealed record IncidentDiskSettingsPatchRequest( - [property: Required] long? MinimumAvailableBytes, - [property: Required] double? MinimumAvailablePercent); + [Required] long? MinimumAvailableBytes, + [Required] double? MinimumAvailablePercent); internal sealed record IncidentSettingsPatchRequest( - [property: Required] TimeSpan? DownloadStalledAfter, - [property: Required] TimeSpan? ReportThrottle, - [property: Required] TimeSpan? ReconciliationInterval, - [property: Required] IncidentDiskSettingsPatchRequest? Disk); + [Required] TimeSpan? DownloadStalledAfter, + [Required] TimeSpan? ReportThrottle, + [Required] TimeSpan? ReconciliationInterval, + [Required] IncidentDiskSettingsPatchRequest? Disk); internal sealed record NfsSettingsPatchRequest( - [property: Required] bool? Enabled, - [property: Required] int? Port, - [property: Required] string? BindAddress, - [property: Required] int? LeaseSeconds, - [property: Required] int? MaxConnections, - [property: Required] int? IdleTimeoutSeconds, - [property: Required] bool? AllowAnonymous, - [property: Required] IReadOnlyList? AllowedNetworks); + [Required] bool? Enabled, + [Required] int? Port, + [Required] string? BindAddress, + [Required] int? LeaseSeconds, + [Required] int? MaxConnections, + [Required] int? IdleTimeoutSeconds, + [Required] bool? AllowAnonymous, + [Required] IReadOnlyList? AllowedNetworks); internal sealed record NotificationSettingsPatchRequest( - [property: Required] bool? WebhookEnabled, - [property: Required] IReadOnlyList? Events, + [Required] bool? WebhookEnabled, + [Required] bool? WebPushEnabled, + [Required] string? WebPushSubject, + [Required] IReadOnlyList? Events, TimeSpan? QuietHoursStart, TimeSpan? QuietHoursEnd, - [property: Required] string? TimeZoneId, - SecretMutationRequest? WebhookUrl); + [Required] string? TimeZoneId, + SecretMutationRequest? WebhookUrl, + bool GenerateVapidKeys = false); internal sealed record PatchApplicationSettingsRequest( long ExpectedRevision, diff --git a/SecondDimensionWatcherReDive/Controllers/External/Notifications.cs b/SecondDimensionWatcherReDive/Controllers/External/Notifications.cs index 2c9590cd..61106b11 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/Notifications.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/Notifications.cs @@ -1,7 +1,11 @@ +using System.ComponentModel.DataAnnotations; + namespace SecondDimensionWatcherReDive.Controllers.External; internal sealed record NotificationDeliveryItem( Guid Id, + Guid EventId, + string Channel, string Type, string Status, int AttemptCount, @@ -11,3 +15,27 @@ internal sealed record NotificationDeliveryItem( string? LastError); internal sealed record TestNotificationResponse(Guid EventId); + +internal sealed record WebPushConfigurationResponse( + bool Enabled, + string VapidPublicKey); + +internal sealed record WebPushSubscriptionKeysRequest( + [Required] string? P256dh, + [Required] string? Auth); + +internal sealed record RegisterWebPushSubscriptionRequest( + [Required] string? Endpoint, + [Required] WebPushSubscriptionKeysRequest? Keys); + +internal sealed record RemoveWebPushSubscriptionRequest( + [Required] string? Endpoint); + +internal sealed record WebPushSubscriptionSummary( + Guid Id, + string EndpointOrigin, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + DateTimeOffset? LastSuccessAt, + DateTimeOffset? LastFailureAt, + string? LastError); diff --git a/SecondDimensionWatcherReDive/Controllers/External/Todos.cs b/SecondDimensionWatcherReDive/Controllers/External/Todos.cs index 650d670a..24eae340 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/Todos.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/Todos.cs @@ -34,6 +34,6 @@ internal enum TodoStateAction } internal sealed record UpdateTodoStateRequest( - [property: Required, MinLength(1), MaxLength(100)] IReadOnlyList Keys, - [property: Required] TodoStateAction Action, + [Required, MinLength(1), MaxLength(100)] IReadOnlyList Keys, + [Required] TodoStateAction? Action, DateTimeOffset? SnoozedUntil); diff --git a/SecondDimensionWatcherReDive/Controllers/IncidentsController.cs b/SecondDimensionWatcherReDive/Controllers/IncidentsController.cs index fe2f7850..2c5bea51 100644 --- a/SecondDimensionWatcherReDive/Controllers/IncidentsController.cs +++ b/SecondDimensionWatcherReDive/Controllers/IncidentsController.cs @@ -19,7 +19,8 @@ public async Task GetAsync( [FromQuery] int skip = 0, [FromQuery] int take = 50, [FromQuery] bool includeResolved = false, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + [FromQuery] Guid? focus = null) { if (skip < 0 || take is < 1 or > 200) return BadRequest(new { message = "skip must be non-negative and take must be between 1 and 200." }); @@ -32,8 +33,16 @@ public async Task GetAsync( skip, take, cancellationToken); + var items = page.Items; + if (focus.HasValue && items.All(item => item.Id != focus.Value)) + { + var focused = await incidentRepository.FindByIdAsync(focus.Value, cancellationToken); + if (focused is not null + && (!parsedType.HasValue || focused.Type == parsedType.Value)) + items = [focused, .. items]; + } return Ok(new External.IncidentListResponse( - page.Items.Select(ToExternal).ToList(), + items.Select(ToExternal).ToList(), page.TotalCount, page.OpenCount, page.OpenCountsByType.ToDictionary( diff --git a/SecondDimensionWatcherReDive/Controllers/MetadataReviewController.cs b/SecondDimensionWatcherReDive/Controllers/MetadataReviewController.cs index 08201518..018e3a0e 100644 --- a/SecondDimensionWatcherReDive/Controllers/MetadataReviewController.cs +++ b/SecondDimensionWatcherReDive/Controllers/MetadataReviewController.cs @@ -19,6 +19,7 @@ public async Task GetAsync( [FromQuery] string status = "pending", [FromQuery] int skip = 0, [FromQuery] int take = 20, + [FromQuery] Guid? focus = null, CancellationToken cancellationToken = default) { if (!TryParseQueueStatus(status, out var parsedStatus)) @@ -34,6 +35,7 @@ public async Task GetAsync( parsedStatus, skip, take, + focus, cancellationToken); return Ok(ToExternal(page)); } diff --git a/SecondDimensionWatcherReDive/Controllers/NotificationsController.cs b/SecondDimensionWatcherReDive/Controllers/NotificationsController.cs index c55f903f..97f754d5 100644 --- a/SecondDimensionWatcherReDive/Controllers/NotificationsController.cs +++ b/SecondDimensionWatcherReDive/Controllers/NotificationsController.cs @@ -13,24 +13,36 @@ namespace SecondDimensionWatcherReDive.Controllers; internal sealed class NotificationsController( INotificationPublisher publisher, INotificationOutboxRepository outboxRepository, - IConfiguration configuration) : ControllerBase + IConfiguration configuration, + IWebPushSubscriptionRepository? webPushSubscriptions = null) : ControllerBase { [HttpPost("test")] public async Task> SendTestAsync( CancellationToken cancellationToken) { - if (!configuration.GetValue("Notifications:Webhook:Enabled") - || string.IsNullOrWhiteSpace(configuration["Notifications:Webhook:Url"])) - return Conflict(new { message = "Enable and configure the webhook channel first." }); + var webhookReady = configuration.GetValue("Notifications:Webhook:Enabled") + && !string.IsNullOrWhiteSpace( + configuration["Notifications:Webhook:Url"]); + var webPushReady = configuration.GetValue("Notifications:WebPush:Enabled") + && webPushSubscriptions is not null + && !string.IsNullOrWhiteSpace( + configuration["Notifications:WebPush:VapidPublicKey"]) + && (await webPushSubscriptions.GetAllAsync(cancellationToken)).Count > 0; + if (!webhookReady && !webPushReady) + return Conflict(new { message = "Enable and configure at least one notification destination first." }); var id = Guid.NewGuid(); - await publisher.PublishAsync(new NotificationEvent( + var enqueued = await publisher.PublishAsync(new NotificationEvent( NotificationEventType.Test, $"test:{id}", "SecondDimensionWatcher Re:Dive test", - "Your webhook notification channel is configured correctly.", + "Your notification channel is configured correctly.", "/settings?section=notifications", Id: id), cancellationToken); + if (!enqueued) + return StatusCode( + StatusCodes.Status503ServiceUnavailable, + new { message = "The test notification could not be persisted." }); return Accepted(new TestNotificationResponse(id)); } @@ -43,6 +55,8 @@ public async Task>> GetDeli var items = await outboxRepository.GetRecentAsync(take, cancellationToken); return Ok(items.Select(item => new NotificationDeliveryItem( item.Id, + item.EventId, + item.Channel.ToString(), ToJsonName(item.Type), item.Status.ToString(), item.AttemptCount, diff --git a/SecondDimensionWatcherReDive/Controllers/SettingsController.cs b/SecondDimensionWatcherReDive/Controllers/SettingsController.cs index a2827e0e..caec9d7f 100644 --- a/SecondDimensionWatcherReDive/Controllers/SettingsController.cs +++ b/SecondDimensionWatcherReDive/Controllers/SettingsController.cs @@ -250,19 +250,23 @@ request.Tmdb is null if (request is null) return null; if (request.WebhookEnabled is null) AddRequired("notifications.webhookEnabled"); + if (request.WebPushEnabled is null) AddRequired("notifications.webPushEnabled"); + if (request.WebPushSubject is null) AddRequired("notifications.webPushSubject"); if (request.Events is null) AddRequired("notifications.events"); if (request.TimeZoneId is null) AddRequired("notifications.timeZoneId"); if (!ModelState.IsValid) return null; return new NotificationSettingsUpdate( - new NotificationSettingsValues( - request.WebhookEnabled!.Value, - request.Events!, - request.QuietHoursStart, - request.QuietHoursEnd, - request.TimeZoneId!), - MapSecret(request.WebhookUrl, "notifications.webhook.url")); + request.WebhookEnabled!.Value, + request.WebPushEnabled!.Value, + request.WebPushSubject!, + request.Events!, + request.QuietHoursStart, + request.QuietHoursEnd, + request.TimeZoneId!, + MapSecret(request.WebhookUrl, "notifications.webhook.url"), + request.GenerateVapidKeys); } private SecretMutation? MapSecret(SecretMutationRequest? request, string path) @@ -340,6 +344,10 @@ private static ApplicationSettingsResponse ToResponse(RuntimeSettingsState state state.PendingRestart), new NotificationSettingsResponse( values.Notifications.WebhookEnabled, + values.Notifications.WebPushEnabled, + values.Notifications.WebPushSubject, + values.Notifications.VapidPublicKey, + Secret(state, RuntimeSecretKeys.NotificationVapidPrivateKey), values.Notifications.Events, values.Notifications.QuietHoursStart, values.Notifications.QuietHoursEnd, diff --git a/SecondDimensionWatcherReDive/Controllers/TodosController.cs b/SecondDimensionWatcherReDive/Controllers/TodosController.cs index dc5dcbc1..33574616 100644 --- a/SecondDimensionWatcherReDive/Controllers/TodosController.cs +++ b/SecondDimensionWatcherReDive/Controllers/TodosController.cs @@ -17,6 +17,7 @@ public async Task> GetAsync( [FromQuery] bool includeSnoozed = false, [FromQuery] int skip = 0, [FromQuery] int take = 50, + [FromQuery] string? focus = null, CancellationToken cancellationToken = default) { if (skip < 0 || take is < 1 or > 200) @@ -24,6 +25,8 @@ public async Task> GetAsync( { message = "skip must be non-negative and take must be between 1 and 200." }); + if (focus is not null && !IsValidKey(focus)) + return BadRequest(new { message = "focus must be a valid todo resource key." }); var page = await todoRepository.GetAsync( includeRead, @@ -31,6 +34,7 @@ public async Task> GetAsync( DateTimeOffset.UtcNow, skip, take, + focus, cancellationToken); return Ok(new TodoListResponse( page.Items.Select(item => new TodoItemResponse( diff --git a/SecondDimensionWatcherReDive/Controllers/WebPushSubscriptionsController.cs b/SecondDimensionWatcherReDive/Controllers/WebPushSubscriptionsController.cs new file mode 100644 index 00000000..fa550022 --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/WebPushSubscriptionsController.cs @@ -0,0 +1,177 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Cryptography; +using SecondDimensionWatcherReDive.Controllers.External; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Repositories; +using SecondDimensionWatcherReDive.Utils.Http; + +namespace SecondDimensionWatcherReDive.Controllers; + +[ApiController] +[Route("api/notifications/web-push")] +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +internal sealed class WebPushSubscriptionsController( + IWebPushSubscriptionRepository subscriptions, + OutboundAddressPolicy outboundAddressPolicy, + IConfiguration configuration) : ControllerBase +{ + private const int MaximumEndpointLength = 2048; + + [HttpGet("config")] + public ActionResult GetConfiguration() => + Ok(new WebPushConfigurationResponse( + configuration.GetValue("Notifications:WebPush:Enabled"), + configuration["Notifications:WebPush:VapidPublicKey"] ?? string.Empty)); + + [HttpGet("subscriptions")] + public async Task>> GetSubscriptionsAsync( + CancellationToken cancellationToken) + { + var items = await subscriptions.GetAllAsync(cancellationToken); + return Ok(items.Select(ToSummary).ToList()); + } + + [HttpPost("subscriptions")] + public async Task> RegisterAsync( + [FromBody] RegisterWebPushSubscriptionRequest request, + CancellationToken cancellationToken) + { + if (!configuration.GetValue("Notifications:WebPush:Enabled") + || string.IsNullOrWhiteSpace( + configuration["Notifications:WebPush:VapidPublicKey"]) + || string.IsNullOrWhiteSpace( + configuration["Notifications:WebPush:VapidPrivateKey"])) + return Conflict(new { message = "Enable and configure Web Push first." }); + + if (!TryNormalizeEndpoint(request.Endpoint, out var endpoint, out var endpointUri)) + return ValidationError( + "endpoint", + "The endpoint must be an absolute HTTPS push-service URL of at most 2048 characters."); + if (request.Keys is null + || !IsValidKey(request.Keys.P256dh, expectedLength: 65, requiredPrefix: 0x04) + || !IsValidKey(request.Keys.Auth, expectedLength: 16, requiredPrefix: null)) + return ValidationError("keys", "The browser subscription keys are invalid."); + + try + { + // Push endpoints are bearer capabilities supplied by a browser. Apply + // the same DNS/IP policy used for other outbound requests at both + // registration time and again through the pinned sending handler. + await outboundAddressPolicy.ValidateUriAsync(endpointUri!, cancellationToken); + var now = DateTimeOffset.UtcNow; + var saved = await subscriptions.UpsertAsync( + new WebPushSubscription( + Guid.NewGuid(), + endpoint!, + request.Keys.P256dh!, + request.Keys.Auth!, + now, + now, + null, + null, + null), + cancellationToken); + return Ok(ToSummary(saved)); + } + catch (OutboundRequestBlockedException) + { + return ValidationError("endpoint", "The push-service endpoint is not allowed."); + } + catch (InvalidOperationException exception) + { + return Conflict(new { message = exception.Message }); + } + } + + [HttpDelete("subscriptions/{id:guid}")] + public async Task RemoveAsync( + [FromRoute] Guid id, + CancellationToken cancellationToken) => + await subscriptions.RemoveAsync(id, cancellationToken) + ? NoContent() + : NotFound(); + + [HttpPost("subscriptions/remove-current")] + public async Task RemoveCurrentAsync( + [FromBody] RemoveWebPushSubscriptionRequest request, + CancellationToken cancellationToken) + { + if (!TryNormalizeEndpoint(request.Endpoint, out var endpoint, out _)) + return ValidationError("endpoint", "The push-service endpoint is invalid."); + await subscriptions.RemoveByEndpointAsync(endpoint!, cancellationToken); + return NoContent(); + } + + private static WebPushSubscriptionSummary ToSummary(WebPushSubscription subscription) => new( + subscription.Id, + new Uri(subscription.Endpoint).GetLeftPart(UriPartial.Authority), + subscription.CreatedAt, + subscription.UpdatedAt, + subscription.LastSuccessAt, + subscription.LastFailureAt, + subscription.LastError); + + private ActionResult ValidationError(string key, string message) + { + ModelState.AddModelError(key, message); + return ValidationProblem(ModelState); + } + + private static bool TryNormalizeEndpoint( + string? value, + out string? endpoint, + out Uri? uri) + { + endpoint = null; + uri = null; + if (string.IsNullOrWhiteSpace(value) + || value.Length > MaximumEndpointLength + || !Uri.TryCreate(value.Trim(), UriKind.Absolute, out uri) + || uri.Scheme != Uri.UriSchemeHttps + || string.IsNullOrWhiteSpace(uri.IdnHost) + || !string.IsNullOrEmpty(uri.UserInfo) + || !string.IsNullOrEmpty(uri.Fragment)) + return false; + endpoint = uri.AbsoluteUri; + return true; + } + + private static bool IsValidKey( + string? value, + int expectedLength, + byte? requiredPrefix) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 256) + return false; + try + { + var normalized = value.Replace('-', '+').Replace('_', '/'); + normalized = normalized.PadRight((normalized.Length + 3) / 4 * 4, '='); + var bytes = Convert.FromBase64String(normalized); + if (bytes.Length != expectedLength + || (requiredPrefix.HasValue && bytes[0] != requiredPrefix.Value)) + return false; + if (requiredPrefix == 0x04) + { + using var key = ECDiffieHellman.Create(new ECParameters + { + Curve = ECCurve.NamedCurves.nistP256, + Q = new ECPoint + { + X = bytes[1..33], + Y = bytes[33..65] + } + }); + } + return true; + } + catch (Exception exception) when ( + exception is FormatException or CryptographicException or ArgumentException) + { + return false; + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.Designer.cs b/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.Designer.cs index 8a0ad989..5aa2d50a 100644 --- a/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.Designer.cs +++ b/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.Designer.cs @@ -199,6 +199,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("SourceFeedId"); + b.HasIndex("AutomationDisposition", "PublishTime"); + b.HasIndex("FileStore", "StorePath") .IsUnique() .HasFilter("\"DownloadType\" = 'http://schemas.hcgstudio.com/ws/2023/06/sdw/downloadtype/media-library-import'"); diff --git a/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.cs b/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.cs index d0c665a5..9ec8a3ab 100644 --- a/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.cs +++ b/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.cs @@ -49,6 +49,11 @@ protected override void Up(MigrationBuilder migrationBuilder) table.PrimaryKey("PK_TodoItemStates", x => x.Key); }); + migrationBuilder.CreateIndex( + name: "IX_AnimationInfo_AutomationDisposition_PublishTime", + table: "AnimationInfo", + columns: new[] { "AutomationDisposition", "PublishTime" }); + migrationBuilder.CreateIndex( name: "IX_NotificationOutboxMessages_DeduplicationKey", table: "NotificationOutboxMessages", @@ -64,6 +69,10 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropIndex( + name: "IX_AnimationInfo_AutomationDisposition_PublishTime", + table: "AnimationInfo"); + migrationBuilder.DropTable( name: "NotificationOutboxMessages"); diff --git a/SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.Designer.cs b/SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.Designer.cs index 8ee82991..9e048fe7 100644 --- a/SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.Designer.cs +++ b/SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.Designer.cs @@ -199,6 +199,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("SourceFeedId"); + b.HasIndex("AutomationDisposition", "PublishTime"); + b.HasIndex("FileStore", "StorePath") .IsUnique() .HasFilter("\"DownloadType\" = 'http://schemas.hcgstudio.com/ws/2023/06/sdw/downloadtype/media-library-import'"); diff --git a/SecondDimensionWatcherReDive/Migrations/20260831045113_AddWebPushNotifications.Designer.cs b/SecondDimensionWatcherReDive/Migrations/20260831045113_AddWebPushNotifications.Designer.cs new file mode 100644 index 00000000..0e4f24e8 --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260831045113_AddWebPushNotifications.Designer.cs @@ -0,0 +1,1206 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SecondDimensionWatcherReDive.Models; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260831045113_AddWebPushNotifications")] + partial class AddWebPushNotifications + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Animation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PosterPath") + .HasColumnType("text"); + + b.Property("TmdbId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("Animations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AnimationGroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalDownloadInfo") + .IsRequired() + .HasColumnType("text"); + + b.Property("AiRetryCount") + .HasColumnType("integer"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("AutomationDisposition") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AutomationExplanationJson") + .HasColumnType("text"); + + b.Property("CachedDownloadData") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("CurrentMetadataReviewOperationId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadAttemptId") + .HasColumnType("uuid"); + + b.Property("DownloadCancellationId") + .HasColumnType("uuid"); + + b.Property("DownloadEndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadType") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("FileStore") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAiProcessed") + .HasColumnType("boolean"); + + b.Property("IsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("IsDownloadTracked") + .HasColumnType("boolean"); + + b.Property("MediaLibraryMissingSince") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaLibrarySourceId") + .HasColumnType("uuid"); + + b.Property("MetadataConfidence") + .HasColumnType("double precision"); + + b.Property("MetadataLastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MetadataStatus") + .HasColumnType("integer"); + + b.Property("PublishTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleaseSizeBytes") + .HasColumnType("bigint"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SourceFeedId") + .HasColumnType("uuid"); + + b.Property("StateVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("StorePath") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AnimationId"); + + b.HasIndex("CurrentMetadataReviewOperationId") + .IsUnique(); + + b.HasIndex("GroupId"); + + b.HasIndex("MediaLibrarySourceId"); + + b.HasIndex("SourceFeedId"); + + b.HasIndex("AutomationDisposition", "PublishTime"); + + b.HasIndex("FileStore", "StorePath") + .IsUnique() + .HasFilter("\"DownloadType\" = 'http://schemas.hcgstudio.com/ws/2023/06/sdw/downloadtype/media-library-import'"); + + b.HasIndex("MetadataStatus", "PublishTime"); + + b.ToTable("AnimationInfo", t => + { + t.HasCheckConstraint("CK_AnimationInfo_MetadataConfidence_Range", "\"MetadataConfidence\" IS NULL OR (\"MetadataConfidence\" >= 0 AND \"MetadataConfidence\" <= 1)"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ApplicationSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ProtectedSecrets") + .HasColumnType("text"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings", t => + { + t.HasCheckConstraint("CK_ApplicationSettings_Revision_Positive", "\"Revision\" > 0"); + + t.HasCheckConstraint("CK_ApplicationSettings_Singleton", "\"Id\" = 1"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AuthenticationState", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ClaimId") + .HasColumnType("uuid"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RegisteredAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("AuthenticationStates", t => + { + t.HasCheckConstraint("CK_AuthenticationStates_Singleton", "\"Id\" = 1"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MikanSubgroupId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SeasonBangumiId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SeasonBangumiId", "MikanSubgroupId") + .IsUnique(); + + b.ToTable("BangumiSubgroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ChatConversations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToolCallId") + .HasColumnType("text"); + + b.Property("ToolCallsJson") + .HasColumnType("text"); + + b.Property("ToolName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Feed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Feeds"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("VirtualPath") + .IsUnique(); + + b.ToTable("FileMappings"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("AnimationId", "CreatedAt"); + + b.HasIndex("AnimationId", "Pattern") + .IsUnique(); + + b.ToTable("FileNameRegexRules"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Incident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DetectedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastRetryError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Occurrence") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Fingerprint") + .IsUnique(); + + b.HasIndex("ResolvedAt", "Type", "UpdatedAt"); + + b.ToTable("Incidents", t => + { + t.HasCheckConstraint("CK_Incidents_Occurrence_Positive", "\"Occurrence\" > 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MediaLibrarySource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsMonitoring") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastImportedCount") + .HasColumnType("integer"); + + b.Property("LastRemovedCount") + .HasColumnType("integer"); + + b.Property("LastScanAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSkippedCount") + .HasColumnType("integer"); + + b.Property("LastUpdatedCount") + .HasColumnType("integer"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("MediaLibrarySources"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OperationId", "Kind", "VirtualPath") + .IsUnique(); + + b.ToTable("MetadataReviewMappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AppliedVersion") + .HasColumnType("bigint"); + + b.Property("BaseFileStore") + .HasColumnType("text"); + + b.Property("BaseIsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("BaseStorePath") + .HasColumnType("text"); + + b.Property("BaseVersion") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousAiRetryCount") + .HasColumnType("integer"); + + b.Property("PreviousAnimationId") + .HasColumnType("uuid"); + + b.Property("PreviousConfidence") + .HasColumnType("double precision"); + + b.Property("PreviousCurrentOperationId") + .HasColumnType("uuid"); + + b.Property("PreviousDescription") + .HasColumnType("text"); + + b.Property("PreviousEpisode") + .HasColumnType("integer"); + + b.Property("PreviousGroupId") + .HasColumnType("uuid"); + + b.Property("PreviousIsAiProcessed") + .HasColumnType("boolean"); + + b.Property("PreviousLastError") + .HasColumnType("text"); + + b.Property("PreviousMetadataStatus") + .HasColumnType("integer"); + + b.Property("PreviousReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousSeason") + .HasColumnType("integer"); + + b.Property("ProposedAnimationName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationOriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationPosterPath") + .HasColumnType("text"); + + b.Property("ProposedAnimationTmdbId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedEpisode") + .HasColumnType("integer"); + + b.Property("ProposedGroupName") + .HasColumnType("text"); + + b.Property("ProposedSeason") + .HasColumnType("integer"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("UndoneAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId", "AppliedVersion") + .IsUnique(); + + b.HasIndex("AnimationInfoId", "State"); + + b.HasIndex("State", "ExpiresAt"); + + b.ToTable("MetadataReviewOperations", t => + { + t.HasCheckConstraint("CK_MetadataReviewOperations_Expiry", "\"ExpiresAt\" > \"CreatedAt\""); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MigrationExecutionState", b => + { + b.Property("Key") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Version") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("AttemptCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("Checkpoint") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastErrorSummary") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Key", "Version"); + + b.ToTable("MigrationMarkers", null, t => + { + t.HasCheckConstraint("CK_MigrationMarkers_AttemptCount_NonNegative", "\"AttemptCount\" >= 0"); + + t.HasCheckConstraint("CK_MigrationMarkers_Status_Range", "\"Status\" BETWEEN 0 AND 3"); + + t.HasCheckConstraint("CK_MigrationMarkers_Version_Positive", "\"Version\" > 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.NotificationOutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("DeduplicationKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DeepLink") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DeliveredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PayloadJson") + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("WebPushSubscriptionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeduplicationKey") + .IsUnique(); + + b.HasIndex("WebPushSubscriptionId"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("NotificationOutboxMessages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AutoPlayNext") + .HasColumnType("boolean"); + + b.Property("SubtitleLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SubtitleTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("PlaybackPreferences"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsWatched") + .HasColumnType("boolean"); + + b.Property("PositionSeconds") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VirtualPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("WatchedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("UserId", "AnimationInfoId", "VirtualPath") + .IsUnique(); + + b.HasIndex("UserId", "IsWatched", "UpdatedAt"); + + b.ToTable("PlaybackProgresses", t => + { + t.HasCheckConstraint("CK_PlaybackProgresses_Duration_NonNegative", "\"DurationSeconds\" >= 0"); + + t.HasCheckConstraint("CK_PlaybackProgresses_Position_NonNegative", "\"PositionSeconds\" >= 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("MikanId") + .HasColumnType("integer"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MikanId") + .IsUnique(); + + b.ToTable("SeasonBangumis"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.Property("FeedId") + .HasColumnType("uuid"); + + b.PrimitiveCollection("Codecs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection("ExcludedKeywords") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("Languages") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("MaxSizeBytes") + .HasColumnType("bigint"); + + b.Property("MinSizeBytes") + .HasColumnType("bigint"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.PrimitiveCollection("Resolutions") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("SubtitleGroups") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("FeedId"); + + b.HasIndex("UpdatedAt"); + + b.ToTable("SubscriptionAutomationPolicies"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.TodoItemState", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SnoozedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("TodoItemStates"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebDavToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("WebDavTokens"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebPushSubscription", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndpointHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastError") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastFailureAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSuccessAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProtectedAuth") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ProtectedEndpoint") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("ProtectedP256Dh") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EndpointHash") + .IsUnique(); + + b.ToTable("WebPushSubscriptions"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", "Animation") + .WithMany() + .HasForeignKey("AnimationId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationGroup", "Group") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.MediaLibrarySource", null) + .WithMany() + .HasForeignKey("MediaLibrarySourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", null) + .WithMany() + .HasForeignKey("SourceFeedId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Animation"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.SeasonBangumi", "SeasonBangumi") + .WithMany("Subgroups") + .HasForeignKey("SeasonBangumiId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SeasonBangumi"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.ChatConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", null) + .WithMany() + .HasForeignKey("AnimationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", "Operation") + .WithMany("MappingSnapshots") + .HasForeignKey("OperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", "Feed") + .WithOne() + .HasForeignKey("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", "FeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Feed"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Navigation("MappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Navigation("Subgroups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/20260831045113_AddWebPushNotifications.cs b/SecondDimensionWatcherReDive/Migrations/20260831045113_AddWebPushNotifications.cs new file mode 100644 index 00000000..e1b7b2d6 --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260831045113_AddWebPushNotifications.cs @@ -0,0 +1,148 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + /// + public partial class AddWebPushNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Channel", + table: "NotificationOutboxMessages", + type: "character varying(24)", + maxLength: 24, + nullable: false, + defaultValue: "Webhook"); + + // Move pre-channel webhook rows into the same explicit target + // namespace used by the publisher. This preserves their deduplication + // identity across the upgrade while keeping every channel target in + // the table-wide unique-key domain. + migrationBuilder.DropIndex( + name: "IX_NotificationOutboxMessages_DeduplicationKey", + table: "NotificationOutboxMessages"); + + migrationBuilder.Sql( + """ + UPDATE "NotificationOutboxMessages" + SET "DeduplicationKey" = CASE + WHEN char_length('webhook:' || "DeduplicationKey") <= 256 + THEN 'webhook:' || "DeduplicationKey" + ELSE left('webhook:' || "DeduplicationKey", 191) + || ':' + || encode(sha256(convert_to('webhook:' || "DeduplicationKey", 'UTF8')), 'hex') + END; + """); + + migrationBuilder.CreateIndex( + name: "IX_NotificationOutboxMessages_DeduplicationKey", + table: "NotificationOutboxMessages", + column: "DeduplicationKey", + unique: true); + + migrationBuilder.AddColumn( + name: "EventId", + table: "NotificationOutboxMessages", + type: "uuid", + nullable: true); + + migrationBuilder.Sql( + "UPDATE \"NotificationOutboxMessages\" SET \"EventId\" = \"Id\" WHERE \"EventId\" IS NULL;"); + + migrationBuilder.AlterColumn( + name: "EventId", + table: "NotificationOutboxMessages", + type: "uuid", + nullable: false, + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "WebPushSubscriptionId", + table: "NotificationOutboxMessages", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "WebPushSubscriptions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + EndpointHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + ProtectedEndpoint = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: false), + ProtectedP256Dh = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + ProtectedAuth = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastSuccessAt = table.Column(type: "timestamp with time zone", nullable: true), + LastFailureAt = table.Column(type: "timestamp with time zone", nullable: true), + LastError = table.Column(type: "character varying(256)", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_WebPushSubscriptions", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_NotificationOutboxMessages_WebPushSubscriptionId", + table: "NotificationOutboxMessages", + column: "WebPushSubscriptionId"); + + migrationBuilder.CreateIndex( + name: "IX_WebPushSubscriptions_EndpointHash", + table: "WebPushSubscriptions", + column: "EndpointHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_NotificationOutboxMessages_DeduplicationKey", + table: "NotificationOutboxMessages"); + + migrationBuilder.Sql( + """ + DELETE FROM "NotificationOutboxMessages" + WHERE "Channel" = 'WebPush'; + + UPDATE "NotificationOutboxMessages" + SET "DeduplicationKey" = substring("DeduplicationKey" from 9) + WHERE "Channel" = 'Webhook' + AND "DeduplicationKey" LIKE 'webhook:%'; + """); + + migrationBuilder.CreateIndex( + name: "IX_NotificationOutboxMessages_DeduplicationKey", + table: "NotificationOutboxMessages", + column: "DeduplicationKey", + unique: true); + + migrationBuilder.DropTable( + name: "WebPushSubscriptions"); + + migrationBuilder.DropIndex( + name: "IX_NotificationOutboxMessages_WebPushSubscriptionId", + table: "NotificationOutboxMessages"); + + migrationBuilder.DropColumn( + name: "Channel", + table: "NotificationOutboxMessages"); + + migrationBuilder.DropColumn( + name: "EventId", + table: "NotificationOutboxMessages"); + + migrationBuilder.DropColumn( + name: "WebPushSubscriptionId", + table: "NotificationOutboxMessages"); + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs index 9479367e..156b19b5 100644 --- a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs +++ b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs @@ -196,6 +196,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SourceFeedId"); + b.HasIndex("AutomationDisposition", "PublishTime"); + b.HasIndex("FileStore", "StorePath") .IsUnique() .HasFilter("\"DownloadType\" = 'http://schemas.hcgstudio.com/ws/2023/06/sdw/downloadtype/media-library-import'"); @@ -756,6 +758,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(2048) .HasColumnType("character varying(2048)"); + b.Property("Channel") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + b.Property("DeduplicationKey") .IsRequired() .HasMaxLength(256) @@ -769,6 +776,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DeliveredAt") .HasColumnType("timestamp with time zone"); + b.Property("EventId") + .HasColumnType("uuid"); + b.Property("LastAttemptAt") .HasColumnType("timestamp with time zone"); @@ -800,11 +810,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(48) .HasColumnType("character varying(48)"); + b.Property("WebPushSubscriptionId") + .HasColumnType("uuid"); + b.HasKey("Id"); b.HasIndex("DeduplicationKey") .IsUnique(); + b.HasIndex("WebPushSubscriptionId"); + b.HasIndex("Status", "NextAttemptAt"); b.ToTable("NotificationOutboxMessages"); @@ -1019,6 +1034,55 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("WebDavTokens"); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebPushSubscription", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndpointHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastError") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastFailureAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSuccessAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProtectedAuth") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ProtectedEndpoint") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("ProtectedP256Dh") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EndpointHash") + .IsUnique(); + + b.ToTable("WebPushSubscriptions"); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => { b.HasOne("SecondDimensionWatcherReDive.Models.Animation", "Animation") diff --git a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs index 879b3bf4..6a1f6915 100644 --- a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs +++ b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs @@ -36,6 +36,7 @@ public ApplicationContext(DbContextOptions options) public DbSet ApplicationSettings { get; set; } public DbSet NotificationOutboxMessages { get; set; } public DbSet TodoItemStates { get; set; } + public DbSet WebPushSubscriptions { get; set; } public DbSet AuthenticationStates { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -47,6 +48,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasIndex(message => new { message.Status, message.NextAttemptAt }); + modelBuilder.Entity() + .HasIndex(message => message.WebPushSubscriptionId); + modelBuilder.Entity() .Property(message => message.DeduplicationKey) .HasMaxLength(256); @@ -56,6 +60,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasConversion() .HasMaxLength(48); + modelBuilder.Entity() + .Property(message => message.Channel) + .HasConversion() + .HasMaxLength(24); + modelBuilder.Entity() .Property(message => message.Status) .HasConversion() @@ -88,6 +97,34 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .Property(state => state.Key) .HasMaxLength(128); + modelBuilder.Entity() + .Property(subscription => subscription.Id) + .ValueGeneratedNever(); + + modelBuilder.Entity() + .HasIndex(subscription => subscription.EndpointHash) + .IsUnique(); + + modelBuilder.Entity() + .Property(subscription => subscription.EndpointHash) + .HasMaxLength(64); + + modelBuilder.Entity() + .Property(subscription => subscription.ProtectedEndpoint) + .HasMaxLength(4096); + + modelBuilder.Entity() + .Property(subscription => subscription.ProtectedP256Dh) + .HasMaxLength(1024); + + modelBuilder.Entity() + .Property(subscription => subscription.ProtectedAuth) + .HasMaxLength(1024); + + modelBuilder.Entity() + .Property(subscription => subscription.LastError) + .HasMaxLength(256); + modelBuilder.Entity() .Property(state => state.Id) .ValueGeneratedNever(); @@ -356,6 +393,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasConversion() .HasMaxLength(32); + modelBuilder.Entity() + .HasIndex(info => new { info.AutomationDisposition, info.PublishTime }); + modelBuilder.Entity() .HasOne() .WithMany() diff --git a/SecondDimensionWatcherReDive/Models/NotificationOutboxMessage.cs b/SecondDimensionWatcherReDive/Models/NotificationOutboxMessage.cs index afcce31f..52ac7feb 100644 --- a/SecondDimensionWatcherReDive/Models/NotificationOutboxMessage.cs +++ b/SecondDimensionWatcherReDive/Models/NotificationOutboxMessage.cs @@ -6,7 +6,10 @@ namespace SecondDimensionWatcherReDive.Models; public sealed class NotificationOutboxMessage { public Guid Id { get; set; } + public Guid EventId { get; set; } public string DeduplicationKey { get; set; } = string.Empty; + public NotificationChannel Channel { get; set; } + public Guid? WebPushSubscriptionId { get; set; } public NotificationEventType Type { get; set; } public string Title { get; set; } = string.Empty; public string Body { get; set; } = string.Empty; diff --git a/SecondDimensionWatcherReDive/Models/WebPushSubscription.cs b/SecondDimensionWatcherReDive/Models/WebPushSubscription.cs new file mode 100644 index 00000000..16c9760b --- /dev/null +++ b/SecondDimensionWatcherReDive/Models/WebPushSubscription.cs @@ -0,0 +1,15 @@ +namespace SecondDimensionWatcherReDive.Models; + +public sealed class WebPushSubscription +{ + public Guid Id { get; set; } + public string EndpointHash { get; set; } = string.Empty; + public string ProtectedEndpoint { get; set; } = string.Empty; + public string ProtectedP256Dh { get; set; } = string.Empty; + public string ProtectedAuth { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset? LastSuccessAt { get; set; } + public DateTimeOffset? LastFailureAt { get; set; } + public string? LastError { get; set; } +} diff --git a/SecondDimensionWatcherReDive/Program.cs b/SecondDimensionWatcherReDive/Program.cs index 933b5521..18f1ecf6 100644 --- a/SecondDimensionWatcherReDive/Program.cs +++ b/SecondDimensionWatcherReDive/Program.cs @@ -343,14 +343,61 @@ void ConfigureFeedClient(HttpClient client) }; }); -builder.Services.AddHttpClient("NotificationWebhook") +builder.Services.AddHttpClient("NotificationWebhook", (serviceProvider, client) => + { + var options = serviceProvider + .GetRequiredService>() + .Value; + // Keep a delivery attempt comfortably inside the outbox lease. DNS and + // connect deadlines are also enforced by the pinned connection factory. + client.Timeout = TimeSpan.FromSeconds(Math.Min(options.TotalTimeoutSeconds, 90)); + }) // The destination URL can contain a token. Disable the factory's default // request logger because it includes the complete request URI. .RemoveAllLoggers() - .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + .ConfigurePrimaryHttpMessageHandler(serviceProvider => + { + var connectionFactory = serviceProvider.GetRequiredService(); + var options = serviceProvider.GetRequiredService>().Value; + return new SocketsHttpHandler + { + // A redirect must not forward a secret-bearing webhook URL to another origin. + AllowAutoRedirect = false, + ConnectTimeout = TimeSpan.FromSeconds(options.ConnectTimeoutSeconds), + MaxConnectionsPerServer = options.MaxConcurrentRequests, + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + // Do not let an ambient proxy bypass destination validation. + UseProxy = false, + ConnectCallback = (context, cancellationToken) => + connectionFactory.ConnectAsync(context.DnsEndPoint, cancellationToken) + }; + }); + +builder.Services.AddHttpClient("WebPush", (serviceProvider, client) => + { + var options = serviceProvider + .GetRequiredService>() + .Value; + client.Timeout = TimeSpan.FromSeconds(Math.Min(options.TotalTimeoutSeconds, 90)); + }) + // A browser push endpoint is a bearer capability. Never emit its path or + // query through the HttpClient factory's request logger. + .RemoveAllLoggers() + .ConfigurePrimaryHttpMessageHandler(serviceProvider => { - // A redirect must not forward a secret-bearing webhook URL to another origin. - AllowAutoRedirect = false + var connectionFactory = serviceProvider.GetRequiredService(); + var options = serviceProvider.GetRequiredService>().Value; + return new SocketsHttpHandler + { + AllowAutoRedirect = false, + AutomaticDecompression = DecompressionMethods.None, + ConnectTimeout = TimeSpan.FromSeconds(options.ConnectTimeoutSeconds), + MaxConnectionsPerServer = options.MaxConcurrentRequests, + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + UseProxy = false, + ConnectCallback = (context, cancellationToken) => + connectionFactory.ConnectAsync(context.DnsEndPoint, cancellationToken) + }; }); var contentTypeProvider = new FileExtensionContentTypeProvider(); @@ -433,6 +480,7 @@ void ConfigureFeedClient(HttpClient client) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs b/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs index 6941385a..9a42a498 100644 --- a/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs @@ -360,6 +360,12 @@ public async Task UpdateAsync(AnimationInfo info, CancellationToken cancellation throw new DbUpdateConcurrencyException( $"AnimationInfo {info.Id} changed from revision {info.StateVersion} to {currentStateVersion}."); + await ResetTodoStateForTransitionAsync( + context, + entity, + info.MetadataStatus, + info.AutomationDisposition, + cancellationToken); info.ApplyTo(entity); entity.Animation = info.Animation is null @@ -401,14 +407,7 @@ public async Task TryStartDownloadAsync( } else { - entity.IsDownloadTracked = true; - entity.IsDownloadFinished = false; - entity.DownloadAttemptId = downloadAttemptId; - entity.DownloadCancellationId = null; - entity.DownloadStartTime = startedAt; - entity.FileStore = null; - entity.StorePath = null; - entity.AutomationDisposition = queuedDisposition + var nextDisposition = queuedDisposition ?? entity.AutomationDisposition switch { SubscriptionAutomationDisposition.Notified or @@ -418,6 +417,20 @@ SubscriptionAutomationDisposition.AutoDownloadFailed or SubscriptionAutomationDisposition.ManualDownloadQueued, _ => entity.AutomationDisposition }; + await ResetTodoStateForTransitionAsync( + writeContext, + entity, + entity.MetadataStatus, + nextDisposition, + cancellationToken); + entity.IsDownloadTracked = true; + entity.IsDownloadFinished = false; + entity.DownloadAttemptId = downloadAttemptId; + entity.DownloadCancellationId = null; + entity.DownloadStartTime = startedAt; + entity.FileStore = null; + entity.StorePath = null; + entity.AutomationDisposition = nextDisposition; entity.StateVersion = checked(entity.StateVersion + 1); await writeContext.SaveChangesAsync(cancellationToken); } @@ -512,6 +525,12 @@ public async Task TryBeginCancelDownloadAsync( SubscriptionAutomationDisposition.AutoDownloadQueued or SubscriptionAutomationDisposition.ManualDownloadQueued) { + await ResetTodoStateForTransitionAsync( + writeContext, + entity, + entity.MetadataStatus, + SubscriptionAutomationDisposition.DownloadCompleted, + cancellationToken); entity.AutomationDisposition = SubscriptionAutomationDisposition.DownloadCompleted; changed = true; } @@ -562,13 +581,20 @@ await writeContext.Entry(entity) entity.IsDownloadFinished = false; entity.DownloadAttemptId = null; entity.DownloadCancellationId = null; - entity.AutomationDisposition = terminalDisposition + var nextDisposition = terminalDisposition ?? (entity.AutomationDisposition is SubscriptionAutomationDisposition.AutoDownloadQueued or SubscriptionAutomationDisposition.ManualDownloadQueued or SubscriptionAutomationDisposition.DownloadCompleted ? SubscriptionAutomationDisposition.DownloadCancelled : entity.AutomationDisposition); + await ResetTodoStateForTransitionAsync( + writeContext, + entity, + entity.MetadataStatus, + nextDisposition, + cancellationToken); + entity.AutomationDisposition = nextDisposition; entity.StateVersion = checked(entity.StateVersion + 1); await writeContext.SaveChangesAsync(cancellationToken); } @@ -599,6 +625,12 @@ public async Task TryUpdateAsync( if (entity is null || entity.StateVersion != expectedStateVersion) return false; + await ResetTodoStateForTransitionAsync( + context, + entity, + info.MetadataStatus, + info.AutomationDisposition, + cancellationToken); info.ApplyTo(entity); entity.Animation = info.Animation is null ? null @@ -616,8 +648,46 @@ public async Task TryUpdateAsync( } catch (DbUpdateConcurrencyException) { - context.Entry(entity).State = EntityState.Detached; + // The transition may also have staged a todo-state deletion. None + // of those tracked changes belong to the losing revision. + context.ChangeTracker.Clear(); return false; } } + + private static async Task ResetTodoStateForTransitionAsync( + Models.ApplicationContext writeContext, + Models.AnimationInfo current, + MetadataReviewStatus nextMetadataStatus, + SubscriptionAutomationDisposition? nextAutomationDisposition, + CancellationToken cancellationToken) + { + if (current.MetadataStatus != nextMetadataStatus + && (IsMetadataTodoState(current.MetadataStatus) + || IsMetadataTodoState(nextMetadataStatus))) + { + var metadataState = await writeContext.TodoItemStates + .FindAsync(["metadata:" + current.Id], cancellationToken); + if (metadataState is not null) + writeContext.TodoItemStates.Remove(metadataState); + } + + if (current.AutomationDisposition != nextAutomationDisposition + && (IsAutomationTodoState(current.AutomationDisposition) + || IsAutomationTodoState(nextAutomationDisposition))) + { + var automationState = await writeContext.TodoItemStates + .FindAsync(["automation:" + current.Id], cancellationToken); + if (automationState is not null) + writeContext.TodoItemStates.Remove(automationState); + } + } + + private static bool IsMetadataTodoState(MetadataReviewStatus status) => + status is MetadataReviewStatus.LowConfidence or MetadataReviewStatus.Failed; + + private static bool IsAutomationTodoState(SubscriptionAutomationDisposition? disposition) => + disposition is SubscriptionAutomationDisposition.Notified + or SubscriptionAutomationDisposition.PendingConfirmation + or SubscriptionAutomationDisposition.AutoDownloadFailed; } diff --git a/SecondDimensionWatcherReDive/Repositories/IncidentRepository.cs b/SecondDimensionWatcherReDive/Repositories/IncidentRepository.cs index 443e29d8..c89143e6 100644 --- a/SecondDimensionWatcherReDive/Repositories/IncidentRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/IncidentRepository.cs @@ -76,6 +76,8 @@ public async Task UpsertAsync(Incident incident, CancellationToken can } else { + if (entity.ResolvedAt is not null) + await RemoveTodoStateAsync(entity, cancellationToken); ApplyReport(entity, incident); } @@ -92,6 +94,8 @@ public async Task UpsertAsync(Incident incident, CancellationToken can context.Entry(entity).State = EntityState.Detached; entity = await context.Incidents .FirstAsync(candidate => candidate.Fingerprint == incident.Fingerprint, cancellationToken); + if (entity.ResolvedAt is not null) + await RemoveTodoStateAsync(entity, cancellationToken); ApplyReport(entity, incident); await context.SaveChangesAsync(cancellationToken); } @@ -109,6 +113,7 @@ public async Task UpsertAsync(Incident incident, CancellationToken can if (entity.ResolvedAt is null) { + await RemoveTodoStateAsync(entity, cancellationToken); entity.ResolvedAt = resolvedAt; entity.UpdatedAt = resolvedAt; entity.LastRetryError = null; @@ -118,6 +123,18 @@ public async Task UpsertAsync(Incident incident, CancellationToken can return ToRecord(entity); } + private async Task RemoveTodoStateAsync( + IncidentEntity incident, + CancellationToken cancellationToken) + { + var key = incident.Occurrence <= 1 + ? "incident:" + incident.Id + : $"incident:{incident.Id}:{incident.Occurrence}"; + var state = await context.TodoItemStates.FindAsync([key], cancellationToken); + if (state is not null) + context.TodoItemStates.Remove(state); + } + public async Task RecordRetryAsync( Guid id, DateTimeOffset retriedAt, @@ -125,6 +142,13 @@ public async Task UpsertAsync(Incident incident, CancellationToken can bool resolve, CancellationToken cancellationToken) { + var occurrence = resolve + ? await context.Incidents + .AsNoTracking() + .Where(incident => incident.Id == id) + .Select(incident => (int?)incident.Occurrence) + .SingleOrDefaultAsync(cancellationToken) + : null; var affected = resolve ? await context.Incidents .Where(incident => incident.Id == id) @@ -144,6 +168,15 @@ public async Task UpsertAsync(Incident incident, CancellationToken can .SetProperty(incident => incident.UpdatedAt, retriedAt), cancellationToken); if (affected == 0) return null; + if (resolve && occurrence.HasValue) + { + var key = occurrence.Value <= 1 + ? "incident:" + id + : $"incident:{id}:{occurrence.Value}"; + await context.TodoItemStates + .Where(state => state.Key == key) + .ExecuteDeleteAsync(cancellationToken); + } var entity = await context.Incidents .AsNoTracking() .FirstAsync(incident => incident.Id == id, cancellationToken); diff --git a/SecondDimensionWatcherReDive/Repositories/MetadataReviewRepository.cs b/SecondDimensionWatcherReDive/Repositories/MetadataReviewRepository.cs index 233c45d6..6402a7fb 100644 --- a/SecondDimensionWatcherReDive/Repositories/MetadataReviewRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/MetadataReviewRepository.cs @@ -15,6 +15,7 @@ public async Task GetQueueAsync( MetadataReviewStatus status, int skip, int take, + Guid? focusId, CancellationToken cancellationToken) { var queueQuery = context.AnimationInfo @@ -29,6 +30,18 @@ public async Task GetQueueAsync( .Skip(skip) .Take(take) .ToListAsync(cancellationToken); + if (focusId.HasValue && entities.All(info => info.Id != focusId.Value)) + { + var focused = await context.AnimationInfo + .AsNoTracking() + .Include(info => info.Animation) + .Include(info => info.Group) + .SingleOrDefaultAsync( + info => info.Id == focusId.Value && info.MetadataStatus == status, + cancellationToken); + if (focused is not null) + entities.Insert(0, focused); + } var animationInfoIds = entities.Select(info => info.Id).ToArray(); var mappingCounts = animationInfoIds.Length == 0 @@ -332,6 +345,9 @@ await applyContext.MetadataReviewMappingSnapshots.AddRangeAsync( operation.ProposedGroupName, cancellationToken); + await applyContext.TodoItemStates + .Where(state => state.Key == "metadata:" + animationInfo.Id) + .ExecuteDeleteAsync(cancellationToken); animationInfo.Description = operation.ProposedDescription; animationInfo.Animation = animation; animationInfo.Group = group; @@ -547,6 +563,9 @@ public async Task UndoAsync( animationInfo.Id); } + await undoContext.TodoItemStates + .Where(state => state.Key == "metadata:" + animationInfo.Id) + .ExecuteDeleteAsync(cancellationToken); animationInfo.Description = operation.PreviousDescription; animationInfo.Animation = previousAnimation; animationInfo.Group = previousGroup; diff --git a/SecondDimensionWatcherReDive/Repositories/NotificationOutboxRepository.cs b/SecondDimensionWatcherReDive/Repositories/NotificationOutboxRepository.cs index 549630ab..01b4ad33 100644 --- a/SecondDimensionWatcherReDive/Repositories/NotificationOutboxRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/NotificationOutboxRepository.cs @@ -27,69 +27,69 @@ public async Task EnqueueAsync( } public async Task> ClaimDueAsync( - DateTimeOffset now, - DateTimeOffset leaseUntil, + TimeSpan leaseDuration, int take, CancellationToken cancellationToken) { - var candidateIds = await context.NotificationOutboxMessages - .AsNoTracking() - .Where(message => - (message.Status == NotificationDeliveryStatus.Pending - || message.Status == NotificationDeliveryStatus.Processing) - && message.NextAttemptAt <= now) - .OrderBy(message => message.NextAttemptAt) - .ThenBy(message => message.OccurredAt) - .Select(message => message.Id) - .Take(take) - .ToListAsync(cancellationToken); - - var claimedIds = new List(candidateIds.Count); - foreach (var id in candidateIds) - { - var affected = await context.NotificationOutboxMessages - .Where(message => message.Id == id - && (message.Status == NotificationDeliveryStatus.Pending - || message.Status == NotificationDeliveryStatus.Processing) - && message.NextAttemptAt <= now) - .ExecuteUpdateAsync(setters => setters - .SetProperty(message => message.Status, NotificationDeliveryStatus.Processing) - .SetProperty(message => message.NextAttemptAt, leaseUntil), cancellationToken); - if (affected == 1) claimedIds.Add(id); - } - - if (claimedIds.Count == 0) return []; + take = Math.Clamp(take, 1, 100); + // Claim the ordered batch in one PostgreSQL statement. SKIP LOCKED makes + // concurrent app instances cooperate without selecting the same work, + // while RETURNING gives each caller the exact lease value it owns. return (await context.NotificationOutboxMessages + .FromSqlInterpolated($$""" + WITH candidates AS ( + SELECT "Id" + FROM "NotificationOutboxMessages" + WHERE "Status" IN ('Pending', 'Processing') + AND "NextAttemptAt" <= CURRENT_TIMESTAMP + ORDER BY "NextAttemptAt", "OccurredAt", "Id" + FOR UPDATE SKIP LOCKED + LIMIT {{take}} + ), claimed AS ( + UPDATE "NotificationOutboxMessages" AS message + SET "Status" = 'Processing', + "NextAttemptAt" = CURRENT_TIMESTAMP + {{leaseDuration}} + FROM candidates + WHERE message."Id" = candidates."Id" + RETURNING message.* + ) + SELECT * FROM claimed + ORDER BY "OccurredAt", "Id" + """) .AsNoTracking() - .Where(message => claimedIds.Contains(message.Id)) - .OrderBy(message => message.OccurredAt) .ToListAsync(cancellationToken)) .Select(ToRecord) .ToList(); } - public Task MarkDeliveredAsync( + public async Task MarkDeliveredAsync( Guid id, + DateTimeOffset expectedLeaseUntil, DateTimeOffset deliveredAt, CancellationToken cancellationToken) => - context.NotificationOutboxMessages - .Where(message => message.Id == id) + await context.NotificationOutboxMessages + .Where(message => message.Id == id + && message.Status == NotificationDeliveryStatus.Processing + && message.NextAttemptAt == expectedLeaseUntil) .ExecuteUpdateAsync(setters => setters .SetProperty(message => message.Status, NotificationDeliveryStatus.Delivered) .SetProperty(message => message.AttemptCount, message => message.AttemptCount + 1) .SetProperty(message => message.LastAttemptAt, deliveredAt) .SetProperty(message => message.DeliveredAt, deliveredAt) - .SetProperty(message => message.LastError, (string?)null), cancellationToken); + .SetProperty(message => message.LastError, (string?)null), cancellationToken) == 1; - public Task MarkFailedAsync( + public async Task MarkFailedAsync( Guid id, + DateTimeOffset expectedLeaseUntil, int attemptCount, DateTimeOffset attemptedAt, DateTimeOffset? nextAttemptAt, string error, CancellationToken cancellationToken) => - context.NotificationOutboxMessages - .Where(message => message.Id == id) + await context.NotificationOutboxMessages + .Where(message => message.Id == id + && message.Status == NotificationDeliveryStatus.Processing + && message.NextAttemptAt == expectedLeaseUntil) .ExecuteUpdateAsync(setters => setters .SetProperty(message => message.Status, nextAttemptAt.HasValue @@ -98,32 +98,42 @@ public Task MarkFailedAsync( .SetProperty(message => message.AttemptCount, attemptCount) .SetProperty(message => message.LastAttemptAt, attemptedAt) .SetProperty(message => message.NextAttemptAt, nextAttemptAt ?? attemptedAt) - .SetProperty(message => message.LastError, error), cancellationToken); + .SetProperty(message => message.LastError, error), cancellationToken) == 1; - public Task RescheduleAsync( + public async Task RescheduleAsync( Guid id, + DateTimeOffset expectedLeaseUntil, DateTimeOffset nextAttemptAt, CancellationToken cancellationToken) => - context.NotificationOutboxMessages - .Where(message => message.Id == id) + await context.NotificationOutboxMessages + .Where(message => message.Id == id + && message.Status == NotificationDeliveryStatus.Processing + && message.NextAttemptAt == expectedLeaseUntil) .ExecuteUpdateAsync(setters => setters .SetProperty(message => message.Status, NotificationDeliveryStatus.Pending) - .SetProperty(message => message.NextAttemptAt, nextAttemptAt), cancellationToken); + .SetProperty(message => message.NextAttemptAt, nextAttemptAt), cancellationToken) == 1; public async Task> GetRecentAsync( int take, - CancellationToken cancellationToken) => - (await context.NotificationOutboxMessages + CancellationToken cancellationToken) + { + take = Math.Clamp(take, 1, 100); + return (await context.NotificationOutboxMessages .AsNoTracking() .OrderByDescending(message => message.OccurredAt) + .ThenByDescending(message => message.Id) .Take(take) .ToListAsync(cancellationToken)) .Select(ToRecord) .ToList(); + } private static NotificationOutboxMessage ToRecord(OutboxEntity message) => new( message.Id, + message.EventId, message.DeduplicationKey, + message.Channel, + message.WebPushSubscriptionId, message.Type, message.Title, message.Body, @@ -140,7 +150,10 @@ public async Task> GetRecentAsync( private static OutboxEntity ToEntity(NotificationOutboxMessage message) => new() { Id = message.Id, + EventId = message.EventId, DeduplicationKey = message.DeduplicationKey, + Channel = message.Channel, + WebPushSubscriptionId = message.WebPushSubscriptionId, Type = message.Type, Title = message.Title, Body = message.Body, diff --git a/SecondDimensionWatcherReDive/Repositories/TodoRepository.cs b/SecondDimensionWatcherReDive/Repositories/TodoRepository.cs index 2458902c..07e3f31f 100644 --- a/SecondDimensionWatcherReDive/Repositories/TodoRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/TodoRepository.cs @@ -11,6 +11,7 @@ public async Task GetAsync( DateTimeOffset now, int skip, int take, + string? focusKey, CancellationToken cancellationToken) { var automation = @@ -70,7 +71,7 @@ from state in candidateStates.DefaultIfEmpty() Title = incident.Title, Detail = incident.Detail, DeepLink = incident.Type == IncidentType.DiskSpaceLow - ? "/incidents?type=diskSpaceLow" + ? "/incidents?type=diskSpaceLow&focus=" + incident.Id.ToString() : "/incidents?focus=" + incident.Id.ToString(), ResourceId = incident.Id, OccurredAt = incident.UpdatedAt, @@ -126,6 +127,17 @@ from state in candidateStates.DefaultIfEmpty() .Take(take) .ToListAsync(cancellationToken); + if (focusKey is not null && rows.All(item => item.Key != focusKey)) + { + // Notification deep links must remain actionable even when the + // target is outside the current page or was already read/snoozed. + var focused = await allItems + .Where(item => item.Key == focusKey) + .SingleOrDefaultAsync(cancellationToken); + if (focused is not null) + rows.Insert(0, focused); + } + return new TodoPage( rows.Select(item => new TodoItem( item.Key, @@ -150,22 +162,123 @@ public async Task SetStateAsync( bool updateSnoozedUntil, CancellationToken cancellationToken) { - var existing = await context.TodoItemStates - .Where(state => keys.Contains(state.Key)) - .ToDictionaryAsync(state => state.Key, cancellationToken); + var validKeys = await ResolveCurrentKeysAsync(keys, cancellationToken); + if (validKeys.Length == 0) + return; + var now = DateTimeOffset.UtcNow; - foreach (var key in keys) + // One set-based upsert avoids the insert race produced when two tabs + // update a previously untouched todo at the same time. + await context.Database.ExecuteSqlInterpolatedAsync($$""" + INSERT INTO "TodoItemStates" ("Key", "ReadAt", "SnoozedUntil", "UpdatedAt") + SELECT input."Key", {{readAt}}, {{snoozedUntil}}, {{now}} + FROM unnest({{validKeys}}) AS input("Key") + ON CONFLICT ("Key") DO UPDATE SET + "ReadAt" = CASE + WHEN {{updateReadAt}} THEN EXCLUDED."ReadAt" + ELSE "TodoItemStates"."ReadAt" + END, + "SnoozedUntil" = CASE + WHEN {{updateSnoozedUntil}} THEN EXCLUDED."SnoozedUntil" + ELSE "TodoItemStates"."SnoozedUntil" + END, + "UpdatedAt" = EXCLUDED."UpdatedAt" + """, cancellationToken); + + // Mark-unread/unsnooze of an otherwise pristine item does not need a + // tombstone. Keeping the table limited to meaningful state also bounds + // joins on long-lived installations. + await context.TodoItemStates + .Where(state => validKeys.Contains(state.Key) + && state.ReadAt == null + && state.SnoozedUntil == null) + .ExecuteDeleteAsync(cancellationToken); + + var stillCurrent = await ResolveCurrentKeysAsync(validKeys, cancellationToken); + var staleKeys = validKeys.Except(stillCurrent, StringComparer.Ordinal).ToArray(); + if (staleKeys.Length > 0) + { + await context.TodoItemStates + .Where(state => staleKeys.Contains(state.Key)) + .ExecuteDeleteAsync(cancellationToken); + } + } + + private async Task ResolveCurrentKeysAsync( + IReadOnlyCollection requestedKeys, + CancellationToken cancellationToken) + { + var requested = requestedKeys.ToHashSet(StringComparer.Ordinal); + var automationIds = new HashSet(); + var metadataIds = new HashSet(); + var incidentIds = new HashSet(); + foreach (var key in requested) { - if (!existing.TryGetValue(key, out var state)) + var parts = key.Split(':'); + if (parts.Length < 2 || !Guid.TryParse(parts[1], out var id)) + continue; + switch (parts[0]) { - state = new Models.TodoItemState { Key = key }; - await context.TodoItemStates.AddAsync(state, cancellationToken); + case "automation": + automationIds.Add(id); + break; + case "metadata": + metadataIds.Add(id); + break; + case "incident": + incidentIds.Add(id); + break; } - if (updateReadAt) state.ReadAt = readAt; - if (updateSnoozedUntil) state.SnoozedUntil = snoozedUntil; - state.UpdatedAt = now; } - await context.SaveChangesAsync(cancellationToken); + + var valid = new HashSet(StringComparer.Ordinal); + if (automationIds.Count > 0) + { + var ids = await context.AnimationInfo + .AsNoTracking() + .Where(info => automationIds.Contains(info.Id) + && (info.AutomationDisposition == SubscriptionAutomationDisposition.Notified + || info.AutomationDisposition == SubscriptionAutomationDisposition.PendingConfirmation + || info.AutomationDisposition == SubscriptionAutomationDisposition.AutoDownloadFailed)) + .Select(info => info.Id) + .ToListAsync(cancellationToken); + foreach (var id in ids) + valid.Add("automation:" + id); + } + + if (metadataIds.Count > 0) + { + var ids = await context.AnimationInfo + .AsNoTracking() + .Where(info => metadataIds.Contains(info.Id) + && (info.MetadataStatus == MetadataReviewStatus.LowConfidence + || info.MetadataStatus == MetadataReviewStatus.Failed)) + .Select(info => info.Id) + .ToListAsync(cancellationToken); + foreach (var id in ids) + valid.Add("metadata:" + id); + } + + if (incidentIds.Count > 0) + { + var incidents = await context.Incidents + .AsNoTracking() + .Where(incident => incidentIds.Contains(incident.Id) + && incident.ResolvedAt == null) + .Select(incident => new { incident.Id, incident.Occurrence }) + .ToListAsync(cancellationToken); + foreach (var incident in incidents) + { + valid.Add(incident.Occurrence <= 1 + ? "incident:" + incident.Id + : $"incident:{incident.Id}:{incident.Occurrence}"); + } + } + + return valid + .Where(requested.Contains) + .Order(StringComparer.Ordinal) + .ToArray(); } private sealed class TodoQueryRow diff --git a/SecondDimensionWatcherReDive/Repositories/TodoRepositoryPostgreSqlTestFixture.cs b/SecondDimensionWatcherReDive/Repositories/TodoRepositoryPostgreSqlTestFixture.cs deleted file mode 100644 index 42695b06..00000000 --- a/SecondDimensionWatcherReDive/Repositories/TodoRepositoryPostgreSqlTestFixture.cs +++ /dev/null @@ -1,107 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using SecondDimensionWatcherReDive.Framework.DataRepository; - -namespace SecondDimensionWatcherReDive.Repositories; - -/// -/// Owns PostgreSQL setup and seed data for todo/incident repository integration -/// tests without exposing the EF context outside the repository boundary. -/// -internal sealed class TodoRepositoryPostgreSqlTestFixture(string connectionString) -{ - private readonly DbContextOptions _contextOptions = - new DbContextOptionsBuilder() - .UseNpgsql(connectionString) - .Options; - - public async Task ResetAsync(CancellationToken cancellationToken) - { - await using var context = new Models.ApplicationContext(_contextOptions); - await context.Database.ExecuteSqlRawAsync( - "TRUNCATE TABLE \"TodoItemStates\", \"Incidents\", \"AnimationInfo\" RESTART IDENTITY CASCADE", - cancellationToken); - } - - public async Task SeedAnimationInfoAsync( - string title, - DateTimeOffset publishTime, - SubscriptionAutomationDisposition? automationDisposition, - MetadataReviewStatus metadataStatus, - CancellationToken cancellationToken) - { - await using var context = new Models.ApplicationContext(_contextOptions); - var entity = new Models.AnimationInfo - { - Id = Guid.NewGuid(), - Title = title, - PublishTime = publishTime, - AutomationDisposition = automationDisposition, - MetadataStatus = metadataStatus - }; - await context.AnimationInfo.AddAsync(entity, cancellationToken); - await context.SaveChangesAsync(cancellationToken); - return entity.Id; - } - - public async Task UpsertIncidentAsync( - Incident incident, - CancellationToken cancellationToken) - { - await using var context = new Models.ApplicationContext(_contextOptions); - return await new IncidentRepository(context).UpsertAsync(incident, cancellationToken); - } - - public async Task ResolveIncidentAsync( - string fingerprint, - DateTimeOffset resolvedAt, - CancellationToken cancellationToken) - { - await using var context = new Models.ApplicationContext(_contextOptions); - return await new IncidentRepository(context).ResolveByFingerprintAsync( - fingerprint, - resolvedAt, - cancellationToken); - } - - public async Task GetTodosAsync( - bool includeRead, - bool includeSnoozed, - DateTimeOffset now, - int skip, - int take, - CancellationToken cancellationToken) - { - await using var context = new Models.ApplicationContext(_contextOptions); - return await new TodoRepository(context).GetAsync( - includeRead, - includeSnoozed, - now, - skip, - take, - cancellationToken); - } - - public async Task SetTodoStateAsync( - IReadOnlyCollection keys, - DateTimeOffset? readAt, - bool updateReadAt, - DateTimeOffset? snoozedUntil, - bool updateSnoozedUntil, - CancellationToken cancellationToken) - { - await using var context = new Models.ApplicationContext(_contextOptions); - await new TodoRepository(context).SetStateAsync( - keys, - readAt, - updateReadAt, - snoozedUntil, - updateSnoozedUntil, - cancellationToken); - } - - public async Task GetTodoStateCountAsync(CancellationToken cancellationToken) - { - await using var context = new Models.ApplicationContext(_contextOptions); - return await context.TodoItemStates.CountAsync(cancellationToken); - } -} diff --git a/SecondDimensionWatcherReDive/Repositories/WebPushSubscriptionRepository.cs b/SecondDimensionWatcherReDive/Repositories/WebPushSubscriptionRepository.cs new file mode 100644 index 00000000..04197d55 --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/WebPushSubscriptionRepository.cs @@ -0,0 +1,162 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using SubscriptionEntity = SecondDimensionWatcherReDive.Models.WebPushSubscription; + +namespace SecondDimensionWatcherReDive.Repositories; + +public sealed class WebPushSubscriptionRepository : + Framework.DataRepository.IWebPushSubscriptionRepository +{ + public const int MaximumSubscriptions = 50; + private const string ProtectorPurpose = + "SecondDimensionWatcherReDive.WebPushSubscriptions.v1"; + + private readonly Models.ApplicationContext _context; + private readonly DbContextOptions _contextOptions; + private readonly IDataProtector _protector; + + public WebPushSubscriptionRepository( + Models.ApplicationContext context, + DbContextOptions contextOptions, + IDataProtectionProvider dataProtectionProvider) + { + _context = context; + _contextOptions = contextOptions; + _protector = dataProtectionProvider.CreateProtector(ProtectorPurpose); + } + + public async Task UpsertAsync( + Framework.DataRepository.WebPushSubscription subscription, + CancellationToken cancellationToken) + { + var strategy = _context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var writeContext = new Models.ApplicationContext(_contextOptions); + await using var transaction = await writeContext.Database + .BeginTransactionAsync(cancellationToken); + // Serialize registration/count changes across app replicas so the + // global subscription cap cannot be bypassed with parallel requests. + await writeContext.Database.ExecuteSqlRawAsync( + "SELECT pg_advisory_xact_lock(1396983639)", + cancellationToken); + + var endpointHash = HashEndpoint(subscription.Endpoint); + var entity = await writeContext.WebPushSubscriptions + .SingleOrDefaultAsync( + item => item.EndpointHash == endpointHash, + cancellationToken); + if (entity is null) + { + if (await writeContext.WebPushSubscriptions.CountAsync(cancellationToken) + >= MaximumSubscriptions) + throw new InvalidOperationException( + $"At most {MaximumSubscriptions} Web Push subscriptions are allowed."); + + entity = new SubscriptionEntity + { + Id = subscription.Id, + EndpointHash = endpointHash, + CreatedAt = subscription.CreatedAt + }; + Apply(entity, subscription); + await writeContext.WebPushSubscriptions.AddAsync(entity, cancellationToken); + } + else + { + Apply(entity, subscription); + } + + await writeContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return ToRecord(entity); + }); + } + + public async Task FindByIdAsync( + Guid id, + CancellationToken cancellationToken) + { + var entity = await _context.WebPushSubscriptions + .AsNoTracking() + .SingleOrDefaultAsync(item => item.Id == id, cancellationToken); + return entity is null ? null : ToRecord(entity); + } + + public async Task> GetAllAsync( + CancellationToken cancellationToken) => + (await _context.WebPushSubscriptions + .AsNoTracking() + .OrderByDescending(item => item.UpdatedAt) + .ThenBy(item => item.Id) + .ToListAsync(cancellationToken)) + .Select(ToRecord) + .ToList(); + + public async Task RemoveAsync(Guid id, CancellationToken cancellationToken) => + await _context.WebPushSubscriptions + .Where(item => item.Id == id) + .ExecuteDeleteAsync(cancellationToken) == 1; + + public async Task RemoveByEndpointAsync( + string endpoint, + CancellationToken cancellationToken) => + await _context.WebPushSubscriptions + .Where(item => item.EndpointHash == HashEndpoint(endpoint)) + .ExecuteDeleteAsync(cancellationToken) == 1; + + public async Task RecordSuccessAsync( + Guid id, + DateTimeOffset succeededAt, + CancellationToken cancellationToken) + { + await _context.WebPushSubscriptions + .Where(item => item.Id == id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(item => item.LastSuccessAt, succeededAt) + .SetProperty(item => item.LastError, (string?)null), cancellationToken); + } + + public async Task RecordFailureAsync( + Guid id, + DateTimeOffset failedAt, + string error, + CancellationToken cancellationToken) + { + var safeError = error.Length <= 256 ? error : error[..256]; + await _context.WebPushSubscriptions + .Where(item => item.Id == id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(item => item.LastFailureAt, failedAt) + .SetProperty(item => item.LastError, safeError), cancellationToken); + } + + private void Apply( + SubscriptionEntity entity, + Framework.DataRepository.WebPushSubscription subscription) + { + entity.ProtectedEndpoint = _protector.Protect(subscription.Endpoint); + entity.ProtectedP256Dh = _protector.Protect(subscription.P256Dh); + entity.ProtectedAuth = _protector.Protect(subscription.Auth); + entity.UpdatedAt = subscription.UpdatedAt; + entity.LastError = null; + } + + private Framework.DataRepository.WebPushSubscription ToRecord( + SubscriptionEntity entity) => new( + entity.Id, + _protector.Unprotect(entity.ProtectedEndpoint), + _protector.Unprotect(entity.ProtectedP256Dh), + _protector.Unprotect(entity.ProtectedAuth), + entity.CreatedAt, + entity.UpdatedAt, + entity.LastSuccessAt, + entity.LastFailureAt, + entity.LastError); + + private static string HashEndpoint(string endpoint) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(endpoint))) + .ToLowerInvariant(); +} diff --git a/SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj b/SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj index 83fbd05e..ca82c6a9 100644 --- a/SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj +++ b/SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj @@ -33,6 +33,7 @@ + diff --git a/SecondDimensionWatcherReDive/Services/FetchRemoteTorrentBackgroundService.cs b/SecondDimensionWatcherReDive/Services/FetchRemoteTorrentBackgroundService.cs index d587b9a1..2d9e7909 100644 --- a/SecondDimensionWatcherReDive/Services/FetchRemoteTorrentBackgroundService.cs +++ b/SecondDimensionWatcherReDive/Services/FetchRemoteTorrentBackgroundService.cs @@ -4,6 +4,7 @@ using SecondDimensionWatcherReDive.Data; using SecondDimensionWatcherReDive.Framework.FileStore; using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.Utils.FileDownload; using SecondDimensionWatcherReDive.Utils.Incidents; @@ -17,7 +18,8 @@ public partial class FetchRemoteTorrentBackgroundService( IServiceScopeFactory scopeFactory, ILogger logger, IConfiguration configuration, - IIncidentReporter? incidentReporter = null) + IIncidentReporter? incidentReporter = null, + INotificationPublisher? notificationPublisher = null) : BackgroundService { private sealed record DownloadObservation( @@ -200,6 +202,15 @@ await ReportDownloadIncidentAsync( request.ItemId, $"The remote download client reports state '{torrentInfo.State}'.", cancellationToken); + if (notificationPublisher is not null) + { + await notificationPublisher.PublishAsync(new NotificationEvent( + NotificationEventType.DownloadFailed, + $"download-failed:{request.ItemId}:{request.DownloadAttemptId?.ToString() ?? "legacy"}", + "Download failed", + "The remote download client reported an error.", + "/downloading"), cancellationToken); + } observations[torrentInfo.Hash] = observation with { LastReportedAt = now, diff --git a/SecondDimensionWatcherReDive/Services/SyncFeed.cs b/SecondDimensionWatcherReDive/Services/SyncFeed.cs index 63dd726c..01c51af1 100644 --- a/SecondDimensionWatcherReDive/Services/SyncFeed.cs +++ b/SecondDimensionWatcherReDive/Services/SyncFeed.cs @@ -227,12 +227,22 @@ await incidentReporter.ResolveAsync( cancellationToken); if (!started && notificationPublisher is not null) { - await notificationPublisher.PublishAsync(new NotificationEvent( - NotificationEventType.DownloadFailed, - $"auto-download-failed:{info.Id}", - "Automatic download failed", - info.Title, - $"/todo?focus=automation:{info.Id}"), cancellationToken); + // A failed compensation can leave the remote attempt + // durably tracked for startup recovery. Only announce a + // terminal failure once the database confirms that state. + var failed = await animationInfoRepository.FindByIdAsync( + info.Id, + cancellationToken); + if (failed?.AutomationDisposition == + SubscriptionAutomationDisposition.AutoDownloadFailed) + { + await notificationPublisher.PublishAsync(new NotificationEvent( + NotificationEventType.DownloadFailed, + $"auto-download-failed:{info.Id}", + "Automatic download failed", + info.Title, + $"/todo?focus=automation:{info.Id}"), cancellationToken); + } } } } diff --git a/SecondDimensionWatcherReDive/Utils/Incidents/IncidentReporter.cs b/SecondDimensionWatcherReDive/Utils/Incidents/IncidentReporter.cs index aef0db80..11b231d6 100644 --- a/SecondDimensionWatcherReDive/Utils/Incidents/IncidentReporter.cs +++ b/SecondDimensionWatcherReDive/Utils/Incidents/IncidentReporter.cs @@ -58,7 +58,7 @@ await notificationPublisher.PublishAsync(new NotificationEvent( saved.Title, saved.Detail, isDiskSpaceLow - ? "/incidents?type=diskSpaceLow" + ? $"/incidents?type=diskSpaceLow&focus={saved.Id}" : $"/incidents?focus={saved.Id}"), cancellationToken); } return saved; diff --git a/SecondDimensionWatcherReDive/Utils/Notifications/NotificationDeliveryBackgroundService.cs b/SecondDimensionWatcherReDive/Utils/Notifications/NotificationDeliveryBackgroundService.cs index d5bb944e..1d4082c2 100644 --- a/SecondDimensionWatcherReDive/Utils/Notifications/NotificationDeliveryBackgroundService.cs +++ b/SecondDimensionWatcherReDive/Utils/Notifications/NotificationDeliveryBackgroundService.cs @@ -2,6 +2,8 @@ using System.Text; using System.Text.Json; using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Utils.Http; +using WebPush; namespace SecondDimensionWatcherReDive.Utils.Notifications; @@ -13,8 +15,11 @@ public sealed partial class NotificationDeliveryBackgroundService( { private const int MaxAttempts = 8; private const int BatchSize = 20; + // Keep the serialized plaintext comfortably below the Web Push record limit; + // encryption metadata and padding also consume bytes in the 4096-byte record. + private const int MaxWebPushPayloadBytes = 3000; private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2); - private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(2); + private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(3); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -40,61 +45,134 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } internal async Task DeliverBatchAsync(CancellationToken cancellationToken) + { + IReadOnlyList messages; + await using (var claimScope = scopeFactory.CreateAsyncScope()) + { + var repository = claimScope.ServiceProvider + .GetRequiredService(); + messages = await repository.ClaimDueAsync( + LeaseDuration, BatchSize, cancellationToken); + } + + // Start every claimed delivery immediately. The named HttpClient bounds + // sockets and the per-request deadline includes handler queueing, so the + // complete batch finishes inside its lease. Each task needs its own scoped + // repository because DbContext is not safe for concurrent use. + await Task.WhenAll(messages.Select(message => + DeliverClaimedAsync(message, cancellationToken))); + return messages.Count; + } + + private async Task DeliverClaimedAsync( + NotificationOutboxMessage message, + CancellationToken cancellationToken) { await using var scope = scopeFactory.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); - var now = DateTimeOffset.UtcNow; - var messages = await repository.ClaimDueAsync( - now, now + LeaseDuration, BatchSize, cancellationToken); - foreach (var message in messages) - await DeliverAsync(repository, message, cancellationToken); - return messages.Count; + var subscriptions = scope.ServiceProvider + .GetRequiredService(); + await DeliverAsync(repository, subscriptions, message, cancellationToken); } private async Task DeliverAsync( INotificationOutboxRepository repository, + IWebPushSubscriptionRepository subscriptions, NotificationOutboxMessage message, CancellationToken cancellationToken) { var now = DateTimeOffset.UtcNow; - if (IsQuietHours(now)) + if (message.Type != Framework.Notifications.NotificationEventType.Test + && IsQuietHours(now)) { - await repository.RescheduleAsync(message.Id, now.AddMinutes(15), cancellationToken); + await repository.RescheduleAsync( + message.Id, message.NextAttemptAt, now.AddMinutes(15), cancellationToken); return; } + switch (message.Channel) + { + case NotificationChannel.Webhook: + await DeliverWebhookAsync(repository, message, now, cancellationToken); + break; + case NotificationChannel.WebPush: + await DeliverWebPushAsync( + repository, + subscriptions, + message, + now, + cancellationToken); + break; + default: + await repository.MarkFailedAsync( + message.Id, + message.NextAttemptAt, + message.AttemptCount + 1, + now, + null, + "UnsupportedChannel", + cancellationToken); + break; + } + } + + private async Task DeliverWebhookAsync( + INotificationOutboxRepository repository, + NotificationOutboxMessage message, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var endpoint = configuration["Notifications:Webhook:Url"]; if (!configuration.GetValue("Notifications:Webhook:Enabled") || string.IsNullOrWhiteSpace(endpoint)) { - await repository.RescheduleAsync(message.Id, now.AddMinutes(5), cancellationToken); + await repository.RescheduleAsync( + message.Id, message.NextAttemptAt, now.AddMinutes(5), cancellationToken); return; } var attempt = message.AttemptCount + 1; try { - using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); - request.Headers.TryAddWithoutValidation("X-SDW-Event-Id", message.Id.ToString("D")); + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri)) + throw new OutboundRequestBlockedException("The webhook URL is invalid."); + OutboundAddressPolicy.ValidateUriShape(endpointUri); + if (endpointUri.Scheme == Uri.UriSchemeHttp && !endpointUri.IsLoopback) + throw new OutboundRequestBlockedException( + "Plain HTTP is allowed only for loopback webhook endpoints."); + + using var request = new HttpRequestMessage(HttpMethod.Post, endpointUri); + request.Headers.TryAddWithoutValidation( + "X-SDW-Event-Id", + message.EventId.ToString("D")); request.Content = new StringContent(CreatePayload(message), Encoding.UTF8, "application/json"); using var response = await httpClientFactory.CreateClient("NotificationWebhook") .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (response.IsSuccessStatusCode) { - await repository.MarkDeliveredAsync(message.Id, now, cancellationToken); - LogDelivered(logger, message.Id, message.Type); + await repository.MarkDeliveredAsync( + message.Id, message.NextAttemptAt, now, cancellationToken); + LogDelivered(logger, message.EventId, message.Channel, message.Type); return; } var retry = IsRetryable(response.StatusCode) && attempt < MaxAttempts; await repository.MarkFailedAsync( message.Id, + message.NextAttemptAt, attempt, now, retry ? now + RetryDelay(attempt) : null, $"HTTP {(int)response.StatusCode}", cancellationToken); - LogDeliveryRejected(logger, message.Id, message.Type, (int)response.StatusCode, retry); + LogDeliveryRejected( + logger, + message.EventId, + message.Channel, + message.Type, + (int)response.StatusCode, + retry); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -105,6 +183,7 @@ await repository.MarkFailedAsync( var retry = attempt < MaxAttempts; await repository.MarkFailedAsync( message.Id, + message.NextAttemptAt, attempt, now, retry ? now + RetryDelay(attempt) : null, @@ -112,7 +191,212 @@ await repository.MarkFailedAsync( cancellationToken); // HttpClient exception text can contain the request URI. The webhook URL may // carry an access token, so only log the exception type here. - LogDeliveryFailed(logger, message.Id, message.Type, exception.GetType().Name, retry); + LogDeliveryFailed( + logger, + message.EventId, + message.Channel, + message.Type, + exception.GetType().Name, + retry); + } + } + + private async Task DeliverWebPushAsync( + INotificationOutboxRepository repository, + IWebPushSubscriptionRepository subscriptions, + NotificationOutboxMessage message, + DateTimeOffset now, + CancellationToken cancellationToken) + { + if (!configuration.GetValue("Notifications:WebPush:Enabled")) + { + await repository.RescheduleAsync( + message.Id, message.NextAttemptAt, now.AddMinutes(5), cancellationToken); + return; + } + + var attempt = message.AttemptCount + 1; + try + { + if (!message.WebPushSubscriptionId.HasValue) + throw new InvalidDataException("The Web Push target is missing."); + var subscription = await subscriptions.FindByIdAsync( + message.WebPushSubscriptionId.Value, + cancellationToken); + if (subscription is null) + { + await repository.MarkFailedAsync( + message.Id, + message.NextAttemptAt, + attempt, + now, + null, + "SubscriptionRemoved", + cancellationToken); + return; + } + + var subject = configuration["Notifications:WebPush:Subject"]; + var publicKey = configuration["Notifications:WebPush:VapidPublicKey"]; + var privateKey = configuration["Notifications:WebPush:VapidPrivateKey"]; + if (string.IsNullOrWhiteSpace(subject) + || string.IsNullOrWhiteSpace(publicKey) + || string.IsNullOrWhiteSpace(privateKey)) + { + await repository.RescheduleAsync( + message.Id, + message.NextAttemptAt, + now.AddMinutes(5), + cancellationToken); + return; + } + + using var client = new WebPushClient(httpClientFactory.CreateClient("WebPush")); + await client.SendNotificationAsync( + new PushSubscription(subscription.Endpoint, subscription.P256Dh, subscription.Auth), + CreateWebPushPayload(message), + new VapidDetails(subject, publicKey, privateKey), + cancellationToken); + if (await repository.MarkDeliveredAsync( + message.Id, + message.NextAttemptAt, + now, + cancellationToken)) + await TryRecordSubscriptionSuccessAsync( + subscriptions, + subscription.Id, + now, + cancellationToken); + LogDelivered(logger, message.EventId, message.Channel, message.Type); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (WebPushException exception) + { + var expired = exception.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone; + var retry = !expired && IsRetryable(exception.StatusCode) && attempt < MaxAttempts; + var error = expired ? "SubscriptionExpired" : $"HTTP {(int)exception.StatusCode}"; + await repository.MarkFailedAsync( + message.Id, + message.NextAttemptAt, + attempt, + now, + retry ? now + RetryDelay(attempt) : null, + error, + cancellationToken); + if (message.WebPushSubscriptionId.HasValue) + { + if (expired) + await TryRemoveSubscriptionAsync( + subscriptions, + message.WebPushSubscriptionId.Value, + cancellationToken); + else + await TryRecordSubscriptionFailureAsync( + subscriptions, + message.WebPushSubscriptionId.Value, + now, + error, + cancellationToken); + } + LogDeliveryRejected( + logger, + message.EventId, + message.Channel, + message.Type, + (int)exception.StatusCode, + retry); + } + catch (Exception exception) + { + var retry = attempt < MaxAttempts; + var error = exception.GetType().Name; + await repository.MarkFailedAsync( + message.Id, + message.NextAttemptAt, + attempt, + now, + retry ? now + RetryDelay(attempt) : null, + error, + cancellationToken); + if (message.WebPushSubscriptionId.HasValue) + await TryRecordSubscriptionFailureAsync( + subscriptions, + message.WebPushSubscriptionId.Value, + now, + error, + cancellationToken); + // WebPushException and HttpClient messages can include the capability + // endpoint. Persist and log only a bounded type/status token. + LogDeliveryFailed( + logger, + message.EventId, + message.Channel, + message.Type, + error, + retry); + } + } + + private async Task TryRecordSubscriptionSuccessAsync( + IWebPushSubscriptionRepository subscriptions, + Guid id, + DateTimeOffset succeededAt, + CancellationToken cancellationToken) + { + try + { + await subscriptions.RecordSuccessAsync(id, succeededAt, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + LogSubscriptionStateUpdateFailed(logger, id, exception.GetType().Name); + } + } + + private async Task TryRecordSubscriptionFailureAsync( + IWebPushSubscriptionRepository subscriptions, + Guid id, + DateTimeOffset failedAt, + string error, + CancellationToken cancellationToken) + { + try + { + await subscriptions.RecordFailureAsync(id, failedAt, error, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + LogSubscriptionStateUpdateFailed(logger, id, exception.GetType().Name); + } + } + + private async Task TryRemoveSubscriptionAsync( + IWebPushSubscriptionRepository subscriptions, + Guid id, + CancellationToken cancellationToken) + { + try + { + await subscriptions.RemoveAsync(id, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + LogSubscriptionStateUpdateFailed(logger, id, exception.GetType().Name); } } @@ -127,7 +411,7 @@ private static string CreatePayload(NotificationOutboxMessage message) return JsonSerializer.Serialize(new { - eventId = message.Id, + eventId = message.EventId, type = char.ToLowerInvariant(message.Type.ToString()[0]) + message.Type.ToString()[1..], message.Title, message.Body, @@ -137,6 +421,96 @@ private static string CreatePayload(NotificationOutboxMessage message) }, JsonOptions); } + private static string CreateWebPushPayload(NotificationOutboxMessage message) + { + var title = LimitUtf8(message.Title, 256); + var body = LimitUtf8(message.Body, 1024); + var deepLink = LimitUtf8(message.DeepLink, 1024); + var serialized = SerializeWebPushPayload(message, title, body, deepLink); + if (Encoding.UTF8.GetByteCount(serialized) <= MaxWebPushPayloadBytes) + return serialized; + + // JSON escaping can expand a single input rune to several bytes. Fit the + // actual serialized payload, preserving the click target until after the + // visible text has been reduced. + body = FitWebPushField( + body, + candidate => SerializeWebPushPayload(message, title, candidate, deepLink)); + serialized = SerializeWebPushPayload(message, title, body, deepLink); + if (Encoding.UTF8.GetByteCount(serialized) <= MaxWebPushPayloadBytes) + return serialized; + + title = FitWebPushField( + title, + candidate => SerializeWebPushPayload(message, candidate, body, deepLink)); + serialized = SerializeWebPushPayload(message, title, body, deepLink); + if (Encoding.UTF8.GetByteCount(serialized) <= MaxWebPushPayloadBytes) + return serialized; + + deepLink = FitWebPushField( + deepLink, + candidate => SerializeWebPushPayload(message, title, body, candidate)); + return SerializeWebPushPayload(message, title, body, deepLink); + } + + private static string SerializeWebPushPayload( + NotificationOutboxMessage message, + string title, + string body, + string deepLink) => + JsonSerializer.Serialize(new + { + eventId = message.EventId, + type = char.ToLowerInvariant(message.Type.ToString()[0]) + message.Type.ToString()[1..], + title, + body, + deepLink, + message.OccurredAt + }, JsonOptions); + + private static string FitWebPushField( + string value, + Func serialize) + { + var offsets = new List { 0 }; + var offset = 0; + foreach (var rune in value.EnumerateRunes()) + { + offset += rune.Utf16SequenceLength; + offsets.Add(offset); + } + + var lower = 0; + var upper = offsets.Count - 1; + while (lower < upper) + { + var candidateLength = lower + (upper - lower + 1) / 2; + var candidate = value[..offsets[candidateLength]]; + if (Encoding.UTF8.GetByteCount(serialize(candidate)) <= MaxWebPushPayloadBytes) + lower = candidateLength; + else + upper = candidateLength - 1; + } + return value[..offsets[lower]]; + } + + private static string LimitUtf8(string value, int maximumBytes) + { + if (Encoding.UTF8.GetByteCount(value) <= maximumBytes) + return value; + var builder = new StringBuilder(value.Length); + var bytes = 0; + foreach (var rune in value.EnumerateRunes()) + { + var runeBytes = rune.Utf8SequenceLength; + if (bytes + runeBytes > maximumBytes) + break; + builder.Append(rune.ToString()); + bytes += runeBytes; + } + return builder.ToString(); + } + private bool IsQuietHours(DateTimeOffset now) { var start = configuration.GetValue("Notifications:QuietHours:Start"); @@ -175,27 +549,37 @@ private static TimeSpan RetryDelay(int attempt) => private static partial void LogBatchFailed(ILogger logger, Exception exception); [LoggerMessage(Level = LogLevel.Information, - Message = "Delivered notification {NotificationId} ({NotificationType})")] + Message = "Delivered notification {NotificationId} via {NotificationChannel} ({NotificationType})")] private static partial void LogDelivered( ILogger logger, Guid notificationId, + NotificationChannel notificationChannel, Framework.Notifications.NotificationEventType notificationType); [LoggerMessage(Level = LogLevel.Warning, - Message = "Webhook rejected notification {NotificationId} ({NotificationType}) with HTTP {StatusCode}; retry={Retry}")] + Message = "Notification channel {NotificationChannel} rejected {NotificationId} ({NotificationType}) with HTTP {StatusCode}; retry={Retry}")] private static partial void LogDeliveryRejected( ILogger logger, Guid notificationId, + NotificationChannel notificationChannel, Framework.Notifications.NotificationEventType notificationType, int statusCode, bool retry); [LoggerMessage(Level = LogLevel.Warning, - Message = "Failed to deliver notification {NotificationId} ({NotificationType}) with {ErrorType}; retry={Retry}")] + Message = "Failed to deliver notification {NotificationId} via {NotificationChannel} ({NotificationType}) with {ErrorType}; retry={Retry}")] private static partial void LogDeliveryFailed( ILogger logger, Guid notificationId, + NotificationChannel notificationChannel, Framework.Notifications.NotificationEventType notificationType, string errorType, bool retry); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Failed to update Web Push subscription {SubscriptionId} state with {ErrorType}")] + private static partial void LogSubscriptionStateUpdateFailed( + ILogger logger, + Guid subscriptionId, + string errorType); } diff --git a/SecondDimensionWatcherReDive/Utils/Notifications/NotificationPublisher.cs b/SecondDimensionWatcherReDive/Utils/Notifications/NotificationPublisher.cs index a878c021..2bdb308c 100644 --- a/SecondDimensionWatcherReDive/Utils/Notifications/NotificationPublisher.cs +++ b/SecondDimensionWatcherReDive/Utils/Notifications/NotificationPublisher.cs @@ -1,3 +1,6 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.Notifications; @@ -8,41 +11,128 @@ public sealed partial class NotificationPublisher( IConfiguration configuration, ILogger logger) : INotificationPublisher { - public async Task PublishAsync( + private const int MaxDeduplicationKeyLength = 256; + private const int MaxPayloadBytes = 64 * 1024; + + public async Task PublishAsync( NotificationEvent notificationEvent, CancellationToken cancellationToken) { - if (!configuration.GetValue("Notifications:Webhook:Enabled")) - return; + var webhookEnabled = configuration.GetValue("Notifications:Webhook:Enabled"); + var webPushEnabled = configuration.GetValue("Notifications:WebPush:Enabled"); + if (!webhookEnabled && !webPushEnabled) + return false; if (notificationEvent.Type != NotificationEventType.Test && !SubscribedEvents(configuration["Notifications:Events"]) .Contains(notificationEvent.Type)) - return; - - var occurredAt = notificationEvent.OccurredAt ?? DateTimeOffset.UtcNow; - var message = new NotificationOutboxMessage( - notificationEvent.Id ?? Guid.NewGuid(), - notificationEvent.DeduplicationKey, - notificationEvent.Type, - Limit(notificationEvent.Title, 256), - Limit(notificationEvent.Body, 2048), - Limit(notificationEvent.DeepLink, 2048), - notificationEvent.PayloadJson, - occurredAt, - NotificationDeliveryStatus.Pending, - 0, - occurredAt, - null, - null, - null); + return false; try { - await using var scope = scopeFactory.CreateAsyncScope(); - var repository = scope.ServiceProvider - .GetRequiredService(); - if (!await repository.EnqueueAsync(message, cancellationToken)) + var eventId = notificationEvent.Id ?? Guid.NewGuid(); + var occurredAt = notificationEvent.OccurredAt ?? DateTimeOffset.UtcNow; + var title = Limit(notificationEvent.Title, 256); + var body = Limit(notificationEvent.Body, 2048); + var deepLink = Limit(notificationEvent.DeepLink, 2048); + var payload = NormalizePayload(notificationEvent.PayloadJson); + var baseDeduplicationKey = NormalizeDeduplicationKey( + notificationEvent.DeduplicationKey, + notificationEvent.Type, + eventId); + + var enqueued = false; + var hasAssignedEventId = false; + + if (webhookEnabled) + { + enqueued |= await TryEnqueueChannelAsync( + async () => + { + await using var webhookScope = scopeFactory.CreateAsyncScope(); + var outbox = webhookScope.ServiceProvider + .GetRequiredService(); + return await EnqueueTargetAsync( + outbox, + new NotificationOutboxMessage( + eventId, + eventId, + NormalizeTargetDeduplicationKey( + baseDeduplicationKey, + NotificationChannel.Webhook, + null), + NotificationChannel.Webhook, + null, + notificationEvent.Type, + title, + body, + deepLink, + payload, + occurredAt, + NotificationDeliveryStatus.Pending, + 0, + occurredAt, + null, + null, + null), + cancellationToken); + }, + notificationEvent.Type, + cancellationToken); + hasAssignedEventId = true; + } + + if (webPushEnabled) + { + var eventIdAlreadyAssigned = hasAssignedEventId; + enqueued |= await TryEnqueueChannelAsync( + async () => + { + await using var webPushScope = scopeFactory.CreateAsyncScope(); + var outbox = webPushScope.ServiceProvider + .GetRequiredService(); + var subscriptionRepository = webPushScope.ServiceProvider + .GetRequiredService(); + var subscriptions = await subscriptionRepository + .GetAllAsync(cancellationToken); + var any = false; + foreach (var subscription in subscriptions) + { + var targetDeduplicationKey = NormalizeTargetDeduplicationKey( + baseDeduplicationKey, + NotificationChannel.WebPush, + subscription.Id); + any |= await EnqueueTargetAsync( + outbox, + new NotificationOutboxMessage( + eventIdAlreadyAssigned ? Guid.NewGuid() : eventId, + eventId, + targetDeduplicationKey, + NotificationChannel.WebPush, + subscription.Id, + notificationEvent.Type, + title, + body, + deepLink, + payload, + occurredAt, + NotificationDeliveryStatus.Pending, + 0, + occurredAt, + null, + null, + null), + cancellationToken); + eventIdAlreadyAssigned = true; + } + return any; + }, + notificationEvent.Type, + cancellationToken); + } + + if (!enqueued) LogDuplicateSkipped(logger, notificationEvent.Type); + return enqueued; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -52,6 +142,33 @@ public async Task PublishAsync( { // Notification persistence is deliberately isolated from the core operation. LogEnqueueFailed(logger, exception, notificationEvent.Type); + return false; + } + } + + private static async Task EnqueueTargetAsync( + INotificationOutboxRepository repository, + NotificationOutboxMessage message, + CancellationToken cancellationToken) => + await repository.EnqueueAsync(message, cancellationToken); + + private async Task TryEnqueueChannelAsync( + Func> enqueue, + NotificationEventType notificationType, + CancellationToken cancellationToken) + { + try + { + return await enqueue(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + LogEnqueueFailed(logger, exception, notificationType); + return false; } } @@ -70,7 +187,72 @@ internal static IReadOnlySet SubscribedEvents(string? val private static string Limit(string value, int maxLength) { var normalized = string.IsNullOrWhiteSpace(value) ? "Notification" : value.Trim(); - return normalized.Length <= maxLength ? normalized : normalized[..maxLength]; + return normalized.Length <= maxLength + ? normalized + : TruncateUtf16Safely(normalized, maxLength); + } + + private static string NormalizeDeduplicationKey( + string value, + NotificationEventType type, + Guid id) + { + var normalized = value.Trim(); + if (normalized.Length == 0) + normalized = $"{type.ToString().ToLowerInvariant()}:{id:D}"; + return BoundDeduplicationKey(normalized); + } + + private static string NormalizeTargetDeduplicationKey( + string baseDeduplicationKey, + NotificationChannel channel, + Guid? subscriptionId) + { + var targetPrefix = channel switch + { + NotificationChannel.Webhook => "webhook", + NotificationChannel.WebPush when subscriptionId.HasValue => + $"web-push:{subscriptionId.Value:D}", + _ => throw new ArgumentException("A valid notification target is required.", nameof(channel)) + }; + return BoundDeduplicationKey($"{targetPrefix}:{baseDeduplicationKey}"); + } + + private static string BoundDeduplicationKey(string normalized) + { + if (normalized.Length <= MaxDeduplicationKeyLength) + return normalized; + + var digest = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(normalized))) + .ToLowerInvariant(); + var prefixLength = MaxDeduplicationKeyLength - digest.Length - 1; + return $"{TruncateUtf16Safely(normalized, prefixLength)}:{digest}"; + } + + private static string TruncateUtf16Safely(string value, int maximumCodeUnits) + { + var length = Math.Min(value.Length, maximumCodeUnits); + if (length > 0 + && length < value.Length + && char.IsHighSurrogate(value[length - 1]) + && char.IsLowSurrogate(value[length])) + length--; + return value[..length]; + } + + private static string? NormalizePayload(string? payloadJson) + { + if (string.IsNullOrWhiteSpace(payloadJson)) + return null; + if (Encoding.UTF8.GetByteCount(payloadJson) > MaxPayloadBytes) + throw new InvalidDataException("The notification payload is larger than allowed."); + + using var document = JsonDocument.Parse(payloadJson); + var normalized = JsonSerializer.Serialize(document.RootElement); + if (Encoding.UTF8.GetByteCount(normalized) > MaxPayloadBytes) + throw new InvalidDataException("The normalized notification payload is larger than allowed."); + return normalized; } [LoggerMessage(Level = LogLevel.Debug, diff --git a/SecondDimensionWatcherReDive/appsettings.example.json b/SecondDimensionWatcherReDive/appsettings.example.json index d7429a06..7cdc3ef2 100644 --- a/SecondDimensionWatcherReDive/appsettings.example.json +++ b/SecondDimensionWatcherReDive/appsettings.example.json @@ -27,7 +27,8 @@ "MaxFeedBytes": 4194304, "MaxTorrentBytes": 8388608, "MaxFeedItems": 1000, - // Exact hostnames and CIDRs are the only way to opt private feeds back in. + // Exact hostnames and CIDRs are the only way to opt private feeds, + // Webhooks, or Web Push endpoints back in. Keep this list narrow. "AllowedPrivateHosts": [], "AllowedPrivateNetworks": [] }, @@ -124,13 +125,21 @@ } }, - // Durable notification outbox. The full webhook URL may contain a token and is - // therefore treated as a secret; prefer configuring it from the web settings page. + // Durable notification outbox. Webhook and browser PushSubscription capability + // URLs are encrypted and never returned. Prefer configuring channels in the web UI; + // it can generate a VAPID pair whose private key is encrypted at rest. + // Remote endpoints must use HTTPS (plain HTTP is accepted only for loopback Webhooks). "Notifications": { "Webhook": { "Enabled": false, "Url": "" }, + "WebPush": { + "Enabled": false, + "Subject": "", + "VapidPublicKey": "", + "VapidPrivateKey": "" + }, "Events": "ReleaseMatched,DownloadPendingConfirmation,DownloadCompleted,DownloadFailed,IncidentOpened,MetadataNeedsReview,DiskSpaceLow", "QuietHours": { "Start": null, diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9bfc6886..eb004e20 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -39,6 +39,7 @@ direct dependencies listed here. | Npgsql.EntityFrameworkCore.PostgreSQL | PostgreSQL License | https://github.com/npgsql/efcore.pg | | Swashbuckle.AspNetCore | MIT | https://github.com/domaindrivendev/Swashbuckle.AspNetCore | | TMDbLib | MIT | https://github.com/LordMike/TMDbLib | +| WebPush | MIT | https://github.com/web-push-libs/web-push-csharp | The PostgreSQL License (used by Npgsql) is a permissive license functionally equivalent to the MIT/BSD family; full text: diff --git a/packaging/appsettings.yml b/packaging/appsettings.yml index 51d39f77..10c5f25e 100644 --- a/packaging/appsettings.yml +++ b/packaging/appsettings.yml @@ -26,6 +26,7 @@ OutboundHttp: MaxFeedBytes: 4194304 MaxTorrentBytes: 8388608 MaxFeedItems: 1000 + # 私网 RSS / Webhook / Web Push 端点仅通过精确主机名或 CIDR 显式放行。 AllowedPrivateHosts: [] AllowedPrivateNetworks: [] @@ -114,12 +115,17 @@ Incidents: MinimumAvailableBytes: 5368709120 # 5 GiB MinimumAvailablePercent: 5 -# 通知 Outbox(默认关闭)。Webhook URL 可能包含访问令牌,建议在网页设置中填写, -# 服务端会使用 Data Protection 加密保存且 API 不会回显明文。 +# 通知 Outbox(默认关闭)。Webhook URL、Web Push 订阅能力凭据及 VAPID 私钥会使用 +# Data Protection 加密保存且 API 不会回显明文;建议在网页设置中生成/配置,远端必须 HTTPS。 Notifications: Webhook: Enabled: false Url: "" + WebPush: + Enabled: false + Subject: "" + VapidPublicKey: "" + VapidPrivateKey: "" Events: "ReleaseMatched,DownloadPendingConfirmation,DownloadCompleted,DownloadFailed,IncidentOpened,MetadataNeedsReview,DiskSpaceLow" QuietHours: Start: From a1a837ac8417292971e4586394ace20a0c471619 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Mon, 31 Aug 2026 13:20:26 +0800 Subject: [PATCH 4/7] Avoid exposing internal subscription failures --- .../DataRepository/IWebPushSubscriptionRepository.cs | 3 +++ .../Controllers/WebPushSubscriptionsController.cs | 2 +- .../Repositories/WebPushSubscriptionRepository.cs | 3 +-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/IWebPushSubscriptionRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/IWebPushSubscriptionRepository.cs index 55ffff3c..718a1288 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/IWebPushSubscriptionRepository.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/IWebPushSubscriptionRepository.cs @@ -11,6 +11,9 @@ public sealed record WebPushSubscription( DateTimeOffset? LastFailureAt, string? LastError); +public sealed class WebPushSubscriptionLimitExceededException() + : Exception("The Web Push subscription limit has been reached."); + public interface IWebPushSubscriptionRepository { Task UpsertAsync( diff --git a/SecondDimensionWatcherReDive/Controllers/WebPushSubscriptionsController.cs b/SecondDimensionWatcherReDive/Controllers/WebPushSubscriptionsController.cs index fa550022..d73e2321 100644 --- a/SecondDimensionWatcherReDive/Controllers/WebPushSubscriptionsController.cs +++ b/SecondDimensionWatcherReDive/Controllers/WebPushSubscriptionsController.cs @@ -80,7 +80,7 @@ public async Task> RegisterAsync( { return ValidationError("endpoint", "The push-service endpoint is not allowed."); } - catch (InvalidOperationException exception) + catch (WebPushSubscriptionLimitExceededException exception) { return Conflict(new { message = exception.Message }); } diff --git a/SecondDimensionWatcherReDive/Repositories/WebPushSubscriptionRepository.cs b/SecondDimensionWatcherReDive/Repositories/WebPushSubscriptionRepository.cs index 04197d55..dfe6d97b 100644 --- a/SecondDimensionWatcherReDive/Repositories/WebPushSubscriptionRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/WebPushSubscriptionRepository.cs @@ -52,8 +52,7 @@ await writeContext.Database.ExecuteSqlRawAsync( { if (await writeContext.WebPushSubscriptions.CountAsync(cancellationToken) >= MaximumSubscriptions) - throw new InvalidOperationException( - $"At most {MaximumSubscriptions} Web Push subscriptions are allowed."); + throw new Framework.DataRepository.WebPushSubscriptionLimitExceededException(); entity = new SubscriptionEntity { From f2ec3bef7af5b94b3466d37a7289d85cb9fdf011 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Mon, 31 Aug 2026 13:58:53 +0800 Subject: [PATCH 5/7] fix: keep notification event identity stable --- .../Notifications/NotificationPublisher.cs | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/SecondDimensionWatcherReDive/Utils/Notifications/NotificationPublisher.cs b/SecondDimensionWatcherReDive/Utils/Notifications/NotificationPublisher.cs index 2bdb308c..e48b5952 100644 --- a/SecondDimensionWatcherReDive/Utils/Notifications/NotificationPublisher.cs +++ b/SecondDimensionWatcherReDive/Utils/Notifications/NotificationPublisher.cs @@ -29,7 +29,7 @@ public async Task PublishAsync( try { - var eventId = notificationEvent.Id ?? Guid.NewGuid(); + var fallbackEventId = notificationEvent.Id ?? Guid.NewGuid(); var occurredAt = notificationEvent.OccurredAt ?? DateTimeOffset.UtcNow; var title = Limit(notificationEvent.Title, 256); var body = Limit(notificationEvent.Body, 2048); @@ -38,10 +38,14 @@ public async Task PublishAsync( var baseDeduplicationKey = NormalizeDeduplicationKey( notificationEvent.DeduplicationKey, notificationEvent.Type, - eventId); + fallbackEventId); + // EventId identifies the logical event across all delivery targets and + // publication retries. Keep the outbox row Id independent so a target + // added later can be inserted without colliding with an existing row. + var eventId = notificationEvent.Id + ?? DeriveEventId(notificationEvent.Type, baseDeduplicationKey); var enqueued = false; - var hasAssignedEventId = false; if (webhookEnabled) { @@ -54,7 +58,7 @@ public async Task PublishAsync( return await EnqueueTargetAsync( outbox, new NotificationOutboxMessage( - eventId, + Guid.NewGuid(), eventId, NormalizeTargetDeduplicationKey( baseDeduplicationKey, @@ -78,12 +82,10 @@ public async Task PublishAsync( }, notificationEvent.Type, cancellationToken); - hasAssignedEventId = true; } if (webPushEnabled) { - var eventIdAlreadyAssigned = hasAssignedEventId; enqueued |= await TryEnqueueChannelAsync( async () => { @@ -104,7 +106,7 @@ public async Task PublishAsync( any |= await EnqueueTargetAsync( outbox, new NotificationOutboxMessage( - eventIdAlreadyAssigned ? Guid.NewGuid() : eventId, + Guid.NewGuid(), eventId, targetDeduplicationKey, NotificationChannel.WebPush, @@ -122,7 +124,6 @@ public async Task PublishAsync( null, null), cancellationToken); - eventIdAlreadyAssigned = true; } return any; }, @@ -203,6 +204,16 @@ private static string NormalizeDeduplicationKey( return BoundDeduplicationKey(normalized); } + private static Guid DeriveEventId( + NotificationEventType type, + string baseDeduplicationKey) + { + var identity = $"{type}:{baseDeduplicationKey}"; + Span digest = stackalloc byte[32]; + SHA256.HashData(Encoding.UTF8.GetBytes(identity), digest); + return new Guid(digest[..16]); + } + private static string NormalizeTargetDeduplicationKey( string baseDeduplicationKey, NotificationChannel channel, From 108663facbb2a0c198f3f5cb8a2455b8ed394769 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Mon, 31 Aug 2026 14:08:53 +0800 Subject: [PATCH 6/7] fix: refresh fingerprinted push worker --- .../src/App.tsx | 2 ++ .../src/notifications/webPush.ts | 24 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/SecondDimensionWatcherReDive.Client/src/App.tsx b/SecondDimensionWatcherReDive.Client/src/App.tsx index 87c2234c..8adfc26a 100644 --- a/SecondDimensionWatcherReDive.Client/src/App.tsx +++ b/SecondDimensionWatcherReDive.Client/src/App.tsx @@ -6,6 +6,7 @@ import { Main } from "./Main"; import fetcher from "./auth/httpClient"; import { ToastProvider } from "./components/ToastProvider"; import i18n from "./i18n"; +import { refreshWebPushServiceWorker } from "./notifications/webPush"; import "./styles.css"; import { setDayjsLocale } from "./utils/initDayjs"; @@ -15,6 +16,7 @@ i18n.on("languageChanged", (lng) => { setDayjsLocale(lng); document.documentElement.lang = lng; }); +void refreshWebPushServiceWorker().catch(() => undefined); const root = createRoot(document.getElementById("app")!); root.render( diff --git a/SecondDimensionWatcherReDive.Client/src/notifications/webPush.ts b/SecondDimensionWatcherReDive.Client/src/notifications/webPush.ts index d83c9c86..52f9d0e2 100644 --- a/SecondDimensionWatcherReDive.Client/src/notifications/webPush.ts +++ b/SecondDimensionWatcherReDive.Client/src/notifications/webPush.ts @@ -30,6 +30,24 @@ const keysEqual = ( const getRegistration = () => navigator.serviceWorker.getRegistration("/"); +const registerCurrentWorker = () => { + const workerUrl = new URL("./pushServiceWorker.js", import.meta.url); + return navigator.serviceWorker.register(workerUrl, { + scope: "/", + type: "module", + updateViaCache: "none", + }); +}; + +export const refreshWebPushServiceWorker = async (): Promise => { + if (!isWebPushSupported()) return; + // Parcel fingerprints the worker URL. Re-register the current build whenever + // an installation already owns this scope so deployments can replace a + // worker whose previous fingerprinted asset is no longer on the server. + if (!(await getRegistration())) return; + await registerCurrentWorker(); +}; + export const getCurrentWebPushSubscription = async () => { if (!isWebPushSupported()) return null; const registration = await getRegistration(); @@ -41,11 +59,7 @@ export const enableWebPushForCurrentDevice = async (vapidPublicKey: string) => { const permission = await Notification.requestPermission(); if (permission !== "granted") throw new Error("permissionDenied"); - const workerUrl = new URL("./pushServiceWorker.js", import.meta.url); - const registration = await navigator.serviceWorker.register(workerUrl, { - scope: "/", - type: "module", - }); + const registration = await registerCurrentWorker(); const expectedKey = decodeBase64Url(vapidPublicKey); let subscription = await registration.pushManager.getSubscription(); if ( From 16b0bf5abc57287673fa3bd8aac12174ea02ded5 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Mon, 31 Aug 2026 14:23:27 +0800 Subject: [PATCH 7/7] fix: reconcile current Web Push revocation --- .../mock-server.mjs | 3 +- .../settings/NotificationSettingsSection.tsx | 41 ++++++++++++++----- .../src/notifications/types.ts | 1 + .../src/notifications/webPush.ts | 10 +++++ .../Controllers/External/Notifications.cs | 1 + .../WebPushSubscriptionsController.cs | 6 +++ 6 files changed, 51 insertions(+), 11 deletions(-) diff --git a/SecondDimensionWatcherReDive.Client/mock-server.mjs b/SecondDimensionWatcherReDive.Client/mock-server.mjs index 9489d78d..3583b98a 100644 --- a/SecondDimensionWatcherReDive.Client/mock-server.mjs +++ b/SecondDimensionWatcherReDive.Client/mock-server.mjs @@ -1,7 +1,7 @@ // Mock API server for frontend development/testing. // Run with: yarn mock (or: node mock-server.mjs) // Then run: yarn start — the Parcel proxy forwards /api/* to this server. -import { randomBytes, randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; import { createServer } from "node:http"; const PORT = parseInt(process.env.MOCK_PORT ?? "5097", 10); @@ -1877,6 +1877,7 @@ async function route(method, pathname, searchParams, req, res) { id: randomUUID(), endpoint: body.endpoint, endpointOrigin: new URL(body.endpoint).origin, + endpointHash: createHash("sha256").update(body.endpoint).digest("hex"), createdAt: now, updatedAt: now, lastSuccessAt: null, diff --git a/SecondDimensionWatcherReDive.Client/src/components/settings/NotificationSettingsSection.tsx b/SecondDimensionWatcherReDive.Client/src/components/settings/NotificationSettingsSection.tsx index 908813a6..81dfc0cd 100644 --- a/SecondDimensionWatcherReDive.Client/src/components/settings/NotificationSettingsSection.tsx +++ b/SecondDimensionWatcherReDive.Client/src/components/settings/NotificationSettingsSection.tsx @@ -20,10 +20,12 @@ import { useNotificationDeliveries, useWebPushSubscriptions, } from "../../notifications/hooks"; +import { WebPushSubscriptionSummary } from "../../notifications/types"; import { disableWebPushForCurrentDevice, enableWebPushForCurrentDevice, getCurrentWebPushSubscription, + hashWebPushEndpoint, isWebPushSupported, } from "../../notifications/webPush"; import { @@ -82,17 +84,23 @@ export const NotificationSettingsSection: React.FC< const [saving, setSaving] = React.useState(false); const [testing, setTesting] = React.useState(false); const [webPushBusy, setWebPushBusy] = React.useState(false); - const [deviceSubscribed, setDeviceSubscribed] = React.useState(false); + const [currentEndpointHash, setCurrentEndpointHash] = React.useState< + string | null + >(null); const [saved, setSaved] = React.useState(false); React.useEffect(() => { let active = true; void getCurrentWebPushSubscription() .then((subscription) => { - if (active) setDeviceSubscribed(subscription !== null); + if (!subscription) return null; + return hashWebPushEndpoint(subscription.endpoint); + }) + .then((endpointHash) => { + if (active) setCurrentEndpointHash(endpointHash); }) .catch(() => { - if (active) setDeviceSubscribed(false); + if (active) setCurrentEndpointHash(null); }); return () => { active = false; @@ -119,6 +127,12 @@ export const NotificationSettingsSection: React.FC< !urlDraft.value.trim()) || (draft.webhookEnabled && urlDraft.operation === "clear"); const webPushInvalid = draft.webPushEnabled && !draft.webPushSubject.trim(); + const deviceSubscribed = + currentEndpointHash !== null && + (subscriptions === undefined || + subscriptions.some( + (subscription) => subscription.endpointHash === currentEndpointHash, + )); const reset = React.useCallback(() => { setDraft({ ...value, events: [...value.events] }); @@ -186,8 +200,10 @@ export const NotificationSettingsSection: React.FC< if (webPushBusy || !value.vapidPublicKey) return; setWebPushBusy(true); try { - await enableWebPushForCurrentDevice(value.vapidPublicKey); - setDeviceSubscribed(true); + const subscription = await enableWebPushForCurrentDevice( + value.vapidPublicKey, + ); + setCurrentEndpointHash(subscription.endpointHash); await mutateSubscriptions(); addToast({ title: t("system.notifications.webPush.deviceEnabled"), @@ -208,7 +224,7 @@ export const NotificationSettingsSection: React.FC< setWebPushBusy(true); try { await disableWebPushForCurrentDevice(); - setDeviceSubscribed(false); + setCurrentEndpointHash(null); await mutateSubscriptions(); addToast({ title: t("system.notifications.webPush.deviceDisabled"), @@ -225,11 +241,16 @@ export const NotificationSettingsSection: React.FC< }, [addToast, mutateSubscriptions, t, webPushBusy]); const revokeSubscription = React.useCallback( - async (id: string) => { + async (subscription: WebPushSubscriptionSummary) => { if (webPushBusy) return; setWebPushBusy(true); try { - await removeWebPushSubscription(id); + if (subscription.endpointHash === currentEndpointHash) { + await disableWebPushForCurrentDevice(); + setCurrentEndpointHash(null); + } else { + await removeWebPushSubscription(subscription.id); + } await mutateSubscriptions(); addToast({ title: t("system.notifications.webPush.subscriptionRevoked"), @@ -244,7 +265,7 @@ export const NotificationSettingsSection: React.FC< setWebPushBusy(false); } }, - [addToast, mutateSubscriptions, t, webPushBusy], + [addToast, currentEndpointHash, mutateSubscriptions, t, webPushBusy], ); const test = React.useCallback(async () => { @@ -383,7 +404,7 @@ export const NotificationSettingsSection: React.FC< aria-label={t( "system.notifications.webPush.revokeSubscription", )} - onClick={() => void revokeSubscription(subscription.id)} + onClick={() => void revokeSubscription(subscription)} > diff --git a/SecondDimensionWatcherReDive.Client/src/notifications/types.ts b/SecondDimensionWatcherReDive.Client/src/notifications/types.ts index d0d78ec1..d6f508bf 100644 --- a/SecondDimensionWatcherReDive.Client/src/notifications/types.ts +++ b/SecondDimensionWatcherReDive.Client/src/notifications/types.ts @@ -19,6 +19,7 @@ export interface WebPushConfiguration { export interface WebPushSubscriptionSummary { id: string; endpointOrigin: string; + endpointHash: string; createdAt: string; updatedAt: string; lastSuccessAt: string | null; diff --git a/SecondDimensionWatcherReDive.Client/src/notifications/webPush.ts b/SecondDimensionWatcherReDive.Client/src/notifications/webPush.ts index 52f9d0e2..0f428813 100644 --- a/SecondDimensionWatcherReDive.Client/src/notifications/webPush.ts +++ b/SecondDimensionWatcherReDive.Client/src/notifications/webPush.ts @@ -54,6 +54,16 @@ export const getCurrentWebPushSubscription = async () => { return registration?.pushManager.getSubscription() ?? null; }; +export const hashWebPushEndpoint = async (endpoint: string) => { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(endpoint), + ); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +}; + export const enableWebPushForCurrentDevice = async (vapidPublicKey: string) => { if (!isWebPushSupported()) throw new Error("unsupported"); const permission = await Notification.requestPermission(); diff --git a/SecondDimensionWatcherReDive/Controllers/External/Notifications.cs b/SecondDimensionWatcherReDive/Controllers/External/Notifications.cs index 61106b11..5ce4def0 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/Notifications.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/Notifications.cs @@ -34,6 +34,7 @@ internal sealed record RemoveWebPushSubscriptionRequest( internal sealed record WebPushSubscriptionSummary( Guid Id, string EndpointOrigin, + string EndpointHash, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, DateTimeOffset? LastSuccessAt, diff --git a/SecondDimensionWatcherReDive/Controllers/WebPushSubscriptionsController.cs b/SecondDimensionWatcherReDive/Controllers/WebPushSubscriptionsController.cs index d73e2321..5829af36 100644 --- a/SecondDimensionWatcherReDive/Controllers/WebPushSubscriptionsController.cs +++ b/SecondDimensionWatcherReDive/Controllers/WebPushSubscriptionsController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Security.Cryptography; +using System.Text; using SecondDimensionWatcherReDive.Controllers.External; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Repositories; @@ -108,12 +109,17 @@ public async Task RemoveCurrentAsync( private static WebPushSubscriptionSummary ToSummary(WebPushSubscription subscription) => new( subscription.Id, new Uri(subscription.Endpoint).GetLeftPart(UriPartial.Authority), + HashEndpoint(subscription.Endpoint), subscription.CreatedAt, subscription.UpdatedAt, subscription.LastSuccessAt, subscription.LastFailureAt, subscription.LastError); + private static string HashEndpoint(string endpoint) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(endpoint))) + .ToLowerInvariant(); + private ActionResult ValidationError(string key, string message) { ModelState.AddModelError(key, message);