feat(notifications): settings notification preferences
This commit is contained in:
@@ -199,6 +199,73 @@ export const markRead = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export interface NotificationPreferences {
|
||||
emailEnabled: boolean;
|
||||
emailMaintenance: boolean;
|
||||
emailAgentTurnFinished: boolean;
|
||||
emailAgentNeedsInput: boolean;
|
||||
emailSyncFailed: boolean;
|
||||
emailConnectionReauth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The authed caller's email notification preferences, resolved for display.
|
||||
* Unset preferences default to enabled, so a caller with no stored row gets an
|
||||
* all-true object. Scoped to the caller via `getRequiredUserId`; never accepts
|
||||
* a userId argument.
|
||||
*/
|
||||
export const getPreferences = query({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<NotificationPreferences> => {
|
||||
const userId = await getRequiredUserId(ctx);
|
||||
const prefs = await ctx.db
|
||||
.query('notificationPreferences')
|
||||
.withIndex('by_user', (q) => q.eq('userId', userId))
|
||||
.unique();
|
||||
return {
|
||||
emailEnabled: prefs?.emailEnabled !== false,
|
||||
emailMaintenance: prefs?.emailMaintenance !== false,
|
||||
emailAgentTurnFinished: prefs?.emailAgentTurnFinished !== false,
|
||||
emailAgentNeedsInput: prefs?.emailAgentNeedsInput !== false,
|
||||
emailSyncFailed: prefs?.emailSyncFailed !== false,
|
||||
emailConnectionReauth: prefs?.emailConnectionReauth !== false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Upserts the authed caller's email notification preferences. Only the provided
|
||||
* fields are written; the row is patched when it exists and inserted otherwise.
|
||||
* Scoped to the caller via `getRequiredUserId`; never accepts a userId argument.
|
||||
*/
|
||||
export const updatePreferences = mutation({
|
||||
args: {
|
||||
emailEnabled: v.optional(v.boolean()),
|
||||
emailMaintenance: v.optional(v.boolean()),
|
||||
emailAgentTurnFinished: v.optional(v.boolean()),
|
||||
emailAgentNeedsInput: v.optional(v.boolean()),
|
||||
emailSyncFailed: v.optional(v.boolean()),
|
||||
emailConnectionReauth: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<void> => {
|
||||
const userId = await getRequiredUserId(ctx);
|
||||
const existing = await ctx.db
|
||||
.query('notificationPreferences')
|
||||
.withIndex('by_user', (q) => q.eq('userId', userId))
|
||||
.unique();
|
||||
const now = Date.now();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { ...args, updatedAt: now });
|
||||
} else {
|
||||
await ctx.db.insert('notificationPreferences', {
|
||||
userId,
|
||||
...args,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Marks all of the caller's unread notifications read. Iterates the caller's
|
||||
* unread rows via the `by_user_unread` index; only ever touches the caller's
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user