feat(notifications): add emit helper + UseSend email dispatch

This commit is contained in:
Gabriel Brown
2026-07-11 15:52:26 -04:00
parent 05eabcb0fa
commit 4eb0c963ff
3 changed files with 305 additions and 0 deletions
@@ -1,6 +1,7 @@
import { convexTest } from 'convex-test';
import { describe, expect, test } from 'vitest';
import { internal } from '../../convex/_generated/api';
import schema from '../../convex/schema';
const modules = import.meta.glob('../../convex/**/*.*s');
@@ -70,3 +71,105 @@ describe('notifications schema', () => {
expect(typeof preference?.updatedAt).toBe('number');
});
});
describe('emitNotification', () => {
test('inserts an unread notification row for the user', async () => {
const t = convexTest(schema, modules);
const userId = await t.run(
async (ctx) =>
await ctx.db.insert('users', { email: 'emit@b.c', name: 'Emit' }),
);
const notificationId = await t.mutation(internal.notifications.emit, {
userId,
kind: 'agent_turn_finished',
title: 'Agent finished',
body: 'Your agent completed its turn.',
link: '/threads/abc',
});
const notification = await t.run(
async (ctx) => await ctx.db.get(notificationId),
);
expect(notification).not.toBeNull();
expect(notification?.userId).toBe(userId);
expect(notification?.kind).toBe('agent_turn_finished');
expect(notification?.title).toBe('Agent finished');
expect(notification?.body).toBe('Your agent completed its turn.');
expect(notification?.link).toBe('/threads/abc');
expect(notification?.readAt).toBeUndefined();
expect(notification?.emailedAt).toBeUndefined();
expect(typeof notification?.createdAt).toBe('number');
});
test('does not email when emailEnabled is false', async () => {
const t = convexTest(schema, modules);
const userId = await t.run(async (ctx) => {
const userId = await ctx.db.insert('users', {
email: 'noemail@b.c',
name: 'NoEmail',
});
await ctx.db.insert('notificationPreferences', {
userId,
emailEnabled: false,
updatedAt: Date.now(),
});
return userId;
});
const notificationId = await t.mutation(internal.notifications.emit, {
userId,
kind: 'sync_failed',
title: 'Sync failed',
body: 'Your sync failed.',
});
// No email should have been 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();
});
test('getForEmail returns the recipient shape and markEmailed patches emailedAt', async () => {
const t = convexTest(schema, modules);
const { notificationId } = await t.run(async (ctx) => {
const userId = await ctx.db.insert('users', {
email: 'foremail@b.c',
name: 'ForEmail',
});
const notificationId = await ctx.db.insert('notifications', {
userId,
kind: 'agent_needs_input',
title: 'Needs input',
body: 'The agent needs your input.',
link: '/threads/xyz',
createdAt: Date.now(),
});
return { notificationId };
});
const forEmail = await t.query(internal.notifications.getForEmail, {
notificationId,
});
expect(forEmail).toEqual({
to: 'foremail@b.c',
title: 'Needs input',
body: 'The agent needs your input.',
link: '/threads/xyz',
});
await t.mutation(internal.notifications.markEmailed, { notificationId });
const notification = await t.run(
async (ctx) => await ctx.db.get(notificationId),
);
expect(typeof notification?.emailedAt).toBe('number');
});
});