mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-15 17:03:20 +00:00
refactor(backend): move db repos to typescript and update imports
This commit is contained in:
11
package-lock.json
generated
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
export default router;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
42
server/src/modules/push-sub/push-sub.services.ts
Normal file
42
server/src/modules/push-sub/push-sub.services.ts
Normal file
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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, '../..');
|
||||
|
||||
317
server/src/services/notification-orchestrator.ts
Normal file
317
server/src/services/notification-orchestrator.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<string, keyof NonNullable<NotificationPreferences['events']>> = {
|
||||
action_required: 'actionRequired',
|
||||
stop: 'stop',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
system: 'System',
|
||||
};
|
||||
|
||||
const recentEventKeys = new Map<string, number>();
|
||||
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<string, string> = {
|
||||
'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<void> {
|
||||
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<string, unknown>;
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
90
server/src/shared/database/repositories/github-tokens.ts
Normal file
90
server/src/shared/database/repositories/github-tokens.ts
Normal file
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<string, any>) : {};
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
106
server/src/shared/database/repositories/session-names.ts
Normal file
106
server/src/shared/database/repositories/session-names.ts
Normal file
@@ -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<sessionId, customName> for efficient overlay onto lists.
|
||||
*/
|
||||
getSessionNames(sessionIds: string[], provider: string): Map<string, string> {
|
||||
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<string, string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
53
server/src/shared/database/repositories/vapid-keys.ts
Normal file
53
server/src/shared/database/repositories/vapid-keys.ts
Normal file
@@ -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<VapidKeyRow, 'public_key' | 'private_key'> | 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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<SessionsRow, 'session_id' | 'custom_name'>;
|
||||
export type SessionNameLookupRow = Pick<SessionNameRow, 'session_id' | 'custom_name'>;
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
Reference in New Issue
Block a user