diff --git a/README.md b/README.md index dc9f24ee..117891f8 100644 --- a/README.md +++ b/README.md @@ -107,6 +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 按敏感配置处理,建议从网页设置中保存;远端地址必须使用 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;仅填写代理,不填写客户端网段 | @@ -118,7 +122,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、VAPID 私钥及浏览器 PushSubscription 能力凭据使用持久化 Data Protection 密钥环加密,API 不会回显明文。可对单个敏感项选择保留、替换、清除或恢复部署默认值。 + +启用且订阅的通知会在核心操作完成后,以唯一去重键尽力写入 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 2622fb46..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); @@ -1106,6 +1106,26 @@ let systemSettings = { restartRequired: true, pendingRestart: false, }, + notifications: { + webhookEnabled: false, + webPushEnabled: false, + webPushSubject: "", + vapidPublicKey: "", + vapidPrivateKey: { isConfigured: false, source: "none" }, + events: [ + "releaseMatched", + "downloadPendingConfirmation", + "downloadCompleted", + "downloadFailed", + "incidentOpened", + "metadataNeedsReview", + "diskSpaceLow", + ], + quietHoursStart: null, + quietHoursEnd: null, + timeZoneId: "UTC", + webhookUrl: { isConfigured: false, source: "none" }, + }, }; const deploymentSecrets = { @@ -1114,8 +1134,14 @@ const deploymentSecrets = { codex: { isConfigured: false, source: "none" }, tmdb: { isConfigured: true, source: "deployment" }, torrent: { isConfigured: false, source: "none" }, + webhook: { isConfigured: false, source: "none" }, }; +let notificationDeliveries = []; +let webPushSubscriptions = []; +const mockVapidPublicKey = + "BGb1EKTo02dge1GKm7kU8hSQowk4T8Qnpl8dOB1nrnSQJnrhc6OdQ3a4gtyGTkera6bMWIp9cKAlEdN_BA6gGQM"; + function applySecretMutation(current, mutation, deploymentValue) { if (!mutation || mutation.operation === "keep") return current; if (mutation.operation === "set") { @@ -1529,6 +1555,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 // --------------------------------------------------------------------------- @@ -1722,6 +1805,32 @@ async function route(method, pathname, searchParams, req, res) { systemSettings.pendingRestart = systemSettings.nfs.pendingRestart; } + 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, + timeZoneId: body.notifications.timeZoneId, + webhookUrl: applySecretMutation( + systemSettings.notifications.webhookUrl, + body.notifications.webhookUrl, + deploymentSecrets.webhook, + ), + }; + } + systemSettings.revision += 1; return json(res, systemSettings); } catch (error) { @@ -1729,6 +1838,171 @@ 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, + endpointHash: createHash("sha256").update(body.endpoint).digest("hex"), + 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") { + 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(); + 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); + } + + 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 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( + (item) => + !item.readAt && + (!item.snoozedUntil || new Date(item.snoozedUntil) <= now), + ).length; + const visible = all.filter( + (item) => + (includeRead || !item.readAt) && + (includeSnoozed || + !item.snoozedUntil || + new Date(item.snoozedUntil) <= now), + ); + 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") { + 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") { 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/Main.tsx b/SecondDimensionWatcherReDive.Client/src/Main.tsx index 9e035818..accfaf7e 100644 --- a/SecondDimensionWatcherReDive.Client/src/Main.tsx +++ b/SecondDimensionWatcherReDive.Client/src/Main.tsx @@ -19,6 +19,7 @@ import { loadPlayerPage, loadSettingsPage, loadTasksPage, + loadTodoPage, } from "./routes/pageLoaders"; const ChatPage = React.lazy(async () => ({ @@ -60,6 +61,9 @@ const SettingsPage = React.lazy(async () => ({ const TasksPage = React.lazy(async () => ({ default: (await loadTasksPage()).TasksPage, })); +const TodoPage = React.lazy(async () => ({ + default: (await loadTodoPage()).TodoPage, +})); const router = createBrowserRouter([ { @@ -125,6 +129,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 aa18630e..8ee9f6d8 100644 --- a/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx +++ b/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx @@ -4,6 +4,7 @@ import { Link, useLocation, useNavigate } from "react-router"; import { mutate } from "swr"; import { + BellRing, Check, Clapperboard, Cog, @@ -30,6 +31,7 @@ import i18n, { } from "../i18n"; import { useIncidents } from "../incidents/hooks"; import { cn } from "../lib/cn"; +import { useTodos } from "../todos/hooks"; import { useToast } from "./ToastProvider"; import { DropdownMenu, @@ -48,8 +50,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", @@ -253,8 +264,9 @@ export const AppHeader: React.FC = () => { const { t } = useTranslation(); const { data: status } = useLoginStatus(); const { data: incidents } = useIncidents({ take: 1 }); + const { data: todos } = useTodos({ take: 1 }); 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..81dfc0cd --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/components/settings/NotificationSettingsSection.tsx @@ -0,0 +1,559 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { + BellRing, + Clock3, + History, + MonitorSmartphone, + Send, + Trash2, + Webhook, +} from "lucide-react"; + +import { apiErrorStatus } from "../../errors/apiError"; +import { + removeWebPushSubscription, + sendTestNotification, +} from "../../notifications/api"; +import { + useNotificationDeliveries, + useWebPushSubscriptions, +} from "../../notifications/hooks"; +import { WebPushSubscriptionSummary } from "../../notifications/types"; +import { + disableWebPushForCurrentDevice, + enableWebPushForCurrentDevice, + getCurrentWebPushSubscription, + hashWebPushEndpoint, + isWebPushSupported, +} from "../../notifications/webPush"; +import { + NotificationEventType, + NotificationSettings, + NotificationSettingsPatch, + 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: NotificationSettingsPatch; + }) => Promise; +} + +export const NotificationSettingsSection: React.FC< + NotificationSettingsSectionProps +> = ({ value, onSave }) => { + const { t, i18n } = useTranslation("settings"); + const { addToast } = useToast(); + const { data: deliveries, mutate: mutateDeliveries } = + useNotificationDeliveries(); + const { data: subscriptions, mutate: mutateSubscriptions } = + useWebPushSubscriptions(); + 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 [webPushBusy, setWebPushBusy] = 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 (!subscription) return null; + return hashWebPushEndpoint(subscription.endpoint); + }) + .then((endpointHash) => { + if (active) setCurrentEndpointHash(endpointHash); + }) + .catch(() => { + if (active) setCurrentEndpointHash(null); + }); + return () => { + active = 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 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] }); + setUrlDraft(createSecretDraft()); + setSaved(false); + }, [value]); + + const save = React.useCallback(async () => { + if (invalid || webPushInvalid || saving) { + if (invalid || webPushInvalid) + addToast({ + title: t("system.notifications.validationFailed"), + color: "warning", + }); + return; + } + setSaving(true); + setSaved(false); + try { + 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); + addToast({ + title: t("system.notifications.saved"), + color: "success", + }); + } catch (error) { + addToast({ + title: + apiErrorStatus(error) === 409 + ? t("system.save.conflict") + : t("system.save.failed"), + color: "danger", + }); + } finally { + setSaving(false); + } + }, [ + 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 { + const subscription = await enableWebPushForCurrentDevice( + value.vapidPublicKey, + ); + setCurrentEndpointHash(subscription.endpointHash); + 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(); + setCurrentEndpointHash(null); + 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 (subscription: WebPushSubscriptionSummary) => { + if (webPushBusy) return; + setWebPushBusy(true); + try { + if (subscription.endpointHash === currentEndpointHash) { + await disableWebPushForCurrentDevice(); + setCurrentEndpointHash(null); + } else { + await removeWebPushSubscription(subscription.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, currentEndpointHash, mutateSubscriptions, t, webPushBusy], + ); + + 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.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} +
+
+ +
+ +
+ + } + 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, + })} + + + {t( + `system.notifications.delivery.channel.${delivery.channel}`, + )} + + {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 4fb1d4d3..678ea788 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 860c8453..362b83a0 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 d4d0ab60..8df497bc 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,79 @@ "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 notification channel, 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…" + }, + "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.", + "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.", + "channel": { + "Webhook": "Webhook", + "WebPush": "Web Push" + }, + "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..28cb8acb --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/todos.json @@ -0,0 +1,52 @@ +{ + "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", + "unsnooze": "Remind me now", + "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 761c1490..d2cc7ae3 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 789ede7d..f6168909 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,79 @@ "minimumPercent": "最小空き割合(%)" } }, + "notifications": { + "eyebrow": "外部アラート", + "title": "通知", + "description": "ダウンロード、推論、障害処理を妨げずに選択したイベントを配信します。", + "saved": "通知設定を保存しました", + "validationFailed": "通知チャネル、1 件以上のイベント、有効な静穏時間を設定してください", + "testQueued": "テスト通知をキューに追加しました", + "testFailed": "テスト通知を追加できませんでした", + "webhook": { + "title": "汎用 Webhook", + "description": "安定したイベント形式を POST します。受信側は X-SDW-Event-Id で重複を防止できます。", + "enabled": "Webhook 配信を有効化", + "enabledHelp": "失敗はバックグラウンドで再試行され、元の処理には影響しません。", + "url": "Webhook URL", + "secretHelp": "完全な URL は暗号化保存され、ブラウザーやログには返されません。", + "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": "外部配信するイベントを選択します。", + "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": "配信記録はまだありません。", + "channel": { + "Webhook": "Webhook", + "WebPush": "Web Push" + }, + "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..0a3bb0cf --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/todos.json @@ -0,0 +1,51 @@ +{ + "eyebrow": "確認キュー", + "title": "統合 TODO センター", + "subtitle": "一致したリリース、確認待ち、障害、メタデータ、ディスク警告をリスク順に確認します。", + "unread": "未読 {{count}} 件", + "listLabel": "現在の TODO", + "filters": { + "includeRead": "既読を表示", + "includeSnoozed": "スヌーズを表示" + }, + "actions": { + "selectAll": "すべて選択", + "selectItem": "{{title}} を選択", + "readSelected": "選択項目を既読にする", + "snoozeSelected": "選択項目を 1 時間スヌーズ", + "markRead": "既読にする", + "markUnread": "未読にする", + "snooze": "1 時間スヌーズ", + "unsnooze": "今すぐ通知", + "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 bc37d7a6..927a1271 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 f3aeaa05..d404fee6 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,79 @@ "minimumPercent": "最小可用比例(%)" } }, + "notifications": { + "eyebrow": "外部提醒", + "title": "通知", + "description": "在不阻塞下载、推断或异常处理的前提下投递所选领域事件。", + "saved": "通知设置已保存", + "validationFailed": "请配置通知渠道、至少一个事件和有效的免打扰时段", + "testQueued": "测试通知已进入队列", + "testFailed": "无法加入测试通知", + "webhook": { + "title": "通用 Webhook", + "description": "发送稳定的事件信封;接收端可用 X-SDW-Event-Id 实现幂等。", + "enabled": "启用 Webhook 投递", + "enabledHelp": "失败会在后台重试,绝不会导致原业务操作失败。", + "url": "Webhook URL", + "secretHelp": "完整 URL 会加密保存,绝不返回浏览器或写入日志。", + "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": "选择允许向外投递的事件。", + "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": "还没有投递记录。", + "channel": { + "Webhook": "Webhook", + "WebPush": "Web Push" + }, + "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..02502520 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/todos.json @@ -0,0 +1,51 @@ +{ + "eyebrow": "关注队列", + "title": "统一待办中心", + "subtitle": "按风险顺序处理订阅命中、下载确认、异常、元数据审查和磁盘预警。", + "unread": "{{count}} 个未读待办", + "listLabel": "当前待办事项", + "filters": { + "includeRead": "显示已读", + "includeSnoozed": "显示已稍后提醒" + }, + "actions": { + "selectAll": "全选", + "selectItem": "选择 {{title}}", + "readSelected": "将所选标为已读", + "snoozeSelected": "所选稍后 1 小时提醒", + "markRead": "标为已读", + "markUnread": "标为未读", + "snooze": "稍后 1 小时提醒", + "unsnooze": "立即提醒", + "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/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 c0148b74..92051a08 100644 --- a/SecondDimensionWatcherReDive.Client/src/metadataReview/api.ts +++ b/SecondDimensionWatcherReDive.Client/src/metadataReview/api.ts @@ -12,13 +12,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 new file mode 100644 index 00000000..2f565650 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/notifications/api.ts @@ -0,0 +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 new file mode 100644 index 00000000..b9e3d138 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/notifications/hooks.ts @@ -0,0 +1,19 @@ +import useSWR from "swr"; + +import fetcher from "../auth/httpClient"; +import { NotificationDelivery } from "./types"; +import { WebPushSubscriptionSummary } from "./types"; + +export const useNotificationDeliveries = () => + useSWR( + "/api/notifications/deliveries?take=10", + 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 new file mode 100644 index 00000000..d6f508bf --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/notifications/types.ts @@ -0,0 +1,28 @@ +export interface NotificationDelivery { + id: string; + eventId: string; + channel: "Webhook" | "WebPush"; + type: string; + status: "Pending" | "Processing" | "Delivered" | "Failed"; + attemptCount: number; + occurredAt: string; + lastAttemptAt: string | null; + deliveredAt: string | null; + lastError: string | null; +} + +export interface WebPushConfiguration { + enabled: boolean; + vapidPublicKey: string; +} + +export interface WebPushSubscriptionSummary { + id: string; + endpointOrigin: string; + endpointHash: 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..0f428813 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/notifications/webPush.ts @@ -0,0 +1,95 @@ +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("/"); + +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(); + 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(); + if (permission !== "granted") throw new Error("permissionDenied"); + + const registration = await registerCurrentWorker(); + 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 242dab2c..648dce6b 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,14 @@ 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 focusedIdRef = React.useRef(null); + 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()); @@ -148,12 +160,44 @@ export const IncidentsPage: React.FC = () => { includeResolved, skip: page * 50, take: 50, + focus, }); React.useEffect(() => { setPage(0); }, [includeResolved, type]); + React.useEffect(() => { + setType(initialType); + }, [initialType]); + + React.useEffect(() => { + 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( + (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 +285,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; + 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} +

    +
    + +
    +

    + {detail} +

    +
    + {automation ? ( + + ) : ( + + )} + + +
    +
    +
    +
  • + ); + })} +
+ {data.totalCount > PAGE_SIZE ? ( + + ) : null} +
+ )} + + ); +}; diff --git a/SecondDimensionWatcherReDive.Client/src/routes/pageLoaders.ts b/SecondDimensionWatcherReDive.Client/src/routes/pageLoaders.ts index 118de23e..7cc613c5 100644 --- a/SecondDimensionWatcherReDive.Client/src/routes/pageLoaders.ts +++ b/SecondDimensionWatcherReDive.Client/src/routes/pageLoaders.ts @@ -6,6 +6,7 @@ export const loadPlayerPage = () => import("../pages/PlayerPage"); export const loadIncidentsPage = () => import("../pages/IncidentsPage"); export const loadFeedsPage = () => import("../pages/FeedsPage"); export const loadTasksPage = () => import("../pages/TasksPage"); +export const loadTodoPage = () => import("../pages/TodoPage"); export const loadMetadataReviewPage = () => import("../pages/MetadataReviewPage"); export const loadChatPage = () => import("../pages/ChatPage"); diff --git a/SecondDimensionWatcherReDive.Client/src/settings/systemTypes.ts b/SecondDimensionWatcherReDive.Client/src/settings/systemTypes.ts index 057e9638..fc3756b2 100644 --- a/SecondDimensionWatcherReDive.Client/src/settings/systemTypes.ts +++ b/SecondDimensionWatcherReDive.Client/src/settings/systemTypes.ts @@ -93,6 +93,28 @@ export interface NfsSettings { pendingRestart: boolean; } +export type NotificationEventType = + | "releaseMatched" + | "downloadPendingConfirmation" + | "downloadCompleted" + | "downloadFailed" + | "incidentOpened" + | "metadataNeedsReview" + | "diskSpaceLow"; + +export interface NotificationSettings { + webhookEnabled: boolean; + webPushEnabled: boolean; + webPushSubject: string; + vapidPublicKey: string; + vapidPrivateKey: SecretState; + events: NotificationEventType[]; + quietHoursStart: string | null; + quietHoursEnd: string | null; + timeZoneId: string; + webhookUrl: SecretState; +} + export interface SystemSettings { revision: number; pendingRestart: boolean; @@ -102,6 +124,7 @@ export interface SystemSettings { mediaLibrary: MediaLibrarySettings; incidents: IncidentSettings; nfs: NfsSettings; + notifications: NotificationSettings; } export interface OpenAiSettingsPatch extends Omit { @@ -147,6 +170,14 @@ export type NfsSettingsPatch = Omit< "restartRequired" | "pendingRestart" >; +export interface NotificationSettingsPatch extends Omit< + NotificationSettings, + "webhookUrl" | "vapidPublicKey" | "vapidPrivateKey" +> { + webhookUrl?: SecretMutation | null; + generateVapidKeys?: boolean; +} + export interface SystemSettingsPatch { expectedRevision: number; ai?: AiSettingsPatch; @@ -155,6 +186,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..0054dc0a --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/todos/hooks.ts @@ -0,0 +1,23 @@ +import useSWR from "swr"; + +import fetcher from "../auth/httpClient"; +import { TodoList } from "./types"; + +export const useTodos = (options?: { + includeRead?: boolean; + 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, { + refreshInterval: 15_000, + }); +}; 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.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/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 new file mode 100644 index 00000000..f6c0f89a --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/INotificationOutboxRepository.cs @@ -0,0 +1,73 @@ +using SecondDimensionWatcherReDive.Framework.Notifications; + +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public enum NotificationDeliveryStatus +{ + Pending, + Processing, + Delivered, + 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, + 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( + TimeSpan leaseDuration, + int take, + CancellationToken cancellationToken); + + Task MarkDeliveredAsync( + Guid id, + DateTimeOffset expectedLeaseUntil, + DateTimeOffset deliveredAt, + CancellationToken cancellationToken); + + Task MarkFailedAsync( + Guid id, + DateTimeOffset expectedLeaseUntil, + int attemptCount, + DateTimeOffset attemptedAt, + DateTimeOffset? nextAttemptAt, + string error, + CancellationToken cancellationToken); + + Task RescheduleAsync( + Guid id, + DateTimeOffset expectedLeaseUntil, + 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..bb14beaf --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/ITodoRepository.cs @@ -0,0 +1,55 @@ +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, + int skip, + int take, + string? focusKey, + CancellationToken cancellationToken); + + Task SetStateAsync( + IReadOnlyCollection keys, + DateTimeOffset? readAt, + bool updateReadAt, + DateTimeOffset? snoozedUntil, + bool updateSnoozedUntil, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/IWebPushSubscriptionRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/IWebPushSubscriptionRepository.cs new file mode 100644 index 00000000..718a1288 --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/IWebPushSubscriptionRepository.cs @@ -0,0 +1,46 @@ +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 sealed class WebPushSubscriptionLimitExceededException() + : Exception("The Web Push subscription limit has been reached."); + +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/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.Framework/Notifications/NotificationEvent.cs b/SecondDimensionWatcherReDive.Framework/Notifications/NotificationEvent.cs new file mode 100644 index 00000000..c2fd5b29 --- /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/Configuration/RuntimeSettingsModels.cs b/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsModels.cs index e4d60469..6972cd31 100644 --- a/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsModels.cs +++ b/SecondDimensionWatcherReDive/Configuration/RuntimeSettingsModels.cs @@ -1,7 +1,9 @@ using System.Globalization; using System.Net; +using System.Security.Cryptography; using System.Text.Json.Serialization; using Microsoft.Extensions.Configuration; +using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.Framework.Networking; namespace SecondDimensionWatcherReDive.Configuration; @@ -138,12 +140,23 @@ internal sealed record NfsSettingsValues( public IReadOnlyList AllowedNetworks { get; init; } = ["127.0.0.0/8", "::1/128"]; } +internal sealed record NotificationSettingsValues( + bool WebhookEnabled, + bool WebPushEnabled, + string WebPushSubject, + string VapidPublicKey, + 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 { @@ -156,6 +169,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); @@ -182,6 +197,17 @@ internal sealed record TorrentSettingsUpdate( TorrentSettingsValues Values, SecretMutation? Password); +internal sealed record NotificationSettingsUpdate( + bool WebhookEnabled, + bool WebPushEnabled, + string WebPushSubject, + IReadOnlyList Events, + TimeSpan? QuietHoursStart, + TimeSpan? QuietHoursEnd, + string TimeZoneId, + SecretMutation? WebhookUrl, + bool GenerateVapidKeys); + internal sealed record RuntimeSettingsPatch( long ExpectedRevision, AiSettingsUpdate? Ai, @@ -189,7 +215,8 @@ internal sealed record RuntimeSettingsPatch( TorrentSettingsUpdate? Torrent, MediaLibrarySettingsValues? MediaLibrary, IncidentSettingsValues? Incidents, - NfsSettingsValues? Nfs); + NfsSettingsValues? Nfs, + NotificationSettingsUpdate? Notifications = null); internal sealed record ResolvedSecret( string? Value, @@ -221,6 +248,8 @@ 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 const string NotificationVapidPrivateKey = "Notifications:WebPush:VapidPrivateKey"; public static readonly string[] All = [ @@ -228,7 +257,9 @@ internal static class RuntimeSecretKeys AnthropicApiKey, CodexToken, TmdbApiKey, - TorrentPassword + TorrentPassword, + NotificationWebhookUrl, + NotificationVapidPrivateKey ]; } @@ -265,7 +296,16 @@ public static RuntimeSettingsValues FromConfiguration(IConfiguration configurati AllowAnonymous = configuration.GetValue("Nfs:AllowAnonymous") ?? false, AllowedNetworks = configuration.GetSection("Nfs:AllowedNetworks").Get() ?? ["127.0.0.0/8", "::1/128"] - }); + }, + 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"), + configuration["Notifications:QuietHours:TimeZone"] ?? "UTC")); public static IReadOnlyDictionary ReadDeploymentSecrets( IConfiguration configuration) => @@ -302,6 +342,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 { @@ -419,6 +473,56 @@ public static IReadOnlyDictionary Validate( Add(errors, $"nfs.allowedNetworks.{index}", "The value must be a valid IPv4 or IPv6 CIDR."); } + 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); + + 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) @@ -465,6 +569,96 @@ 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 (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( Dictionary> errors, string key, @@ -522,6 +716,8 @@ private static void RequireNonNegative( RuntimeSecretKeys.CodexToken => "ai.codexAppServer.token", RuntimeSecretKeys.TmdbApiKey => "tmdb.apiKey", RuntimeSecretKeys.TorrentPassword => "torrent.password", + RuntimeSecretKeys.NotificationWebhookUrl => "notifications.webhook.url", + RuntimeSecretKeys.NotificationVapidPrivateKey => "notifications.webPush.vapidPrivateKey", _ => key }; @@ -604,6 +800,19 @@ internal static class RuntimeSettingsFlattener for (var index = 0; index < values.Nfs.AllowedNetworks.Count; index++) flattened[$"Nfs:AllowedNetworks:{index}"] = values.Nfs.AllowedNetworks[index]; + 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); + 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 4d4ace24..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,19 +358,33 @@ 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, 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 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); @@ -351,6 +392,13 @@ 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); + ApplySecret( + values, + RuntimeSecretKeys.NotificationVapidPrivateKey, + generatedVapidPrivateKey is null + ? null + : new SecretMutation(SecretMutationOperation.Set, generatedVapidPrivateKey)); return new RuntimeSecretOverrides { Values = values }; } @@ -445,6 +493,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; } @@ -679,7 +728,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 06f4a60b..5bf282db 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] @@ -204,6 +206,7 @@ await CompensateFailedStartAsync( downloadClient, downloadAttemptId, remoteMayHaveAccepted: false); + await PublishDownloadFailureAsync(info, downloadAttemptId, cancellationToken); return BadRequest(); } } @@ -222,12 +225,33 @@ 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 async Task PublishDownloadFailureAsync( + Framework.DataRepository.AnimationInfo info, + Guid downloadAttemptId, + 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); + } + [HttpPost("pause/{id:guid}")] public async Task PauseDownload([FromRoute] Guid id, CancellationToken cancellationToken) { @@ -370,7 +394,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 82251d54..d3c76f47 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs @@ -69,6 +69,16 @@ namespace SecondDimensionWatcherReDive.Controllers.External; [JsonSerializable(typeof(QueueMediaLibraryScanResponse))] [JsonSerializable(typeof(ApplicationSettingsResponse))] [JsonSerializable(typeof(PatchApplicationSettingsRequest))] +[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))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(MigrationRetryResponse))] diff --git a/SecondDimensionWatcherReDive/Controllers/External/ApplicationSettings.cs b/SecondDimensionWatcherReDive/Controllers/External/ApplicationSettings.cs index 1e50bfd8..a59d925d 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; @@ -74,6 +75,18 @@ internal sealed record NfsSettingsResponse( bool RestartRequired, bool PendingRestart); +internal sealed record NotificationSettingsResponse( + bool WebhookEnabled, + bool WebPushEnabled, + string WebPushSubject, + string VapidPublicKey, + SecretStateResponse VapidPrivateKey, + IReadOnlyList Events, + TimeSpan? QuietHoursStart, + TimeSpan? QuietHoursEnd, + string TimeZoneId, + SecretStateResponse WebhookUrl); + internal sealed record ApplicationSettingsResponse( long Revision, bool PendingRestart, @@ -82,77 +95,89 @@ internal sealed record ApplicationSettingsResponse( TorrentSettingsResponse Torrent, MediaLibrarySettingsResponse MediaLibrary, IncidentSettingsResponse Incidents, - NfsSettingsResponse Nfs); + NfsSettingsResponse Nfs, + 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( + [Required] bool? WebhookEnabled, + [Required] bool? WebPushEnabled, + [Required] string? WebPushSubject, + [Required] IReadOnlyList? Events, + TimeSpan? QuietHoursStart, + TimeSpan? QuietHoursEnd, + [Required] string? TimeZoneId, + SecretMutationRequest? WebhookUrl, + bool GenerateVapidKeys = false); internal sealed record PatchApplicationSettingsRequest( long ExpectedRevision, @@ -161,4 +186,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..5ce4def0 --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/External/Notifications.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace SecondDimensionWatcherReDive.Controllers.External; + +internal sealed record NotificationDeliveryItem( + Guid Id, + Guid EventId, + string Channel, + string Type, + string Status, + int AttemptCount, + DateTimeOffset OccurredAt, + DateTimeOffset? LastAttemptAt, + DateTimeOffset? DeliveredAt, + 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, + string EndpointHash, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + DateTimeOffset? LastSuccessAt, + DateTimeOffset? LastFailureAt, + string? LastError); diff --git a/SecondDimensionWatcherReDive/Controllers/External/Todos.cs b/SecondDimensionWatcherReDive/Controllers/External/Todos.cs new file mode 100644 index 00000000..24eae340 --- /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( + [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 new file mode 100644 index 00000000..97f754d5 --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/NotificationsController.cs @@ -0,0 +1,74 @@ +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, + IWebPushSubscriptionRepository? webPushSubscriptions = null) : ControllerBase +{ + [HttpPost("test")] + public async Task> SendTestAsync( + CancellationToken cancellationToken) + { + 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(); + var enqueued = await publisher.PublishAsync(new NotificationEvent( + NotificationEventType.Test, + $"test:{id}", + "SecondDimensionWatcher Re:Dive test", + "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)); + } + + [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, + item.EventId, + item.Channel.ToString(), + 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 2f657b91..caec9d7f 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; } @@ -241,6 +244,31 @@ 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.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( + 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) { if (request is null) @@ -313,7 +341,18 @@ private static ApplicationSettingsResponse ToResponse(RuntimeSettingsState state values.Nfs.AllowAnonymous, values.Nfs.AllowedNetworks, RestartRequired: true, - state.PendingRestart)); + 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, + 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..33574616 --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/TodosController.cs @@ -0,0 +1,120 @@ +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, + [FromQuery] int skip = 0, + [FromQuery] int take = 50, + [FromQuery] string? focus = null, + 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." + }); + 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, + includeSnoozed, + DateTimeOffset.UtcNow, + skip, + take, + focus, + 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) + { + 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/Controllers/WebPushSubscriptionsController.cs b/SecondDimensionWatcherReDive/Controllers/WebPushSubscriptionsController.cs new file mode 100644 index 00000000..5829af36 --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/WebPushSubscriptionsController.cs @@ -0,0 +1,183 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +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; +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 (WebPushSubscriptionLimitExceededException 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), + 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); + 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 new file mode 100644 index 00000000..5aa2d50a --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.Designer.cs @@ -0,0 +1,1073 @@ +// +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("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.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..9ec8a3ab --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260829135234_AddNotificationsAndTodoCenter.cs @@ -0,0 +1,83 @@ +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_AnimationInfo_AutomationDisposition_PublishTime", + table: "AnimationInfo", + columns: new[] { "AutomationDisposition", "PublishTime" }); + + 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.DropIndex( + name: "IX_AnimationInfo_AutomationDisposition_PublishTime", + table: "AnimationInfo"); + + migrationBuilder.DropTable( + name: "NotificationOutboxMessages"); + + migrationBuilder.DropTable( + name: "TodoItemStates"); + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.Designer.cs b/SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.Designer.cs new file mode 100644 index 00000000..9e048fe7 --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260830030124_AddIncidentOccurrences.Designer.cs @@ -0,0 +1,1081 @@ +// +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("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.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/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 4f34cce0..4f0d7bda 100644 --- a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs +++ b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs @@ -260,6 +260,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'"); @@ -596,6 +598,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"); @@ -628,7 +635,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 => @@ -879,6 +889,87 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + 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") @@ -1040,6 +1131,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") @@ -1068,6 +1179,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.AnimationCatalogEntry", b => { b.HasOne("SecondDimensionWatcherReDive.Models.Animation", "Animation") @@ -1079,7 +1239,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Animation"); }); - 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 fb45d096..cf4cf8d4 100644 --- a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs +++ b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs @@ -38,10 +38,97 @@ 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; } + public DbSet WebPushSubscriptions { get; set; } public DbSet AuthenticationStates { 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() + .HasIndex(message => message.WebPushSubscriptionId); + + modelBuilder.Entity() + .Property(message => message.DeduplicationKey) + .HasMaxLength(256); + + modelBuilder.Entity() + .Property(message => message.Type) + .HasConversion() + .HasMaxLength(48); + + modelBuilder.Entity() + .Property(message => message.Channel) + .HasConversion() + .HasMaxLength(24); + + 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(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(); @@ -54,7 +141,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .ToTable(table => table.HasCheckConstraint( "CK_AuthenticationStates_Singleton", "\"Id\" = 1")); - modelBuilder.Entity() .Property(settings => settings.Id) .ValueGeneratedNever(); @@ -262,6 +348,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(); @@ -416,6 +511,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/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/Models/NotificationOutboxMessage.cs b/SecondDimensionWatcherReDive/Models/NotificationOutboxMessage.cs new file mode 100644 index 00000000..52ac7feb --- /dev/null +++ b/SecondDimensionWatcherReDive/Models/NotificationOutboxMessage.cs @@ -0,0 +1,25 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; + +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; + 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/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 36755254..4c46a905 100644 --- a/SecondDimensionWatcherReDive/Program.cs +++ b/SecondDimensionWatcherReDive/Program.cs @@ -27,6 +27,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; @@ -40,6 +41,7 @@ using SecondDimensionWatcherReDive.Utils.FileStore; using SecondDimensionWatcherReDive.Utils.MetadataReview; using SecondDimensionWatcherReDive.Utils.Incidents; +using SecondDimensionWatcherReDive.Utils.Notifications; using SecondDimensionWatcherReDive.Utils.Http; using SecondDimensionWatcherReDive.Utils.Scraper; using SecondDimensionWatcherReDive.Utils.Spa; @@ -364,6 +366,62 @@ void ConfigureFeedClient(HttpClient client) }; }); +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(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 => + { + 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) + }; + }); builder.Services.AddOptions() .BindConfiguration(TmdbImageProxyOptions.SectionName) .Validate( @@ -419,12 +477,14 @@ void ConfigureFeedClient(HttpClient client) // 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()); @@ -484,6 +544,9 @@ 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(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs b/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs index d4b730eb..8e42b65c 100644 --- a/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs @@ -534,6 +534,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 @@ -575,14 +581,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 @@ -592,6 +591,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); } @@ -686,6 +699,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; } @@ -736,13 +755,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); } @@ -773,6 +799,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 @@ -790,8 +822,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 1e7d645b..c89143e6 100644 --- a/SecondDimensionWatcherReDive/Repositories/IncidentRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/IncidentRepository.cs @@ -76,15 +76,9 @@ 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; + if (entity.ResolvedAt is not null) + await RemoveTodoStateAsync(entity, cancellationToken); + ApplyReport(entity, incident); } try @@ -100,13 +94,9 @@ 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; + if (entity.ResolvedAt is not null) + await RemoveTodoStateAsync(entity, cancellationToken); + ApplyReport(entity, incident); await context.SaveChangesAsync(cancellationToken); } return ToRecord(entity); @@ -123,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; @@ -132,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, @@ -139,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) @@ -158,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); @@ -177,7 +196,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 +213,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/MetadataReviewRepository.cs b/SecondDimensionWatcherReDive/Repositories/MetadataReviewRepository.cs index ad1a6ecd..c55be0f3 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; @@ -551,6 +567,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 new file mode 100644 index 00000000..01b4ad33 --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/NotificationOutboxRepository.cs @@ -0,0 +1,170 @@ +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( + TimeSpan leaseDuration, + int take, + CancellationToken cancellationToken) + { + 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() + .ToListAsync(cancellationToken)) + .Select(ToRecord) + .ToList(); + } + + public async Task MarkDeliveredAsync( + Guid id, + DateTimeOffset expectedLeaseUntil, + DateTimeOffset deliveredAt, + CancellationToken cancellationToken) => + 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) == 1; + + public async Task MarkFailedAsync( + Guid id, + DateTimeOffset expectedLeaseUntil, + int attemptCount, + DateTimeOffset attemptedAt, + DateTimeOffset? nextAttemptAt, + string error, + CancellationToken cancellationToken) => + await context.NotificationOutboxMessages + .Where(message => message.Id == id + && message.Status == NotificationDeliveryStatus.Processing + && message.NextAttemptAt == expectedLeaseUntil) + .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) == 1; + + public async Task RescheduleAsync( + Guid id, + DateTimeOffset expectedLeaseUntil, + DateTimeOffset nextAttemptAt, + CancellationToken cancellationToken) => + 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) == 1; + + public async Task> GetRecentAsync( + int take, + 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, + 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, + EventId = message.EventId, + DeduplicationKey = message.DeduplicationKey, + Channel = message.Channel, + WebPushSubscriptionId = message.WebPushSubscriptionId, + 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..07e3f31f --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/TodoRepository.cs @@ -0,0 +1,297 @@ +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, + int skip, + int take, + string? focusKey, + CancellationToken cancellationToken) + { + 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 + { + 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 + }; + + 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, + Title = incident.Title, + Detail = incident.Detail, + DeepLink = incident.Type == IncidentType.DiskSpaceLow + ? "/incidents?type=diskSpaceLow&focus=" + incident.Id.ToString() + : "/incidents?focus=" + incident.Id.ToString(), + ResourceId = incident.Id, + OccurredAt = incident.UpdatedAt, + ReadAt = state == null ? null : state.ReadAt, + SnoozedUntil = state == null ? null : state.SnoozedUntil + }; + + 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, + 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 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) + .ThenBy(item => item.Key) + .Skip(skip) + .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, + 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( + IReadOnlyCollection keys, + DateTimeOffset? readAt, + bool updateReadAt, + DateTimeOffset? snoozedUntil, + bool updateSnoozedUntil, + CancellationToken cancellationToken) + { + var validKeys = await ResolveCurrentKeysAsync(keys, cancellationToken); + if (validKeys.Length == 0) + return; + + var now = DateTimeOffset.UtcNow; + // 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) + { + var parts = key.Split(':'); + if (parts.Length < 2 || !Guid.TryParse(parts[1], out var id)) + continue; + switch (parts[0]) + { + case "automation": + automationIds.Add(id); + break; + case "metadata": + metadataIds.Add(id); + break; + case "incident": + incidentIds.Add(id); + break; + } + } + + 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 + { + 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/WebPushSubscriptionRepository.cs b/SecondDimensionWatcherReDive/Repositories/WebPushSubscriptionRepository.cs new file mode 100644 index 00000000..dfe6d97b --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/WebPushSubscriptionRepository.cs @@ -0,0 +1,161 @@ +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 Framework.DataRepository.WebPushSubscriptionLimitExceededException(); + + 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/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/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/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 14c8c6b6..01c51af1 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; using SecondDimensionWatcherReDive.Utils.Http; using SecondDimensionWatcherReDive.Utils.Feed; @@ -22,7 +23,8 @@ internal partial class SyncFeed( ISafeOutboundHttpFetcher outboundFetcher, IServiceScopeFactory scopeFactory, ISubscriptionAutomationMatcher automationMatcher, - IIncidentReporter? incidentReporter = null) + IIncidentReporter? incidentReporter = null, + INotificationPublisher? notificationPublisher = null) : ScheduledTaskBase { private static readonly JsonSerializerOptions ExplanationJsonOptions = new(JsonSerializerDefaults.Web); @@ -188,6 +190,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, @@ -195,11 +219,32 @@ 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) + { + // 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); + } + } + } } catch (InvalidTorrentDataException e) { @@ -218,7 +263,7 @@ await incidentReporter.ReportAsync(new IncidentReport( } } - private async Task QueueAutomaticDownloadAsync( + private async Task QueueAutomaticDownloadAsync( AnimationInfo info, IAnimationInfoRepository animationInfoRepository, IFileDownloadClientProvider downloadClientProvider, @@ -237,7 +282,7 @@ private async Task QueueAutomaticDownloadAsync( cancellationToken)) { LogAutomaticDownloadWarning(logger, info.Title, "download state changed"); - return; + return false; } submissionAttempted = true; @@ -255,7 +300,9 @@ await CompensateAutomaticStartAsync( downloadAttemptId, remoteMayHaveAccepted: false); LogAutomaticDownloadWarning(logger, info.Title, "download client rejected the task"); + return false; } + return true; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -290,6 +337,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..11b231d6 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,29 @@ 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; + 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, + deduplicationKey, + saved.Title, + saved.Detail, + isDiskSpaceLow + ? $"/incidents?type=diskSpaceLow&focus={saved.Id}" + : $"/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..1d4082c2 --- /dev/null +++ b/SecondDimensionWatcherReDive/Utils/Notifications/NotificationDeliveryBackgroundService.cs @@ -0,0 +1,585 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Utils.Http; +using WebPush; + +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; + // 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(3); + 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) + { + 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 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 (message.Type != Framework.Notifications.NotificationEventType.Test + && IsQuietHours(now)) + { + 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, message.NextAttemptAt, now.AddMinutes(5), cancellationToken); + return; + } + + var attempt = message.AttemptCount + 1; + try + { + 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, 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.EventId, + message.Channel, + message.Type, + (int)response.StatusCode, + retry); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + var retry = attempt < MaxAttempts; + await repository.MarkFailedAsync( + message.Id, + message.NextAttemptAt, + 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.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); + } + } + + 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.EventId, + type = char.ToLowerInvariant(message.Type.ToString()[0]) + message.Type.ToString()[1..], + message.Title, + message.Body, + message.DeepLink, + message.OccurredAt, + payload + }, 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"); + 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} via {NotificationChannel} ({NotificationType})")] + private static partial void LogDelivered( + ILogger logger, + Guid notificationId, + NotificationChannel notificationChannel, + Framework.Notifications.NotificationEventType notificationType); + + [LoggerMessage(Level = LogLevel.Warning, + 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} 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 new file mode 100644 index 00000000..e48b5952 --- /dev/null +++ b/SecondDimensionWatcherReDive/Utils/Notifications/NotificationPublisher.cs @@ -0,0 +1,281 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; + +namespace SecondDimensionWatcherReDive.Utils.Notifications; + +public sealed partial class NotificationPublisher( + IServiceScopeFactory scopeFactory, + IConfiguration configuration, + ILogger logger) : INotificationPublisher +{ + private const int MaxDeduplicationKeyLength = 256; + private const int MaxPayloadBytes = 64 * 1024; + + public async Task PublishAsync( + NotificationEvent notificationEvent, + CancellationToken cancellationToken) + { + 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 false; + + try + { + var fallbackEventId = 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, + 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; + + if (webhookEnabled) + { + enqueued |= await TryEnqueueChannelAsync( + async () => + { + await using var webhookScope = scopeFactory.CreateAsyncScope(); + var outbox = webhookScope.ServiceProvider + .GetRequiredService(); + return await EnqueueTargetAsync( + outbox, + new NotificationOutboxMessage( + Guid.NewGuid(), + 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); + } + + if (webPushEnabled) + { + 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( + Guid.NewGuid(), + eventId, + targetDeduplicationKey, + NotificationChannel.WebPush, + subscription.Id, + notificationEvent.Type, + title, + body, + deepLink, + payload, + occurredAt, + NotificationDeliveryStatus.Pending, + 0, + occurredAt, + null, + null, + null), + cancellationToken); + } + return any; + }, + notificationEvent.Type, + cancellationToken); + } + + if (!enqueued) + LogDuplicateSkipped(logger, notificationEvent.Type); + return enqueued; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + // 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; + } + } + + 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 + : 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 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, + 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, + 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 bf778be1..40c72f79 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": [] }, @@ -136,6 +137,29 @@ } }, + // 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, + "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/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1e619421..1ac97ea9 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 1a597b50..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,6 +115,23 @@ Incidents: MinimumAvailableBytes: 5368709120 # 5 GiB MinimumAvailablePercent: 5 +# 通知 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: + End: + TimeZone: "UTC" + # NFSv4 只读导出(可选,默认关闭) # 客户端示例: sudo mount -t nfs4 -o vers=4,nolock,port=2049 host:/ /mnt # Linux 上 2049 为特权端口,容器中需显式发布或改为非特权端口