From 05eabcb0fad5b7e8991c1a6d26849f0b55b3c50b Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 15:41:25 -0400 Subject: [PATCH] feat(notifications): add notifications + notificationPreferences tables --- packages/backend/convex/schema.ts | 31 ++++++++ .../backend/tests/unit/notifications.test.ts | 72 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 packages/backend/tests/unit/notifications.test.ts diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 3e2b375..eefcb09 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -835,6 +835,37 @@ const applicationTables = { }) .index('by_thread', ['threadId']) .index('by_owner', ['ownerId']), + notifications: defineTable({ + userId: v.id('users'), + kind: v.union( + v.literal('maintenance_thread'), + v.literal('agent_turn_finished'), + v.literal('agent_needs_input'), + v.literal('sync_failed'), + v.literal('connection_needs_reauth'), + ), + title: v.string(), + body: v.string(), + link: v.optional(v.string()), + spoonId: v.optional(v.id('spoons')), + threadId: v.optional(v.id('threads')), + readAt: v.optional(v.number()), + emailedAt: v.optional(v.number()), + createdAt: v.number(), + }) + .index('by_user', ['userId']) + .index('by_user_unread', ['userId', 'readAt']) + .index('by_user_created', ['userId', 'createdAt']), + notificationPreferences: defineTable({ + userId: v.id('users'), + 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()), + updatedAt: v.number(), + }).index('by_user', ['userId']), ignoredUpstreamChanges: defineTable({ spoonId: v.id('spoons'), ownerId: v.id('users'), diff --git a/packages/backend/tests/unit/notifications.test.ts b/packages/backend/tests/unit/notifications.test.ts new file mode 100644 index 0000000..59c29f6 --- /dev/null +++ b/packages/backend/tests/unit/notifications.test.ts @@ -0,0 +1,72 @@ +import { convexTest } from 'convex-test'; +import { describe, expect, test } from 'vitest'; + +import schema from '../../convex/schema'; + +const modules = import.meta.glob('../../convex/**/*.*s'); + +describe('notifications schema', () => { + test('inserts and reads back a notifications row', async () => { + const t = convexTest(schema, modules); + + const { notificationId, userId } = await t.run(async (ctx) => { + const userId = await ctx.db.insert('users', { + email: 'a@b.c', + name: 'A', + }); + const now = Date.now(); + const notificationId = await ctx.db.insert('notifications', { + userId, + kind: 'agent_turn_finished', + title: 'Agent finished', + body: 'Your agent completed its turn.', + link: '/threads/abc', + createdAt: now, + }); + return { notificationId, userId }; + }); + + 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('inserts and reads back a notificationPreferences row', async () => { + const t = convexTest(schema, modules); + + const { preferenceId, userId } = await t.run(async (ctx) => { + const userId = await ctx.db.insert('users', { + email: 'prefs@b.c', + name: 'Prefs', + }); + const preferenceId = await ctx.db.insert('notificationPreferences', { + userId, + emailEnabled: true, + emailSyncFailed: false, + updatedAt: Date.now(), + }); + return { preferenceId, userId }; + }); + + const preference = await t.run( + async (ctx) => await ctx.db.get(preferenceId), + ); + + expect(preference).not.toBeNull(); + expect(preference?.userId).toBe(userId); + expect(preference?.emailEnabled).toBe(true); + expect(preference?.emailSyncFailed).toBe(false); + expect(preference?.emailMaintenance).toBeUndefined(); + expect(typeof preference?.updatedAt).toBe('number'); + }); +});