feat(notifications): push for inbound mail, not just messenger
NotificationProjector now also consumes interaction.normalized and, for kind EMAIL (external email + app-to-app mail, which land via ingest — not message.sent), always notifies the non-sender recipient(s), reusing the same presence + mute gates. Refactors the fan-out into a shared notify() with an alwaysNotify flag (DM or mail); message.sent keeps its DM/mention/reply policy. Adds specs: mail notifies in a group thread where a plain message would not, and a non-EMAIL normalized interaction does not notify. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,18 @@ async function sentEvents(): Promise<CloudEvent[]> {
|
||||
const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.messageSent }, orderBy: { createdAt: 'asc' } });
|
||||
return rows.map((r) => r.cloudEvent as unknown as CloudEvent);
|
||||
}
|
||||
/** A synthetic interaction.normalized event (the shape ingest/mail emits) for an existing interaction. */
|
||||
function normalizedEvent(interactionId: string, threadId: string, kind: string): CloudEvent {
|
||||
return {
|
||||
specversion: '1.0',
|
||||
id: `evt_norm_${interactionId}`,
|
||||
type: IIOS_EVENTS.interactionNormalized,
|
||||
source: 'iios/ingest/test',
|
||||
time: '2026-01-01T00:00:00.000Z',
|
||||
insignia: { idempotencyKey: `norm:${interactionId}` },
|
||||
data: { interactionId, threadId, kind },
|
||||
} as CloudEvent;
|
||||
}
|
||||
function makeProjector(presence = new PresenceService(), deliverResult: 'sent' | 'gone' = 'sent') {
|
||||
const deliver = vi.fn().mockResolvedValue(deliverResult);
|
||||
const proj = new NotificationProjector(asService, new OutboxBus(), new DlqService(asService), new ProjectionCursorService(asService), presence, { deliver } as never);
|
||||
@@ -51,7 +63,7 @@ describe('NotificationProjector', () => {
|
||||
await seedSub('bob');
|
||||
await m.send(threadId, alice, { content: 'hi bob' }, 'k1');
|
||||
const { proj, deliver } = makeProjector();
|
||||
await proj.onMessageSent((await sentEvents())[0]!);
|
||||
await proj.onEvent((await sentEvents())[0]!);
|
||||
expect(deliver).toHaveBeenCalledOnce();
|
||||
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
||||
});
|
||||
@@ -63,7 +75,7 @@ describe('NotificationProjector', () => {
|
||||
await seedSub('bob');
|
||||
await m.send(threadId, alice, { content: 'hello all' }, 'k1');
|
||||
const { proj, deliver } = makeProjector();
|
||||
await proj.onMessageSent((await sentEvents())[0]!);
|
||||
await proj.onEvent((await sentEvents())[0]!);
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -74,7 +86,7 @@ describe('NotificationProjector', () => {
|
||||
await seedSub('bob');
|
||||
await m.send(threadId, alice, { content: 'hey @bob' }, 'k1', undefined, undefined, ['bob']);
|
||||
const { proj, deliver } = makeProjector();
|
||||
await proj.onMessageSent((await sentEvents())[0]!);
|
||||
await proj.onEvent((await sentEvents())[0]!);
|
||||
expect(deliver).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -88,7 +100,7 @@ describe('NotificationProjector', () => {
|
||||
await m.send(threadId, alice, { content: 'answer' }, 'k2', undefined, parent.id); // alice replies to bob (no mention)
|
||||
const { proj, deliver } = makeProjector();
|
||||
const evs = await sentEvents();
|
||||
await proj.onMessageSent(evs[evs.length - 1]!); // project the reply
|
||||
await proj.onEvent(evs[evs.length - 1]!); // project the reply
|
||||
expect(deliver).toHaveBeenCalledOnce();
|
||||
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
||||
});
|
||||
@@ -102,7 +114,7 @@ describe('NotificationProjector', () => {
|
||||
const presence = new PresenceService();
|
||||
presence.setFocus('sockB', 'bob', threadId);
|
||||
const { proj, deliver } = makeProjector(presence);
|
||||
await proj.onMessageSent((await sentEvents())[0]!);
|
||||
await proj.onEvent((await sentEvents())[0]!);
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -114,7 +126,31 @@ describe('NotificationProjector', () => {
|
||||
await prisma.iiosThreadParticipant.update({ where: { threadId_actorId: { threadId, actorId: bobActorId } }, data: { muted: true } });
|
||||
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||
const { proj, deliver } = makeProjector();
|
||||
await proj.onMessageSent((await sentEvents())[0]!);
|
||||
await proj.onEvent((await sentEvents())[0]!);
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mail (interaction.normalized, kind EMAIL): notifies the recipient even in a group thread', async () => {
|
||||
// A group thread would NOT notify bob on message.sent (proven above); the EMAIL branch always does.
|
||||
const m = ms();
|
||||
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||
await m.addParticipant(threadId, alice, 'bob');
|
||||
await seedSub('bob');
|
||||
const sent = await m.send(threadId, alice, { content: 'your invoice is attached' }, 'k1');
|
||||
const { proj, deliver } = makeProjector();
|
||||
await proj.onEvent(normalizedEvent(sent.id, threadId, 'EMAIL'));
|
||||
expect(deliver).toHaveBeenCalledOnce();
|
||||
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
||||
});
|
||||
|
||||
it('interaction.normalized that is NOT mail (kind MESSAGE): does not notify', async () => {
|
||||
const m = ms();
|
||||
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||
await m.addParticipant(threadId, alice, 'bob');
|
||||
await seedSub('bob');
|
||||
const sent = await m.send(threadId, alice, { content: 'hello all' }, 'k1');
|
||||
const { proj, deliver } = makeProjector();
|
||||
await proj.onEvent(normalizedEvent(sent.id, threadId, 'MESSAGE'));
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -125,7 +161,7 @@ describe('NotificationProjector', () => {
|
||||
await seedSub('bob');
|
||||
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||
const { proj } = makeProjector(new PresenceService(), 'gone');
|
||||
await proj.onMessageSent((await sentEvents())[0]!);
|
||||
await proj.onEvent((await sentEvents())[0]!);
|
||||
expect(await prisma.iiosNotificationSubscription.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user