feat(iios): notification engine — presence-gated Web Push (engine phase)
Deploy iios-service / build-deploy (push) Failing after 4m37s
CI / build (push) Successful in 5m25s

Generic notification engine: reacts to message.sent and runs three gates before
delivering — policy (DM always; group only on @mention or reply-to-you), presence
(skip if the recipient is focused on that thread), mute (per-thread). Delivery via a
swappable NotificationPort (Web Push/VAPID adapter); a 'gone' (404/410) prunes the sub.

- PresenceService + gateway `focus_thread` signal + disconnect cleanup (room membership
  != viewing, since the sidebar joins every thread room).
- IiosNotificationSubscription table + `muted` on IiosThreadParticipant (migration).
- Endpoints: GET vapid-public-key, POST/DELETE subscribe, POST threads/:id/mute|unmute;
  listThreads returns the caller's `muted`.
- DM-vs-group read from the opaque `membership` attribute in the notification POLICY only
  — kernel stays generic (grep-verified).

Tests: 13 new (3 gates + reply-to-you + prune + web-push adapter states); full suite 205
green. Verified live: module boots, subscribe stored, mute/unmute round-trips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 19:16:53 +05:30
parent f2ef8922ce
commit 0a8544b6fc
19 changed files with 646 additions and 5 deletions
@@ -0,0 +1,131 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
import { makeFakePorts } from '@insignia/iios-testkit';
import { resetDb } from '../test-utils/reset-db';
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
import { ActorResolver } from '../identity/actor.resolver';
import { OutboxBus } from '../outbox/outbox.bus';
import { DlqService } from '../outbox/dlq.service';
import { ProjectionCursorService } from '../projection/projection-cursor.service';
import { PresenceService } from './presence.service';
import { NotificationProjector } from './notification.projector';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
const actors = new ActorResolver(asService);
const ms = () => new MessageService(asService, makeFakePorts(), actors);
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
async function actorIdFor(userId: string): Promise<string> {
const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: userId } });
return (await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } })).id;
}
async function seedSub(userId: string): Promise<string> {
const actorId = await actorIdFor(userId);
const scope = await prisma.iiosScope.findFirstOrThrow();
await prisma.iiosNotificationSubscription.create({ data: { scopeId: scope.id, actorId, kind: 'webpush', endpoint: `https://push/${userId}`, p256dh: 'k', auth: 'a' } });
return actorId;
}
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);
}
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);
return { proj, deliver, presence };
}
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('NotificationProjector', () => {
it('DM: notifies the recipient (absent), never the sender', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
await m.addParticipant(threadId, alice, 'bob');
await seedSub('bob');
await m.send(threadId, alice, { content: 'hi bob' }, 'k1');
const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!);
expect(deliver).toHaveBeenCalledOnce();
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
});
it('group without a mention: 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');
await m.send(threadId, alice, { content: 'hello all' }, 'k1');
const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!);
expect(deliver).not.toHaveBeenCalled();
});
it('group WITH a mention: notifies the mentioned member', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await m.addParticipant(threadId, alice, 'bob');
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]!);
expect(deliver).toHaveBeenCalledOnce();
});
it('group reply-to-you: notifies the parent author even without a mention', async () => {
const m = ms();
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await m.addParticipant(threadId, alice, 'bob');
await seedSub('bob');
const parent = await m.send(threadId, bob, { content: 'question?' }, 'k1'); // bob authors the parent
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
expect(deliver).toHaveBeenCalledOnce();
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
});
it('presence gate: a recipient viewing the thread is NOT notified', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
await m.addParticipant(threadId, alice, 'bob');
await seedSub('bob');
await m.send(threadId, alice, { content: 'hi' }, 'k1');
const presence = new PresenceService();
presence.setFocus('sockB', 'bob', threadId);
const { proj, deliver } = makeProjector(presence);
await proj.onMessageSent((await sentEvents())[0]!);
expect(deliver).not.toHaveBeenCalled();
});
it('mute gate: a muted recipient is NOT notified', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
await m.addParticipant(threadId, alice, 'bob');
const bobActorId = await seedSub('bob');
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]!);
expect(deliver).not.toHaveBeenCalled();
});
it('prunes a subscription that returns "gone"', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
await m.addParticipant(threadId, alice, 'bob');
await seedSub('bob');
await m.send(threadId, alice, { content: 'hi' }, 'k1');
const { proj } = makeProjector(new PresenceService(), 'gone');
await proj.onMessageSent((await sentEvents())[0]!);
expect(await prisma.iiosNotificationSubscription.count()).toBe(0);
});
});