feat(notifications): settings notification preferences

This commit is contained in:
Gabriel Brown
2026-07-11 16:41:35 -04:00
parent c241941e41
commit 8d7486bc8c
6 changed files with 463 additions and 1 deletions
@@ -377,6 +377,141 @@ describe('emit points', () => {
});
});
describe('notification preferences query + mutation', () => {
const countPreferenceRows = async (
t: ReturnType<typeof convexTest>,
userId: Id<'users'>,
) =>
await t.run(async (ctx) => {
const rows = await ctx.db.query('notificationPreferences').collect();
return rows.filter((r) => r.userId === userId).length;
});
test('getPreferences returns all-true defaults when the caller has no row', async () => {
const t = convexTest(schema, modules);
const userId = await t.run(
async (ctx) =>
await ctx.db.insert('users', { email: 'pref-a@b.c', name: 'PrefA' }),
);
const prefs = await authed(t, userId).query(
api.notifications.getPreferences,
{},
);
expect(prefs).toEqual({
emailEnabled: true,
emailMaintenance: true,
emailAgentTurnFinished: true,
emailAgentNeedsInput: true,
emailSyncFailed: true,
emailConnectionReauth: true,
});
});
test('updatePreferences persists a change that getPreferences reflects', async () => {
const t = convexTest(schema, modules);
const userId = await t.run(
async (ctx) =>
await ctx.db.insert('users', { email: 'pref-b@b.c', name: 'PrefB' }),
);
await authed(t, userId).mutation(api.notifications.updatePreferences, {
emailSyncFailed: false,
});
const prefs = await authed(t, userId).query(
api.notifications.getPreferences,
{},
);
expect(prefs.emailSyncFailed).toBe(false);
// Unset fields remain enabled by default.
expect(prefs.emailEnabled).toBe(true);
expect(prefs.emailAgentTurnFinished).toBe(true);
});
test('updatePreferences upserts a single row across repeated updates', async () => {
const t = convexTest(schema, modules);
const userId = await t.run(
async (ctx) =>
await ctx.db.insert('users', { email: 'pref-c@b.c', name: 'PrefC' }),
);
await authed(t, userId).mutation(api.notifications.updatePreferences, {
emailSyncFailed: false,
});
await authed(t, userId).mutation(api.notifications.updatePreferences, {
emailMaintenance: false,
});
expect(await countPreferenceRows(t, userId)).toBe(1);
const prefs = await authed(t, userId).query(
api.notifications.getPreferences,
{},
);
// The second update patches without discarding the first change.
expect(prefs.emailSyncFailed).toBe(false);
expect(prefs.emailMaintenance).toBe(false);
});
test('updatePreferences only touches the callers own row', async () => {
const t = convexTest(schema, modules);
const { userA, userB } = await t.run(async (ctx) => {
const userA = await ctx.db.insert('users', {
email: 'pref-iso-a@b.c',
name: 'IsoA',
});
const userB = await ctx.db.insert('users', {
email: 'pref-iso-b@b.c',
name: 'IsoB',
});
return { userA, userB };
});
await authed(t, userA).mutation(api.notifications.updatePreferences, {
emailEnabled: false,
});
const prefsA = await authed(t, userA).query(
api.notifications.getPreferences,
{},
);
const prefsB = await authed(t, userB).query(
api.notifications.getPreferences,
{},
);
expect(prefsA.emailEnabled).toBe(false);
// B is untouched: still on all-true defaults with no row of its own.
expect(prefsB.emailEnabled).toBe(true);
expect(await countPreferenceRows(t, userB)).toBe(0);
});
test('a sync_failed emit respects a stored emailSyncFailed=false preference', async () => {
const t = convexTest(schema, modules);
const userId = await t.run(
async (ctx) =>
await ctx.db.insert('users', { email: 'pref-gate@b.c', name: 'Gate' }),
);
await authed(t, userId).mutation(api.notifications.updatePreferences, {
emailSyncFailed: false,
});
const notificationId = await t.mutation(internal.notifications.emit, {
userId,
kind: 'sync_failed',
title: 'Sync failed',
body: 'Your sync failed.',
});
// No email scheduled; draining is a safe no-op.
await t.finishAllScheduledFunctions(() => undefined);
const notification = await t.run(
async (ctx) => await ctx.db.get(notificationId),
);
expect(notification?.emailedAt).toBeUndefined();
});
});
describe('inbox query + mark-read mutations', () => {
const seedInbox = async (t: ReturnType<typeof convexTest>) =>
await t.run(async (ctx) => {