refactor(backend): move db repos to typescript and update imports

This commit is contained in:
Haileyesus
2026-03-27 20:10:01 +03:00
parent 33cea381c4
commit 90d234d9f3
26 changed files with 960 additions and 60 deletions

11
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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';

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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));
}
/**

View File

@@ -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 },
});

View 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 };

View File

@@ -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);

View File

@@ -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 },
});

View File

@@ -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, '../..');

View 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,
};

View File

@@ -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(

View 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);
},
};

View File

@@ -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);
},
};

View File

@@ -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);
},
};

View 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;
}
}
}

View File

@@ -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). */

View 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();
},
};

View File

@@ -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);

View File

@@ -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