Skip to content

Commit 9e2bd17

Browse files
Yerazeclaude
andauthored
fix: add UNIQUE constraint to notification preferences userId (#2429)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4e5ebd3 commit 9e2bd17

File tree

4 files changed

+94
-9
lines changed

4 files changed

+94
-9
lines changed

src/db/migrations.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest';
22
import { registry } from './migrations.js';
33

44
describe('migrations registry', () => {
5-
it('has all 14 migrations registered', () => {
6-
expect(registry.count()).toBe(14);
5+
it('has all 15 migrations registered', () => {
6+
expect(registry.count()).toBe(15);
77
});
88

99
it('first migration is v37 baseline', () => {
@@ -12,14 +12,14 @@ describe('migrations registry', () => {
1212
expect(all[0].name).toContain('v37_baseline');
1313
});
1414

15-
it('last migration is the messages decrypted_by fix', () => {
15+
it('last migration is the notification prefs unique constraint', () => {
1616
const all = registry.getAll();
1717
const last = all[all.length - 1];
18-
expect(last.number).toBe(14);
19-
expect(last.name).toContain('messages_decrypted_by');
18+
expect(last.number).toBe(15);
19+
expect(last.name).toContain('notification_prefs_unique');
2020
});
2121

22-
it('migrations are sequentially numbered from 1 to 12', () => {
22+
it('migrations are sequentially numbered from 1 to 15', () => {
2323
const all = registry.getAll();
2424
for (let i = 0; i < all.length; i++) {
2525
expect(all[i].number).toBe(i + 1);

src/db/migrations.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { migration as fixMessageNodeNumBigintMigration, runMigration087Postgres,
2626
import { migration as authAlignMigration, runMigration012Postgres, runMigration012Mysql } from '../server/migrations/012_align_sqlite_auth_schema.js';
2727
import { migration as auditLogColumnsMigration, runMigration013Postgres, runMigration013Mysql } from '../server/migrations/013_add_audit_log_missing_columns.js';
2828
import { migration as messagesDecryptedByMigration, runMigration014Postgres, runMigration014Mysql } from '../server/migrations/014_add_messages_decrypted_by.js';
29+
import { migration as notificationPrefsUniqueMigration, runMigration015Postgres, runMigration015Mysql } from '../server/migrations/015_add_notification_prefs_unique.js';
2930

3031
// ============================================================================
3132
// Registry
@@ -184,3 +185,17 @@ registry.register({
184185
postgres: (client) => runMigration014Postgres(client),
185186
mysql: (pool) => runMigration014Mysql(pool),
186187
});
188+
189+
// ---------------------------------------------------------------------------
190+
// 015 — Add UNIQUE constraint to user_notification_preferences.userId
191+
// The upsert for notification preferences requires this constraint.
192+
// ---------------------------------------------------------------------------
193+
194+
registry.register({
195+
number: 15,
196+
name: 'add_notification_prefs_unique',
197+
settingsKey: 'migration_015_add_notification_prefs_unique',
198+
sqlite: (db) => notificationPrefsUniqueMigration.up(db),
199+
postgres: (client) => runMigration015Postgres(client),
200+
mysql: (pool) => runMigration015Mysql(pool),
201+
});

src/db/schema/notifications.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const pushSubscriptionsPostgres = pgTable('push_subscriptions', {
3838

3939
export const userNotificationPreferencesSqlite = sqliteTable('user_notification_preferences', {
4040
id: integer('id').primaryKey({ autoIncrement: true }),
41-
userId: integer('user_id').notNull().references(() => usersSqlite.id, { onDelete: 'cascade' }),
41+
userId: integer('user_id').notNull().unique().references(() => usersSqlite.id, { onDelete: 'cascade' }),
4242
notifyOnMessage: integer('enable_web_push', { mode: 'boolean' }).default(true),
4343
notifyOnDirectMessage: integer('enable_direct_messages', { mode: 'boolean' }).default(true),
4444
notifyOnEmoji: integer('notify_on_emoji', { mode: 'boolean' }).default(false),
@@ -60,7 +60,7 @@ export const userNotificationPreferencesSqlite = sqliteTable('user_notification_
6060

6161
export const userNotificationPreferencesPostgres = pgTable('user_notification_preferences', {
6262
id: pgSerial('id').primaryKey(),
63-
userId: pgInteger('userId').notNull().references(() => usersPostgres.id, { onDelete: 'cascade' }),
63+
userId: pgInteger('userId').notNull().unique().references(() => usersPostgres.id, { onDelete: 'cascade' }),
6464
notifyOnMessage: pgBoolean('notifyOnMessage').default(true),
6565
notifyOnDirectMessage: pgBoolean('notifyOnDirectMessage').default(true),
6666
notifyOnChannelMessage: pgBoolean('notifyOnChannelMessage').default(false),
@@ -112,7 +112,7 @@ export const pushSubscriptionsMysql = mysqlTable('push_subscriptions', {
112112

113113
export const userNotificationPreferencesMysql = mysqlTable('user_notification_preferences', {
114114
id: mySerial('id').primaryKey(),
115-
userId: myInt('userId').notNull().references(() => usersMysql.id, { onDelete: 'cascade' }),
115+
userId: myInt('userId').notNull().unique().references(() => usersMysql.id, { onDelete: 'cascade' }),
116116
notifyOnMessage: myBoolean('notifyOnMessage').default(true),
117117
notifyOnDirectMessage: myBoolean('notifyOnDirectMessage').default(true),
118118
notifyOnChannelMessage: myBoolean('notifyOnChannelMessage').default(false),
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Migration 015: Add UNIQUE constraint to user_notification_preferences.userId
3+
*
4+
* The onConflictDoUpdate (upsert) for notification preferences requires a
5+
* UNIQUE constraint on userId, but the original schema omitted it.
6+
* This caused a 500 error when saving notification preferences.
7+
*
8+
* Fixes #2426
9+
*/
10+
import type Database from 'better-sqlite3';
11+
12+
// SQLite migration
13+
export const migration = {
14+
up: (db: Database.Database) => {
15+
// SQLite doesn't support ADD CONSTRAINT — need to deduplicate first, then create unique index
16+
try {
17+
// Remove duplicate rows (keep the one with the highest id per user)
18+
db.exec(`
19+
DELETE FROM user_notification_preferences
20+
WHERE id NOT IN (
21+
SELECT MAX(id) FROM user_notification_preferences GROUP BY user_id
22+
)
23+
`);
24+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_notification_preferences_user_id ON user_notification_preferences(user_id)`);
25+
} catch {
26+
// Index may already exist
27+
}
28+
}
29+
};
30+
31+
// PostgreSQL migration
32+
export async function runMigration015Postgres(client: any): Promise<void> {
33+
// Remove duplicates (keep highest id per user)
34+
await client.query(`
35+
DELETE FROM user_notification_preferences
36+
WHERE id NOT IN (
37+
SELECT MAX(id) FROM user_notification_preferences GROUP BY "userId"
38+
)
39+
`);
40+
// Add unique constraint if not exists
41+
await client.query(`
42+
DO $$ BEGIN
43+
IF NOT EXISTS (
44+
SELECT 1 FROM pg_constraint WHERE conname = 'user_notification_preferences_userId_unique'
45+
) THEN
46+
ALTER TABLE user_notification_preferences ADD CONSTRAINT "user_notification_preferences_userId_unique" UNIQUE ("userId");
47+
END IF;
48+
END $$;
49+
`);
50+
}
51+
52+
// MySQL migration
53+
export async function runMigration015Mysql(pool: any): Promise<void> {
54+
// Remove duplicates (keep highest id per user)
55+
await pool.execute(`
56+
DELETE t1 FROM user_notification_preferences t1
57+
INNER JOIN user_notification_preferences t2
58+
WHERE t1.id < t2.id AND t1.userId = t2.userId
59+
`);
60+
// Add unique index if not exists
61+
const [rows] = await pool.execute(`
62+
SELECT COUNT(*) as cnt FROM information_schema.STATISTICS
63+
WHERE TABLE_SCHEMA = DATABASE()
64+
AND TABLE_NAME = 'user_notification_preferences'
65+
AND INDEX_NAME = 'idx_user_notification_preferences_userId'
66+
`);
67+
if (rows[0].cnt === 0) {
68+
await pool.execute(`CREATE UNIQUE INDEX idx_user_notification_preferences_userId ON user_notification_preferences(userId)`);
69+
}
70+
}

0 commit comments

Comments
 (0)