diff --git a/package-lock.json b/package-lock.json index 0185eb06..36e0c89f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,6 +86,7 @@ "@types/node": "^22.19.7", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", + "@types/web-push": "^3.6.4", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.6.0", "auto-changelog": "^2.5.0", @@ -3997,6 +3998,16 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", diff --git a/package.json b/package.json index 8373b11c..1f3cb053 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "@types/node": "^22.19.7", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", + "@types/web-push": "^3.6.4", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.6.0", "auto-changelog": "^2.5.0", diff --git a/server/src/modules/agent/agent.routes.js b/server/src/modules/agent/agent.routes.js index 40eb9487..a8212454 100644 --- a/server/src/modules/agent/agent.routes.js +++ b/server/src/modules/agent/agent.routes.js @@ -4,7 +4,9 @@ import path from 'path'; import os from 'os'; import { promises as fs } from 'fs'; import crypto from 'crypto'; -import { userDb, apiKeysDb, githubTokensDb } from '../../../database/db.js'; +import { userDb } from '@/shared/database/repositories/users.js'; +import { apiKeysDb } from '@/shared/database/repositories/api-keys.js'; +import { githubTokensDb } from '@/shared/database/repositories/github-tokens.js'; import { addProjectManually } from '../../../projects.js'; import { queryClaudeSDK } from '../../../claude-sdk.js'; import { spawnCursor } from '../../../cursor-cli.js'; diff --git a/server/src/modules/api-keys/api-keys.routes.js b/server/src/modules/api-keys/api-keys.routes.js index f1e140d2..66c39b91 100644 --- a/server/src/modules/api-keys/api-keys.routes.js +++ b/server/src/modules/api-keys/api-keys.routes.js @@ -1,5 +1,5 @@ import express from 'express'; -import { apiKeysDb } from '../../../database/db.js'; +import { apiKeysDb } from '@/shared/database/repositories/api-keys.js'; const router = express.Router(); diff --git a/server/src/modules/codex/codex.routes.js b/server/src/modules/codex/codex.routes.js index e23bdd0b..2194e1a4 100644 --- a/server/src/modules/codex/codex.routes.js +++ b/server/src/modules/codex/codex.routes.js @@ -5,7 +5,7 @@ import path from 'path'; import os from 'os'; import TOML from '@iarna/toml'; import { getCodexSessions, deleteCodexSession } from '../../../projects.js'; -import { applyCustomSessionNames, sessionNamesDb } from '../../../database/db.js'; +import { applyCustomSessionNames, sessionNamesDb } from '@/shared/database/repositories/session-names.js'; const router = express.Router(); @@ -72,7 +72,7 @@ router.delete('/sessions/:sessionId', async (req, res) => { try { const { sessionId } = req.params; await deleteCodexSession(sessionId); - sessionNamesDb.deleteName(sessionId, 'codex'); + sessionNamesDb.deleteSessionName(sessionId, 'codex'); res.json({ success: true }); } catch (error) { console.error(`Error deleting Codex session ${req.params.sessionId}:`, error); diff --git a/server/src/modules/credentials/credentials.routes.js b/server/src/modules/credentials/credentials.routes.js index 7a177f6b..8880d8e4 100644 --- a/server/src/modules/credentials/credentials.routes.js +++ b/server/src/modules/credentials/credentials.routes.js @@ -1,5 +1,5 @@ import express from 'express'; -import { credentialsDb } from '../../../database/db.js'; +import { credentialsDb } from '@/shared/database/repositories/credentials.js'; const router = express.Router(); diff --git a/server/src/modules/cursor/cursor.routes.js b/server/src/modules/cursor/cursor.routes.js index 8f618611..e8c80c91 100644 --- a/server/src/modules/cursor/cursor.routes.js +++ b/server/src/modules/cursor/cursor.routes.js @@ -7,7 +7,7 @@ import sqlite3 from 'sqlite3'; import { open } from 'sqlite'; import crypto from 'crypto'; import { CURSOR_MODELS } from '../../../../shared/modelConstants.js'; -import { applyCustomSessionNames } from '../../../database/db.js'; +import { applyCustomSessionNames } from '@/shared/database/repositories/session-names.js'; const router = express.Router(); @@ -795,4 +795,4 @@ router.get('/sessions/:sessionId', async (req, res) => { } }); -export default router; \ No newline at end of file +export default router; diff --git a/server/src/modules/gemini/gemini.routes.js b/server/src/modules/gemini/gemini.routes.js index 64408185..b93e342f 100644 --- a/server/src/modules/gemini/gemini.routes.js +++ b/server/src/modules/gemini/gemini.routes.js @@ -1,6 +1,6 @@ import express from 'express'; import sessionManager from '../../../sessionManager.js'; -import { sessionNamesDb } from '../../../database/db.js'; +import { sessionNamesDb } from '@/shared/database/repositories/session-names.js'; const router = express.Router(); @@ -13,7 +13,7 @@ router.delete('/sessions/:sessionId', async (req, res) => { } await sessionManager.deleteSession(sessionId); - sessionNamesDb.deleteName(sessionId, 'gemini'); + sessionNamesDb.deleteSessionName(sessionId, 'gemini'); res.json({ success: true }); } catch (error) { console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error); diff --git a/server/src/modules/notification-preferences/notification-preferences.routes.js b/server/src/modules/notification-preferences/notification-preferences.routes.js index 4d6d1652..c5446fef 100644 --- a/server/src/modules/notification-preferences/notification-preferences.routes.js +++ b/server/src/modules/notification-preferences/notification-preferences.routes.js @@ -1,5 +1,5 @@ import express from 'express'; -import { notificationPreferencesDb } from '../../../database/db.js'; +import { notificationPreferencesDb } from '@/shared/database/repositories/notification-preferences.js'; const router = express.Router(); @@ -9,7 +9,7 @@ const router = express.Router(); router.get('/', async (req, res) => { try { - const preferences = notificationPreferencesDb.getPreferences(req.user.id); + const preferences = notificationPreferencesDb.getNotificationPreferences(req.user.id); res.json({ success: true, preferences }); } catch (error) { console.error('Error fetching notification preferences:', error); @@ -19,7 +19,7 @@ router.get('/', async (req, res) => { router.put('/', async (req, res) => { try { - const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {}); + const preferences = notificationPreferencesDb.updateNotificationPreferences(req.user.id, req.body || {}); res.json({ success: true, preferences }); } catch (error) { console.error('Error saving notification preferences:', error); diff --git a/server/src/modules/projects/projects.inline.routes.js b/server/src/modules/projects/projects.inline.routes.js index 4e1f770d..21ce852c 100644 --- a/server/src/modules/projects/projects.inline.routes.js +++ b/server/src/modules/projects/projects.inline.routes.js @@ -11,7 +11,7 @@ import { addProjectManually, searchConversations } from '../../../projects.js'; -import { applyCustomSessionNames, sessionNamesDb } from '../../../database/db.js'; +import { applyCustomSessionNames, sessionNamesDb } from '@/shared/database/repositories/session-names.js'; import { authenticateToken } from '../auth/auth.middleware.js'; import { WORKSPACES_ROOT, validateWorkspacePath } from './projects.routes.js'; @@ -70,7 +70,7 @@ router.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToke const { projectName, sessionId } = req.params; console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`); await deleteSession(projectName, sessionId); - sessionNamesDb.deleteName(sessionId, 'claude'); + sessionNamesDb.deleteSessionName(sessionId, 'claude'); console.log(`[API] Session ${sessionId} deleted successfully`); res.json({ success: true }); } catch (error) { diff --git a/server/src/modules/projects/projects.routes.js b/server/src/modules/projects/projects.routes.js index ff9b6983..08b2822c 100644 --- a/server/src/modules/projects/projects.routes.js +++ b/server/src/modules/projects/projects.routes.js @@ -4,6 +4,7 @@ import path from 'path'; import { spawn } from 'child_process'; import os from 'os'; import { addProjectManually } from '../../../projects.js'; +import { githubTokensDb } from '@/shared/database/repositories/github-tokens.js'; const router = express.Router(); @@ -311,21 +312,7 @@ router.post('/create-workspace', async (req, res) => { * Helper function to get GitHub token from database */ async function getGithubTokenById(tokenId, userId) { - const { db } = await import('../../../database/db.js'); - - const credential = db.prepare( - 'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1' - ).get(tokenId, userId, 'github_token'); - - // Return in the expected format (github_token field for compatibility) - if (credential) { - return { - ...credential, - github_token: credential.credential_value - }; - } - - return null; + return githubTokensDb.getGithubTokenById(userId, Number.parseInt(String(tokenId), 10)); } /** diff --git a/server/src/modules/push-sub/push-sub.routes.js b/server/src/modules/push-sub/push-sub.routes.js index 0bf4d881..c79a4996 100644 --- a/server/src/modules/push-sub/push-sub.routes.js +++ b/server/src/modules/push-sub/push-sub.routes.js @@ -1,7 +1,8 @@ import express from 'express'; -import { notificationPreferencesDb, pushSubscriptionsDb } from '../../../database/db.js'; -import { getPublicKey } from '../../../services/vapid-keys.js'; -import { createNotificationEvent, notifyUserIfEnabled } from '../../../services/notification-orchestrator.js'; +import { notificationPreferencesDb } from '@/shared/database/repositories/notification-preferences.js'; +import { pushSubscriptionsDb } from '@/shared/database/repositories/push-subscriptions.js'; +import { getPublicKey } from '@/modules/push-sub/push-sub.services.js'; +import { createNotificationEvent, notifyUserIfEnabled } from '@/services/notification-orchestrator.js'; const router = express.Router(); @@ -25,12 +26,12 @@ router.post('/subscribe', async (req, res) => { if (!endpoint || !keys?.p256dh || !keys?.auth) { return res.status(400).json({ error: 'Missing subscription fields' }); } - pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth); + pushSubscriptionsDb.createPushSubscription(req.user.id, endpoint, keys.p256dh, keys.auth); // Enable webPush in preferences so the confirmation goes through the full pipeline - const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id); + const currentPrefs = notificationPreferencesDb.getNotificationPreferences(req.user.id); if (!currentPrefs?.channels?.webPush) { - notificationPreferencesDb.updatePreferences(req.user.id, { + notificationPreferencesDb.updateNotificationPreferences(req.user.id, { ...currentPrefs, channels: { ...currentPrefs?.channels, webPush: true }, }); @@ -59,12 +60,12 @@ router.post('/unsubscribe', async (req, res) => { if (!endpoint) { return res.status(400).json({ error: 'Missing endpoint' }); } - pushSubscriptionsDb.removeSubscription(endpoint); + pushSubscriptionsDb.deletePushSubscription(endpoint); // Disable webPush in preferences to match subscription state - const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id); + const currentPrefs = notificationPreferencesDb.getNotificationPreferences(req.user.id); if (currentPrefs?.channels?.webPush) { - notificationPreferencesDb.updatePreferences(req.user.id, { + notificationPreferencesDb.updateNotificationPreferences(req.user.id, { ...currentPrefs, channels: { ...currentPrefs.channels, webPush: false }, }); diff --git a/server/src/modules/push-sub/push-sub.services.ts b/server/src/modules/push-sub/push-sub.services.ts new file mode 100644 index 00000000..3e237f4c --- /dev/null +++ b/server/src/modules/push-sub/push-sub.services.ts @@ -0,0 +1,42 @@ +import webPush from 'web-push'; + +import { vapidKeysDb } from '@/shared/database/repositories/vapid-keys.js'; + +type VapidKeyPair = { + publicKey: string; + privateKey: string; +}; + +let cachedKeys: VapidKeyPair | null = null; + +function ensureVapidKeys(): VapidKeyPair { + if (cachedKeys) return cachedKeys; + + const existingKeys = vapidKeysDb.getVapidKeys(); + if (existingKeys) { + cachedKeys = existingKeys; + return existingKeys; + } + + const generatedKeys = webPush.generateVAPIDKeys(); + vapidKeysDb.createVapidKeys(generatedKeys.publicKey, generatedKeys.privateKey); + cachedKeys = generatedKeys; + return generatedKeys; +} + +function getPublicKey(): string { + return ensureVapidKeys().publicKey; +} + +function configureWebPush(): void { + const keys = ensureVapidKeys(); + webPush.setVapidDetails( + 'mailto:noreply@claudecodeui.local', + keys.publicKey, + keys.privateKey + ); + console.log('Web Push notifications configured'); +} + +export { ensureVapidKeys, getPublicKey, configureWebPush }; + diff --git a/server/src/modules/sessions/sessions.inline.routes.js b/server/src/modules/sessions/sessions.inline.routes.js index 3e225050..03fe1c4c 100644 --- a/server/src/modules/sessions/sessions.inline.routes.js +++ b/server/src/modules/sessions/sessions.inline.routes.js @@ -2,7 +2,7 @@ import express from 'express'; import path from 'path'; import os from 'os'; import { promises as fsPromises } from 'fs'; -import { sessionNamesDb } from '../../../database/db.js'; +import { sessionNamesDb } from '@/shared/database/repositories/session-names.js'; import { extractProjectDirectory } from '../../../projects.js'; import { authenticateToken } from '../auth/auth.middleware.js'; @@ -27,7 +27,7 @@ router.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res if (!provider || !VALID_PROVIDERS.includes(provider)) { return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` }); } - sessionNamesDb.setName(safeSessionId, provider, summary.trim()); + sessionNamesDb.createSessionName(safeSessionId, provider, summary.trim()); res.json({ success: true }); } catch (error) { console.error(`[API] Error renaming session ${req.params.sessionId}:`, error); diff --git a/server/src/modules/settings/settings.routes.js b/server/src/modules/settings/settings.routes.js index 685d2460..34b75d9d 100644 --- a/server/src/modules/settings/settings.routes.js +++ b/server/src/modules/settings/settings.routes.js @@ -1,7 +1,10 @@ import express from 'express'; -import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../../../database/db.js'; -import { getPublicKey } from '../../../services/vapid-keys.js'; -import { createNotificationEvent, notifyUserIfEnabled } from '../../../services/notification-orchestrator.js'; +import { apiKeysDb } from '@/shared/database/repositories/api-keys.js'; +import { credentialsDb } from '@/shared/database/repositories/credentials.js'; +import { notificationPreferencesDb } from '@/shared/database/repositories/notification-preferences.js'; +import { pushSubscriptionsDb } from '@/shared/database/repositories/push-subscriptions.js'; +import { getPublicKey } from '@/modules/push-sub/push-sub.services.js'; +import { createNotificationEvent, notifyUserIfEnabled } from '@/services/notification-orchestrator.js'; const router = express.Router(); @@ -183,7 +186,7 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => { router.get('/notification-preferences', async (req, res) => { try { - const preferences = notificationPreferencesDb.getPreferences(req.user.id); + const preferences = notificationPreferencesDb.getNotificationPreferences(req.user.id); res.json({ success: true, preferences }); } catch (error) { console.error('Error fetching notification preferences:', error); @@ -193,7 +196,7 @@ router.get('/notification-preferences', async (req, res) => { router.put('/notification-preferences', async (req, res) => { try { - const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {}); + const preferences = notificationPreferencesDb.updateNotificationPreferences(req.user.id, req.body || {}); res.json({ success: true, preferences }); } catch (error) { console.error('Error saving notification preferences:', error); @@ -221,12 +224,12 @@ router.post('/push/subscribe', async (req, res) => { if (!endpoint || !keys?.p256dh || !keys?.auth) { return res.status(400).json({ error: 'Missing subscription fields' }); } - pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth); + pushSubscriptionsDb.createPushSubscription(req.user.id, endpoint, keys.p256dh, keys.auth); // Enable webPush in preferences so the confirmation goes through the full pipeline - const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id); + const currentPrefs = notificationPreferencesDb.getNotificationPreferences(req.user.id); if (!currentPrefs?.channels?.webPush) { - notificationPreferencesDb.updatePreferences(req.user.id, { + notificationPreferencesDb.updateNotificationPreferences(req.user.id, { ...currentPrefs, channels: { ...currentPrefs?.channels, webPush: true }, }); @@ -255,12 +258,12 @@ router.post('/push/unsubscribe', async (req, res) => { if (!endpoint) { return res.status(400).json({ error: 'Missing endpoint' }); } - pushSubscriptionsDb.removeSubscription(endpoint); + pushSubscriptionsDb.deletePushSubscription(endpoint); // Disable webPush in preferences to match subscription state - const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id); + const currentPrefs = notificationPreferencesDb.getNotificationPreferences(req.user.id); if (currentPrefs?.channels?.webPush) { - notificationPreferencesDb.updatePreferences(req.user.id, { + notificationPreferencesDb.updateNotificationPreferences(req.user.id, { ...currentPrefs, channels: { ...currentPrefs.channels, webPush: false }, }); diff --git a/server/src/runner.ts b/server/src/runner.ts index 430f3f69..1f70e455 100644 --- a/server/src/runner.ts +++ b/server/src/runner.ts @@ -9,6 +9,7 @@ import { fileURLToPath } from 'url'; import { initializeDatabase } from '@/shared/database/init-db.js'; import { initializeWatcher } from '@/modules/sessions/sessions.watcher.js'; +import { configureWebPush } from '@/modules/push-sub/push-sub.services.js'; import { getConnectableHost } from '@/shared/utils/networkHosts.js'; import { logger } from '@/shared/utils/logger.js'; import { authRoutes } from '@/modules/auth/auth.routes.js'; @@ -234,6 +235,7 @@ app.get('*', (req, res) => { async function main() { try { await initializeDatabase(); + configureWebPush(); server.listen(SERVER_PORT, HOST, async () => { const appInstallPath = path.join(__dirname, '../..'); diff --git a/server/src/services/notification-orchestrator.ts b/server/src/services/notification-orchestrator.ts new file mode 100644 index 00000000..6b4d5bb2 --- /dev/null +++ b/server/src/services/notification-orchestrator.ts @@ -0,0 +1,317 @@ +import webPush from 'web-push'; + +import { notificationPreferencesDb } from '@/shared/database/repositories/notification-preferences.js'; +import { pushSubscriptionsDb } from '@/shared/database/repositories/push-subscriptions.js'; +import { sessionNamesDb } from '@/shared/database/repositories/session-names.js'; + +type NotificationKind = 'action_required' | 'stop' | 'error' | 'info' | string; + +type NotificationEvent = { + provider: string; + sessionId?: string | null; + kind?: NotificationKind; + code?: string; + meta?: Record; + severity?: string; + dedupeKey?: string | null; + requiresUserAction?: boolean; + createdAt?: string; +}; + +type NotificationPreferences = { + channels?: { + inApp?: boolean; + webPush?: boolean; + }; + events?: { + actionRequired?: boolean; + stop?: boolean; + error?: boolean; + }; +}; + +const KIND_TO_PREF_KEY: Record> = { + action_required: 'actionRequired', + stop: 'stop', + error: 'error', +}; + +const PROVIDER_LABELS: Record = { + claude: 'Claude', + cursor: 'Cursor', + codex: 'Codex', + gemini: 'Gemini', + system: 'System', +}; + +const recentEventKeys = new Map(); +const DEDUPE_WINDOW_MS = 20_000; + +const cleanupOldEventKeys = (): void => { + const now = Date.now(); + for (const [key, timestamp] of recentEventKeys.entries()) { + if (now - timestamp > DEDUPE_WINDOW_MS) { + recentEventKeys.delete(key); + } + } +}; + +function shouldSendPush( + preferences: NotificationPreferences | null | undefined, + event: NotificationEvent +): boolean { + const webPushEnabled = Boolean(preferences?.channels?.webPush); + const prefEventKey = KIND_TO_PREF_KEY[event.kind ?? '']; + const eventEnabled = prefEventKey + ? Boolean(preferences?.events?.[prefEventKey]) + : true; + + return webPushEnabled && eventEnabled; +} + +function isDuplicate(event: NotificationEvent): boolean { + cleanupOldEventKeys(); + const key = + event.dedupeKey ?? + `${event.provider}:${event.kind ?? 'info'}:${event.code ?? 'generic'}:${event.sessionId ?? 'none'}`; + + if (recentEventKeys.has(key)) { + return true; + } + recentEventKeys.set(key, Date.now()); + return false; +} + +function normalizeErrorMessage(error: unknown): string { + if (typeof error === 'string') { + return error; + } + + if ( + error && + typeof error === 'object' && + 'message' in error && + typeof (error as { message?: unknown }).message === 'string' + ) { + return (error as { message: string }).message; + } + + if (error == null) { + return 'Unknown error'; + } + + return String(error); +} + +function normalizeSessionName(sessionName: unknown): string | null { + if (typeof sessionName !== 'string') { + return null; + } + + const normalized = sessionName.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return null; + } + + return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; +} + +function resolveSessionName(event: NotificationEvent): string | null { + const explicitSessionName = normalizeSessionName(event.meta?.sessionName); + if (explicitSessionName) { + return explicitSessionName; + } + + if (!event.sessionId || !event.provider) { + return null; + } + + return normalizeSessionName(sessionNamesDb.getSessionName(event.sessionId, event.provider)); +} + +function buildPushBody(event: NotificationEvent) { + const codeMap: Record = { + 'permission.required': event.meta?.toolName + ? `Action Required: Tool "${String(event.meta.toolName)}" needs approval` + : 'Action Required: A tool needs your approval', + 'run.stopped': + (typeof event.meta?.stopReason === 'string' && event.meta.stopReason) || + 'Run Stopped: The run has stopped', + 'run.failed': event.meta?.error + ? `Run Failed: ${String(event.meta.error)}` + : 'Run Failed: The run encountered an error', + 'agent.notification': event.meta?.message + ? String(event.meta.message) + : 'You have a new notification', + 'push.enabled': 'Push notifications are now enabled!', + }; + + const providerLabel = PROVIDER_LABELS[event.provider] ?? 'Assistant'; + const sessionName = resolveSessionName(event); + const message = codeMap[event.code ?? ''] ?? 'You have a new notification'; + + return { + title: sessionName ?? 'Claude Code UI', + body: `${providerLabel}: ${message}`, + data: { + sessionId: event.sessionId ?? null, + code: event.code ?? null, + provider: event.provider ?? null, + sessionName, + tag: `${event.provider ?? 'assistant'}:${event.sessionId ?? 'none'}:${event.code ?? 'generic.info'}`, + }, + }; +} + +async function sendWebPush(userId: number, event: NotificationEvent): Promise { + const subscriptions = pushSubscriptionsDb.getPushSubscriptions(userId); + if (!subscriptions.length) return; + + const payload = JSON.stringify(buildPushBody(event)); + + const results = await Promise.allSettled( + subscriptions.map((sub) => + webPush.sendNotification( + { + endpoint: sub.endpoint, + keys: { + p256dh: sub.keys_p256dh, + auth: sub.keys_auth, + }, + }, + payload + ) + ) + ); + + // Clean up gone subscriptions (410 Gone or 404). + results.forEach((result, index) => { + if (result.status === 'rejected') { + const statusCode = (result.reason as { statusCode?: number } | undefined) + ?.statusCode; + if (statusCode === 410 || statusCode === 404) { + pushSubscriptionsDb.deletePushSubscription(subscriptions[index].endpoint); + } + } + }); +} + +function createNotificationEvent({ + provider, + sessionId = null, + kind = 'info', + code = 'generic.info', + meta = {}, + severity = 'info', + dedupeKey = null, + requiresUserAction = false, +}: { + provider: string; + sessionId?: string | null; + kind?: NotificationKind; + code?: string; + meta?: Record; + severity?: string; + dedupeKey?: string | null; + requiresUserAction?: boolean; +}): NotificationEvent { + return { + provider, + sessionId, + kind, + code, + meta, + severity, + requiresUserAction, + dedupeKey, + createdAt: new Date().toISOString(), + }; +} + +function notifyUserIfEnabled({ + userId, + event, +}: { + userId: number | null | undefined; + event: NotificationEvent | null | undefined; +}): void { + if (!userId || !event) { + return; + } + + const preferences = notificationPreferencesDb.getNotificationPreferences(userId); + if (!shouldSendPush(preferences, event)) { + return; + } + if (isDuplicate(event)) { + return; + } + + sendWebPush(userId, event).catch((error) => { + console.error('Web push send error:', error); + }); +} + +function notifyRunStopped({ + userId, + provider, + sessionId = null, + stopReason = 'completed', + sessionName = null, +}: { + userId: number; + provider: string; + sessionId?: string | null; + stopReason?: string; + sessionName?: string | null; +}): void { + notifyUserIfEnabled({ + userId, + event: createNotificationEvent({ + provider, + sessionId, + kind: 'stop', + code: 'run.stopped', + meta: { stopReason, sessionName }, + severity: 'info', + dedupeKey: `${provider}:run:stop:${sessionId ?? 'none'}:${stopReason}`, + }), + }); +} + +function notifyRunFailed({ + userId, + provider, + sessionId = null, + error, + sessionName = null, +}: { + userId: number; + provider: string; + sessionId?: string | null; + error: unknown; + sessionName?: string | null; +}): void { + const errorMessage = normalizeErrorMessage(error); + + notifyUserIfEnabled({ + userId, + event: createNotificationEvent({ + provider, + sessionId, + kind: 'error', + code: 'run.failed', + meta: { error: errorMessage, sessionName }, + severity: 'error', + dedupeKey: `${provider}:run:error:${sessionId ?? 'none'}:${errorMessage}`, + }), + }); +} + +export { + createNotificationEvent, + notifyUserIfEnabled, + notifyRunStopped, + notifyRunFailed, +}; + diff --git a/server/src/shared/database/migrations.ts b/server/src/shared/database/migrations.ts index e671af7d..7b4b391b 100644 --- a/server/src/shared/database/migrations.ts +++ b/server/src/shared/database/migrations.ts @@ -1,5 +1,14 @@ import { Database } from "better-sqlite3"; -import { APP_CONFIG_TABLE_SCHEMA_SQL, LAST_SCANNED_AT_SQL, SESSIONS_TABLE_SCHEMA_SQL, WORK_SPACE_PATH_SQL } from "@/shared/database/schema.js"; +import { + APP_CONFIG_TABLE_SCHEMA_SQL, + LAST_SCANNED_AT_SQL, + PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL, + SESSION_NAMES_TABLE_SCHEMA_SQL, + SESSIONS_TABLE_SCHEMA_SQL, + USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL, + VAPID_KEYS_TABLE_SCHEMA_SQL, + WORK_SPACE_PATH_SQL +} from "@/shared/database/schema.js"; import { logger } from "@/shared/utils/logger.js"; const addColumnToTableIfNotExists = ( @@ -30,6 +39,16 @@ export const runMigrations = (db: Database) => { // Create app_config table if it doesn't exist (for existing installations) db.exec(APP_CONFIG_TABLE_SCHEMA_SQL); + // Create notification and push tables if they don't exist (for existing installations) + db.exec(USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL); + db.exec(VAPID_KEYS_TABLE_SCHEMA_SQL); + db.exec(PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL); + db.exec("CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)"); + + // Create session_names table if it doesn't exist (for existing installations) + db.exec(SESSION_NAMES_TABLE_SCHEMA_SQL); + db.exec("CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider)"); + // Create sessions table if it doesn't exist (for existing installations) db.exec(SESSIONS_TABLE_SCHEMA_SQL); db.exec( diff --git a/server/src/shared/database/repositories/github-tokens.ts b/server/src/shared/database/repositories/github-tokens.ts new file mode 100644 index 00000000..a5af3d0a --- /dev/null +++ b/server/src/shared/database/repositories/github-tokens.ts @@ -0,0 +1,90 @@ +/** + * GitHub tokens repository. + * + * Backward-compatible helper layer over generic credentials storage. + * Tokens are stored in `user_credentials` with `credential_type = 'github_token'`. + */ + +import { getConnection } from '@/shared/database/connection.js'; +import { credentialsDb } from '@/shared/database/repositories/credentials.js'; +import type { + CredentialPublicRow, + CredentialRow, + CreateCredentialResult, +} from '@/shared/database/types.js'; + +const GITHUB_TOKEN_TYPE = 'github_token'; + +type GithubTokenLookup = CredentialRow & { + github_token: string; +}; + +export const githubTokensDb = { + /** Creates a GitHub token credential entry. */ + createGithubToken( + userId: number, + tokenName: string, + githubToken: string, + description: string | null = null + ): CreateCredentialResult { + return credentialsDb.createCredential( + userId, + tokenName, + GITHUB_TOKEN_TYPE, + githubToken, + description + ); + }, + + /** Returns all GitHub tokens (safe shape: no credential value). */ + getGithubTokens(userId: number): CredentialPublicRow[] { + return credentialsDb.getCredentials(userId, GITHUB_TOKEN_TYPE); + }, + + /** Returns the most recent active GitHub token value for a user. */ + getActiveGithubToken(userId: number): string | null { + return credentialsDb.getActiveCredential(userId, GITHUB_TOKEN_TYPE); + }, + + /** + * Returns a specific active GitHub token row by id/user, including + * a `github_token` compatibility field. + */ + getGithubTokenById(userId: number, tokenId: number): GithubTokenLookup | null { + const db = getConnection(); + const row = db + .prepare( + `SELECT * + FROM user_credentials + WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1` + ) + .get(tokenId, userId, GITHUB_TOKEN_TYPE) as CredentialRow | undefined; + + if (!row) return null; + + return { + ...row, + github_token: row.credential_value, + }; + }, + + /** Updates active state for a GitHub token. */ + updateGithubToken( + userId: number, + tokenId: number, + isActive: boolean + ): boolean { + return credentialsDb.toggleCredential(userId, tokenId, isActive); + }, + + /** Deletes a GitHub token. */ + deleteGithubToken(userId: number, tokenId: number): boolean { + return credentialsDb.deleteCredential(userId, tokenId); + }, + + // Legacy alias used by existing routes + toggleGithubToken(userId: number, tokenId: number, isActive: boolean): boolean { + return githubTokensDb.updateGithubToken(userId, tokenId, isActive); + }, +}; + diff --git a/server/src/shared/database/repositories/notification-preferences.ts b/server/src/shared/database/repositories/notification-preferences.ts new file mode 100644 index 00000000..efde277d --- /dev/null +++ b/server/src/shared/database/repositories/notification-preferences.ts @@ -0,0 +1,103 @@ +/** + * Notification preferences repository. + * + * Stores per-user notification channel/event preferences as JSON. + */ + +import { getConnection } from '@/shared/database/connection.js'; + +type NotificationPreferences = { + channels: { + inApp: boolean; + webPush: boolean; + }; + events: { + actionRequired: boolean; + stop: boolean; + error: boolean; + }; +}; + +const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { + channels: { + inApp: false, + webPush: false, + }, + events: { + actionRequired: true, + stop: true, + error: true, + }, +}; + +function normalizeNotificationPreferences(value: unknown): NotificationPreferences { + const source = value && typeof value === 'object' ? (value as Record) : {}; + + return { + channels: { + inApp: source.channels?.inApp === true, + webPush: source.channels?.webPush === true, + }, + events: { + actionRequired: source.events?.actionRequired !== false, + stop: source.events?.stop !== false, + error: source.events?.error !== false, + }, + }; +} + +export const notificationPreferencesDb = { + /** Returns the normalized preferences for a user, creating defaults on first read. */ + getNotificationPreferences(userId: number): NotificationPreferences { + const db = getConnection(); + const row = db + .prepare( + 'SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?' + ) + .get(userId) as { preferences_json: string } | undefined; + + if (!row) { + const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES); + db.prepare( + 'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)' + ).run(userId, JSON.stringify(defaults)); + return defaults; + } + + let parsed: unknown = DEFAULT_NOTIFICATION_PREFERENCES; + try { + parsed = JSON.parse(row.preferences_json); + } catch { + parsed = DEFAULT_NOTIFICATION_PREFERENCES; + } + return normalizeNotificationPreferences(parsed); + }, + + /** Upserts normalized preferences for a user and returns the stored value. */ + updateNotificationPreferences( + userId: number, + preferences: unknown + ): NotificationPreferences { + const normalized = normalizeNotificationPreferences(preferences); + const db = getConnection(); + + db.prepare( + `INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET + preferences_json = excluded.preferences_json, + updated_at = CURRENT_TIMESTAMP` + ).run(userId, JSON.stringify(normalized)); + + return normalized; + }, + + // Legacy aliases used by existing services/routes + getPreferences(userId: number): NotificationPreferences { + return notificationPreferencesDb.getNotificationPreferences(userId); + }, + updatePreferences(userId: number, preferences: unknown): NotificationPreferences { + return notificationPreferencesDb.updateNotificationPreferences(userId, preferences); + }, +}; + diff --git a/server/src/shared/database/repositories/push-subscriptions.ts b/server/src/shared/database/repositories/push-subscriptions.ts new file mode 100644 index 00000000..6b2e84dd --- /dev/null +++ b/server/src/shared/database/repositories/push-subscriptions.ts @@ -0,0 +1,80 @@ +/** + * Push subscriptions repository. + * + * Persists browser push subscription endpoints and keys per user. + */ + +import { getConnection } from '@/shared/database/connection.js'; +import type { PushSubscriptionRow } from '@/shared/database/types.js'; + +type PushSubscriptionLookupRow = Pick< + PushSubscriptionRow, + 'endpoint' | 'keys_p256dh' | 'keys_auth' +>; + +export const pushSubscriptionsDb = { + /** Upserts a push subscription endpoint for a user. */ + createPushSubscription( + userId: number, + endpoint: string, + keysP256dh: string, + keysAuth: string + ): void { + const db = getConnection(); + db.prepare( + `INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth) + VALUES (?, ?, ?, ?) + ON CONFLICT(endpoint) DO UPDATE SET + user_id = excluded.user_id, + keys_p256dh = excluded.keys_p256dh, + keys_auth = excluded.keys_auth` + ).run(userId, endpoint, keysP256dh, keysAuth); + }, + + /** Returns all subscriptions for a user. */ + getPushSubscriptions(userId: number): PushSubscriptionLookupRow[] { + const db = getConnection(); + return db + .prepare( + 'SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?' + ) + .all(userId) as PushSubscriptionLookupRow[]; + }, + + /** Deletes one subscription by endpoint. */ + deletePushSubscription(endpoint: string): void { + const db = getConnection(); + db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint); + }, + + /** Deletes all subscriptions for a user. */ + deletePushSubscriptionsForUser(userId: number): void { + const db = getConnection(); + db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId); + }, + + // Legacy aliases used by existing services/routes + saveSubscription( + userId: number, + endpoint: string, + keysP256dh: string, + keysAuth: string + ): void { + pushSubscriptionsDb.createPushSubscription( + userId, + endpoint, + keysP256dh, + keysAuth + ); + }, + getSubscriptions(userId: number): PushSubscriptionLookupRow[] { + return pushSubscriptionsDb.getPushSubscriptions(userId); + }, + removeSubscription(endpoint: string): void { + pushSubscriptionsDb.deletePushSubscription(endpoint); + }, + removeAllForUser(userId: number): void { + pushSubscriptionsDb.deletePushSubscriptionsForUser(userId); + }, +}; + diff --git a/server/src/shared/database/repositories/session-names.ts b/server/src/shared/database/repositories/session-names.ts new file mode 100644 index 00000000..285798d1 --- /dev/null +++ b/server/src/shared/database/repositories/session-names.ts @@ -0,0 +1,106 @@ +/** + * Session names repository. + * + * Stores provider-scoped custom names for sessions and exposes helpers + * to overlay those names onto in-memory session lists. + */ + +import { getConnection } from '@/shared/database/connection.js'; +import type { + SessionNameLookupRow, + SessionWithSummary, +} from '@/shared/database/types.js'; + +export const sessionNamesDb = { + /** Upserts a custom session name for a provider-scoped session id. */ + createSessionName(sessionId: string, provider: string, customName: string): void { + const db = getConnection(); + db.prepare( + `INSERT INTO session_names (session_id, provider, custom_name) + VALUES (?, ?, ?) + ON CONFLICT(session_id, provider) + DO UPDATE SET custom_name = excluded.custom_name, updated_at = CURRENT_TIMESTAMP` + ).run(sessionId, provider, customName); + }, + + /** Alias to keep write semantics explicit when callers perform edits. */ + updateSessionName(sessionId: string, provider: string, customName: string): void { + sessionNamesDb.createSessionName(sessionId, provider, customName); + }, + + /** Returns a custom name for one session/provider pair or null if unset. */ + getSessionName(sessionId: string, provider: string): string | null { + const db = getConnection(); + const row = db + .prepare( + 'SELECT custom_name FROM session_names WHERE session_id = ? AND provider = ?' + ) + .get(sessionId, provider) as { custom_name: string } | undefined; + return row?.custom_name ?? null; + }, + + /** + * Batch lookup for multiple session ids. + * Returns a Map for efficient overlay onto lists. + */ + getSessionNames(sessionIds: string[], provider: string): Map { + if (sessionIds.length === 0) return new Map(); + + const db = getConnection(); + const placeholders = sessionIds.map(() => '?').join(','); + const rows = db + .prepare( + `SELECT session_id, custom_name FROM session_names + WHERE session_id IN (${placeholders}) AND provider = ?` + ) + .all(...sessionIds, provider) as SessionNameLookupRow[]; + + return new Map(rows.map((row) => [row.session_id, row.custom_name])); + }, + + /** Deletes a custom name. Returns true if a row was removed. */ + deleteSessionName(sessionId: string, provider: string): boolean { + const db = getConnection(); + return ( + db + .prepare( + 'DELETE FROM session_names WHERE session_id = ? AND provider = ?' + ) + .run(sessionId, provider).changes > 0 + ); + }, + + // Legacy aliases used by existing routes/services + setName(sessionId: string, provider: string, customName: string): void { + sessionNamesDb.createSessionName(sessionId, provider, customName); + }, + getName(sessionId: string, provider: string): string | null { + return sessionNamesDb.getSessionName(sessionId, provider); + }, + getNames(sessionIds: string[], provider: string): Map { + return sessionNamesDb.getSessionNames(sessionIds, provider); + }, + deleteName(sessionId: string, provider: string): boolean { + return sessionNamesDb.deleteSessionName(sessionId, provider); + }, +}; + +/** + * Overlay custom names onto a session list in place. + * If a custom name exists, `summary` is replaced. + */ +export function applyCustomSessionNames( + sessions: SessionWithSummary[] | undefined | null, + provider: string +): void { + if (!sessions?.length) return; + + const ids = sessions.map((session) => session.id); + const customNames = sessionNamesDb.getSessionNames(ids, provider); + for (const session of sessions) { + const customName = customNames.get(session.id); + if (customName) { + session.summary = customName; + } + } +} diff --git a/server/src/shared/database/repositories/sessions.db.ts b/server/src/shared/database/repositories/sessions.db.ts index ff82db39..21e787be 100644 --- a/server/src/shared/database/repositories/sessions.db.ts +++ b/server/src/shared/database/repositories/sessions.db.ts @@ -1,10 +1,5 @@ import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js'; import { getConnection } from '@/shared/database/connection.js'; -import type { - SessionNameLookupRow, - SessionWithSummary, -} from '@/shared/database/types.js'; -import { logger } from '@/shared/utils/logger.js'; // --------------------------------------------------------------------------- // Queries @@ -27,7 +22,7 @@ export const sessionsDb = { deleteSession(session_id: string): void { const db = getConnection(); db.prepare('DELETE FROM sessions WHERE session_id = ?').run(session_id); - } + }, // /** Inserts or updates a custom session name (upsert on session_id + provider). */ diff --git a/server/src/shared/database/repositories/vapid-keys.ts b/server/src/shared/database/repositories/vapid-keys.ts new file mode 100644 index 00000000..e35860d4 --- /dev/null +++ b/server/src/shared/database/repositories/vapid-keys.ts @@ -0,0 +1,53 @@ +/** + * VAPID keys repository. + * + * Stores and retrieves the Web Push VAPID key pair. + */ + +import { getConnection } from '@/shared/database/connection.js'; +import type { VapidKeyRow } from '@/shared/database/types.js'; + +type VapidKeyPair = { + publicKey: string; + privateKey: string; +}; + +export const vapidKeysDb = { + /** Returns the latest stored VAPID key pair, or null when unset. */ + getVapidKeys(): VapidKeyPair | null { + const db = getConnection(); + const row = db + .prepare( + 'SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1' + ) + .get() as Pick | undefined; + + if (!row) return null; + return { + publicKey: row.public_key, + privateKey: row.private_key, + }; + }, + + /** Persists a new VAPID key pair. */ + createVapidKeys(publicKey: string, privateKey: string): void { + const db = getConnection(); + db.prepare( + 'INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)' + ).run(publicKey, privateKey); + }, + + /** Replaces all existing keys with a fresh pair. */ + updateVapidKeys(publicKey: string, privateKey: string): void { + const db = getConnection(); + db.prepare('DELETE FROM vapid_keys').run(); + vapidKeysDb.createVapidKeys(publicKey, privateKey); + }, + + /** Deletes all VAPID key rows. */ + deleteVapidKeys(): void { + const db = getConnection(); + db.prepare('DELETE FROM vapid_keys').run(); + }, +}; + diff --git a/server/src/shared/database/schema.ts b/server/src/shared/database/schema.ts index 0d4834cb..37e38461 100644 --- a/server/src/shared/database/schema.ts +++ b/server/src/shared/database/schema.ts @@ -39,6 +39,48 @@ CREATE TABLE IF NOT EXISTS user_credentials ( ); `; +export const USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS user_notification_preferences ( + user_id INTEGER PRIMARY KEY, + preferences_json TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +`; + +export const VAPID_KEYS_TABLE_SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS vapid_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +`; + +export const PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + keys_p256dh TEXT NOT NULL, + keys_auth TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +`; + +export const SESSION_NAMES_TABLE_SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS session_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'claude', + custom_name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(session_id, provider) +); +`; + export const SESSIONS_TABLE_SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY NOT NULL, @@ -93,6 +135,17 @@ CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type); CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active); +${USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL} +CREATE INDEX IF NOT EXISTS idx_user_notification_preferences_user_id ON user_notification_preferences(user_id); + +${VAPID_KEYS_TABLE_SCHEMA_SQL} + +${PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL} +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id); + +${SESSION_NAMES_TABLE_SCHEMA_SQL} +CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider); + ${SESSIONS_TABLE_SCHEMA_SQL} CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id); diff --git a/server/src/shared/database/types.ts b/server/src/shared/database/types.ts index 2bc390d6..7e4da1bd 100644 --- a/server/src/shared/database/types.ts +++ b/server/src/shared/database/types.ts @@ -99,12 +99,21 @@ export type CreateCredentialResult = { export type SessionsRow = { session_id: string; provider: LLMProvider; - workspacePath: string; + workspace_path: string; + custom_name: string | null; +}; + +export type SessionNameRow = { + id: number; + session_id: string; + provider: LLMProvider; custom_name: string; + created_at: string; + updated_at: string; }; /** Minimal shape used in batch lookups. */ -export type SessionNameLookupRow = Pick; +export type SessionNameLookupRow = Pick; /** * Any object that has an `id` and `summary` field. @@ -130,6 +139,32 @@ export type ScanStateRow = { last_scanned_at: string; } +// --------------------------------------------------------------------------- +// Notification preferences / push +// --------------------------------------------------------------------------- + +export type UserNotificationPreferencesRow = { + user_id: number; + preferences_json: string; + updated_at: string; +}; + +export type PushSubscriptionRow = { + id: number; + user_id: number; + endpoint: string; + keys_p256dh: string; + keys_auth: string; + created_at: string; +}; + +export type VapidKeyRow = { + id: number; + public_key: string; + private_key: string; + created_at: string; +}; + // --------------------------------------------------------------------------- // App Config