diff --git a/apps/api/src/routes/lists.ts b/apps/api/src/routes/lists.ts index 50efe75..94d07f0 100644 --- a/apps/api/src/routes/lists.ts +++ b/apps/api/src/routes/lists.ts @@ -31,6 +31,7 @@ import { rankForAppend } from '../lib/rank.js' import '../auth/devBypass.js' import { publishListEvent } from '../lib/listEmitter.js' +import { notifyListChange } from '../lib/listChangeDispatcher.js' export const listsRouter = new Hono() @@ -383,6 +384,8 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => { // Fan-out: notify accessible subscribers that this list metadata changed (LIST-04) publishListEvent(listId, { type: 'list:updated', listId, payload: { id: listId, name: updated.name } }) + // Push: coalesced list-change notification to other accessible members (NOTIF-02) + notifyListChange(listId, currentUserId) return c.json({ id: updated.id, @@ -429,6 +432,9 @@ listsRouter.delete('/:id', async (c) => { // Fan-out: notify accessible subscribers that this list was deleted (LIST-04) publishListEvent(listId, { type: 'list:deleted', listId, payload: { id: listId } }) + // Push: coalesced list-change notification to other accessible members (NOTIF-02) + // Note: list is already deleted from DB; notifyListChange handles missing list gracefully. + notifyListChange(listId, currentUserId) return c.json({ id: listId }) } catch (err) { @@ -495,6 +501,8 @@ listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) = // Fan-out: notify accessible subscribers that an item was added (LIST-04) publishListEvent(listId, { type: 'item:added', listId, payload: { id: newItem.id, listId, text: newItem.text } }) + // Push: coalesced list-change notification to other accessible members (NOTIF-02) + notifyListChange(listId, currentUserId) return c.json( { @@ -638,6 +646,11 @@ listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c) // Fan-out: notify accessible subscribers that an item was updated (LIST-04) publishListEvent(item.listId, { type: 'item:updated', listId: item.listId, payload: { id: updated.id, listId: updated.listId } }) + // Push: coalesced list-change notification — only for meaningful changes (D-01). + // Reorder (position) patches do NOT trigger a push; only checked/text changes do. + if (patch.position === undefined) { + notifyListChange(item.listId, currentUserId) + } return c.json({ id: updated.id, @@ -689,6 +702,8 @@ listItemsRouter.delete('/:itemId', async (c) => { // Fan-out: notify accessible subscribers that an item was deleted (LIST-04) publishListEvent(item.listId, { type: 'item:deleted', listId: item.listId, payload: { id: itemId } }) + // Push: coalesced list-change notification to other accessible members (NOTIF-02) + notifyListChange(item.listId, currentUserId) return c.json({ id: itemId }) } catch (err) { diff --git a/apps/api/tests/routes/lists.test.ts b/apps/api/tests/routes/lists.test.ts index 68ee40a..86657cc 100644 --- a/apps/api/tests/routes/lists.test.ts +++ b/apps/api/tests/routes/lists.test.ts @@ -1122,3 +1122,52 @@ describe('PATCH /api/list-items/:id { position } — reorder ordering (LIST-03, expect(getBody.items[1].id).toBe(id1) }) }) + +// --------------------------------------------------------------------------- +// NOTIF-02 reorder-silent gate (D-01) +// +// D-01: Reorder (position) PATCH must NOT trigger a list-change push. +// Non-reorder mutations (checked, text) MUST trigger notifyListChange. +// --------------------------------------------------------------------------- + +describe('NOTIF-02 reorder-silent gate — notifyListChange call site guard (D-01)', () => { + it('PATCH { position } (reorder) does NOT call notifyListChange', async () => { + const ownerId = await seedUser('notif-reorder-silent') + currentDevUserId = ownerId + const listId = await seedList(ownerId, 'Reorder Silent', false) + const itemId = await seedItem(listId, 'item', 'a0') + + // Spy on notifyListChange BEFORE importing the app so the spy is registered + // in the same module context as the router. + const dispatcherModule = await import('../../src/lib/listChangeDispatcher.js') + const spy = vi.spyOn(dispatcherModule, 'notifyListChange') + + const app = await getApp() + const res = await app.request(jsonRequest('PATCH', `/api/list-items/${itemId}`, { position: 'a1' })) + expect(res.status).toBe(200) + + // Position-only PATCH must NOT call notifyListChange (D-01 reorder-silent) + expect(spy).not.toHaveBeenCalled() + + spy.mockRestore() + }) + + it('PATCH { checked: true } DOES call notifyListChange', async () => { + const ownerId = await seedUser('notif-check-notifies') + currentDevUserId = ownerId + const listId = await seedList(ownerId, 'Check Notifies', false) + const itemId = await seedItem(listId, 'item', 'a0') + + const dispatcherModule = await import('../../src/lib/listChangeDispatcher.js') + const spy = vi.spyOn(dispatcherModule, 'notifyListChange') + + const app = await getApp() + const res = await app.request(jsonRequest('PATCH', `/api/list-items/${itemId}`, { checked: true })) + expect(res.status).toBe(200) + + // Checked PATCH MUST call notifyListChange (meaningful mutation per NOTIF-02) + expect(spy).toHaveBeenCalledWith(listId, ownerId) + + spy.mockRestore() + }) +})