feat(notifications): inbox query + mark-read mutations
This commit is contained in:
@@ -1,9 +1,15 @@
|
|||||||
import { v } from 'convex/values';
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
import type { Doc, Id } from './_generated/dataModel';
|
import type { Doc, Id } from './_generated/dataModel';
|
||||||
import type { MutationCtx } from './_generated/server';
|
import type { MutationCtx } from './_generated/server';
|
||||||
import { internal } from './_generated/api';
|
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 =
|
export type NotificationKind =
|
||||||
| 'maintenance_thread'
|
| '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<Doc<'notifications'>[]> => {
|
||||||
|
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<number> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import schema from '../../convex/schema';
|
|||||||
|
|
||||||
const modules = import.meta.glob('../../convex/**/*.*s');
|
const modules = import.meta.glob('../../convex/**/*.*s');
|
||||||
|
|
||||||
|
const authed = (t: ReturnType<typeof convexTest>, userId: string) =>
|
||||||
|
t.withIdentity({
|
||||||
|
subject: `${userId}|session`,
|
||||||
|
issuer: 'https://convex.test',
|
||||||
|
});
|
||||||
|
|
||||||
const countNotifications = async (
|
const countNotifications = async (
|
||||||
t: ReturnType<typeof convexTest>,
|
t: ReturnType<typeof convexTest>,
|
||||||
userId: Id<'users'>,
|
userId: Id<'users'>,
|
||||||
@@ -370,3 +376,122 @@ describe('emit points', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('inbox query + mark-read mutations', () => {
|
||||||
|
const seedInbox = async (t: ReturnType<typeof convexTest>) =>
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user