diff --git a/packages/backend/convex/notifications.ts b/packages/backend/convex/notifications.ts index af9a373..d3c04f4 100644 --- a/packages/backend/convex/notifications.ts +++ b/packages/backend/convex/notifications.ts @@ -1,9 +1,15 @@ -import { v } from 'convex/values'; +import { ConvexError, v } from 'convex/values'; import type { Doc, Id } from './_generated/dataModel'; import type { MutationCtx } from './_generated/server'; import { internal } from './_generated/api'; -import { internalMutation, internalQuery } from './_generated/server'; +import { + internalMutation, + internalQuery, + mutation, + query, +} from './_generated/server'; +import { getRequiredUserId } from './model'; export type NotificationKind = | 'maintenance_thread' @@ -139,3 +145,78 @@ export const getForEmail = internalQuery({ }; }, }); + +/** + * The authed caller's notifications, newest-first. Scoped to the caller via + * `getRequiredUserId`; never accepts a userId argument. + */ +export const listMine = query({ + args: { limit: v.optional(v.number()) }, + handler: async (ctx, args): Promise[]> => { + const userId = await getRequiredUserId(ctx); + return await ctx.db + .query('notifications') + .withIndex('by_user_created', (q) => q.eq('userId', userId)) + .order('desc') + .take(args.limit ?? 30); + }, +}); + +/** + * How many of the caller's notifications are unread. Bounded at 100 via the + * `by_user_unread` index so the header bell stays cheap. + */ +export const unreadCount = query({ + args: {}, + handler: async (ctx): Promise => { + const userId = await getRequiredUserId(ctx); + const unread = await ctx.db + .query('notifications') + .withIndex('by_user_unread', (q) => + q.eq('userId', userId).eq('readAt', undefined), + ) + .take(100); + return unread.length; + }, +}); + +/** + * Marks a single notification read. Loads the row and asserts it belongs to the + * caller before patching, so a user can never mark another user's notification + * read (indistinguishable from a missing row). + */ +export const markRead = mutation({ + args: { notificationId: v.id('notifications') }, + handler: async (ctx, { notificationId }): Promise => { + const userId = await getRequiredUserId(ctx); + const notification = await ctx.db.get(notificationId); + if (notification?.userId !== userId) { + throw new ConvexError('Notification not found.'); + } + if (notification.readAt === undefined) { + await ctx.db.patch(notificationId, { readAt: Date.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 + * own notifications. + */ +export const markAllRead = mutation({ + args: {}, + handler: async (ctx): Promise => { + const userId = await getRequiredUserId(ctx); + const unread = await ctx.db + .query('notifications') + .withIndex('by_user_unread', (q) => + q.eq('userId', userId).eq('readAt', undefined), + ) + .collect(); + const now = Date.now(); + for (const notification of unread) { + await ctx.db.patch(notification._id, { readAt: now }); + } + }, +}); diff --git a/packages/backend/tests/unit/notifications.test.ts b/packages/backend/tests/unit/notifications.test.ts index 9143dd8..d25167a 100644 --- a/packages/backend/tests/unit/notifications.test.ts +++ b/packages/backend/tests/unit/notifications.test.ts @@ -7,6 +7,12 @@ import schema from '../../convex/schema'; const modules = import.meta.glob('../../convex/**/*.*s'); +const authed = (t: ReturnType, userId: string) => + t.withIdentity({ + subject: `${userId}|session`, + issuer: 'https://convex.test', + }); + const countNotifications = async ( t: ReturnType, userId: Id<'users'>, @@ -370,3 +376,122 @@ describe('emit points', () => { ); }); }); + +describe('inbox query + mark-read mutations', () => { + const seedInbox = async (t: ReturnType) => + await t.run(async (ctx) => { + const userA = await ctx.db.insert('users', { + email: 'inbox-a@example.com', + name: 'Inbox A', + }); + const userB = await ctx.db.insert('users', { + email: 'inbox-b@example.com', + name: 'Inbox B', + }); + const base = 1_000_000; + // Three notifications for A with ascending createdAt (a3 is newest). + const a1 = await ctx.db.insert('notifications', { + userId: userA, + kind: 'agent_turn_finished', + title: 'A one', + body: 'first', + createdAt: base + 1, + }); + const a2 = await ctx.db.insert('notifications', { + userId: userA, + kind: 'agent_turn_finished', + title: 'A two', + body: 'second', + createdAt: base + 2, + }); + const a3 = await ctx.db.insert('notifications', { + userId: userA, + kind: 'agent_needs_input', + title: 'A three', + body: 'third', + createdAt: base + 3, + }); + // One notification for B, which must never leak to A. + const b1 = await ctx.db.insert('notifications', { + userId: userB, + kind: 'sync_failed', + title: 'B one', + body: 'b first', + createdAt: base + 4, + }); + return { userA, userB, a1, a2, a3, b1 }; + }); + + test('listMine returns only the caller notifications, newest-first, capped by limit', async () => { + const t = convexTest(schema, modules); + const { userA, a1, a2, a3 } = await seedInbox(t); + + const all = await authed(t, userA).query(api.notifications.listMine, {}); + expect(all.map((n) => n._id)).toEqual([a3, a2, a1]); + // No notification belongs to any other user. + expect(all.every((n) => n.userId === userA)).toBe(true); + + const limited = await authed(t, userA).query(api.notifications.listMine, { + limit: 2, + }); + expect(limited.map((n) => n._id)).toEqual([a3, a2]); + }); + + test('unreadCount counts only the caller unread notifications', async () => { + const t = convexTest(schema, modules); + const { userA, userB } = await seedInbox(t); + + expect(await authed(t, userA).query(api.notifications.unreadCount, {})).toBe( + 3, + ); + // B has a single unread notification; A's three do not leak in. + expect(await authed(t, userB).query(api.notifications.unreadCount, {})).toBe( + 1, + ); + }); + + test('markRead marks the caller notification and drops unreadCount', async () => { + const t = convexTest(schema, modules); + const { userA, a1 } = await seedInbox(t); + + await authed(t, userA).mutation(api.notifications.markRead, { + notificationId: a1, + }); + + const notification = await t.run(async (ctx) => await ctx.db.get(a1)); + expect(typeof notification?.readAt).toBe('number'); + expect(await authed(t, userA).query(api.notifications.unreadCount, {})).toBe( + 2, + ); + }); + + test('markRead rejects a notification owned by another user', async () => { + const t = convexTest(schema, modules); + const { userA, b1 } = await seedInbox(t); + + await expect( + authed(t, userA).mutation(api.notifications.markRead, { + notificationId: b1, + }), + ).rejects.toThrow('Notification not found.'); + + // B's notification is untouched and B still has one unread. + const notification = await t.run(async (ctx) => await ctx.db.get(b1)); + expect(notification?.readAt).toBeUndefined(); + }); + + test('markAllRead zeroes the caller unread count but leaves other users untouched', async () => { + const t = convexTest(schema, modules); + const { userA, userB } = await seedInbox(t); + + await authed(t, userA).mutation(api.notifications.markAllRead, {}); + + expect(await authed(t, userA).query(api.notifications.unreadCount, {})).toBe( + 0, + ); + // B's unread notification is left alone. + expect(await authed(t, userB).query(api.notifications.unreadCount, {})).toBe( + 1, + ); + }); +});