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,46 @@
import { BadRequestException, Body, Controller, Delete, Get, Headers, Post } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { SessionVerifier } from '../platform/session.verifier';
import { SubscribeDto, UnsubscribeDto } from './notification.dto';
@Controller('v1/notifications')
export class NotificationController {
constructor(
private readonly prisma: PrismaService,
private readonly actors: ActorResolver,
private readonly session: SessionVerifier,
) {}
/** The public VAPID key the client needs to create a push subscription. */
@Get('vapid-public-key')
vapidKey(): { key: string } {
return { key: process.env.VAPID_PUBLIC_KEY ?? '' };
}
/** Store (or refresh) a push subscription for the caller. */
@Post('subscribe')
async subscribe(@Body() body: SubscribeDto, @Headers('authorization') auth?: string): Promise<{ ok: true }> {
const principal = this.principal(auth);
const scope = await this.actors.resolveScope(principal);
const actor = await this.actors.resolveActor(scope.id, principal);
await this.prisma.iiosNotificationSubscription.upsert({
where: { endpoint: body.endpoint },
create: { scopeId: scope.id, actorId: actor.id, kind: body.kind ?? 'webpush', endpoint: body.endpoint, p256dh: body.keys.p256dh, auth: body.keys.auth, userAgent: body.userAgent },
update: { actorId: actor.id, p256dh: body.keys.p256dh, auth: body.keys.auth, lastSeenAt: new Date() },
});
return { ok: true };
}
@Delete('subscribe')
async unsubscribe(@Body() body: UnsubscribeDto): Promise<{ ok: true }> {
await this.prisma.iiosNotificationSubscription.deleteMany({ where: { endpoint: body.endpoint } });
return { ok: true };
}
private principal(auth?: string): MessagePrincipal {
const token = (auth ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
@@ -0,0 +1,12 @@
import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
export class SubscribeDto {
@IsOptional() @IsIn(['webpush']) kind?: string;
@IsString() endpoint!: string;
@IsObject() keys!: { p256dh: string; auth: string };
@IsOptional() @IsString() userAgent?: string;
}
export class UnsubscribeDto {
@IsString() endpoint!: string;
}
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { OutboxModule } from '../outbox/outbox.module';
import { MessageModule } from '../messaging/message.module';
import { NotificationController } from './notification.controller';
import { NotificationProjector } from './notification.projector';
import { WebPushDelivery } from './web-push.delivery';
import { NOTIFICATION_PORT } from './notification.port';
@Module({
imports: [OutboxModule, MessageModule], // OutboxModule → bus/dlq; MessageModule → PresenceService. Prisma/Projection/Identity are @Global.
controllers: [NotificationController],
providers: [
NotificationProjector,
{
// Dev binds Web Push (VAPID); prod can swap this to an email/FCM adapter.
provide: NOTIFICATION_PORT,
useFactory: () =>
new WebPushDelivery(
process.env.VAPID_PUBLIC_KEY
? { publicKey: process.env.VAPID_PUBLIC_KEY, privateKey: process.env.VAPID_PRIVATE_KEY ?? '', subject: process.env.VAPID_SUBJECT ?? 'mailto:dev@insignia' }
: undefined,
),
},
],
})
export class NotificationModule {}
@@ -0,0 +1,18 @@
/** The delivery seam. Dev = Web Push (VAPID); prod can swap to email/FCM/APNs. */
export interface PushSub {
endpoint: string;
p256dh: string;
auth: string;
}
export interface NotificationPayload {
title: string;
body: string;
data: { threadId: string; interactionId?: string };
}
export interface NotificationPort {
deliver(sub: PushSub, payload: NotificationPayload): Promise<'sent' | 'gone' | 'failed'>;
}
export const NOTIFICATION_PORT = Symbol('NOTIFICATION_PORT');
@@ -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);
});
});
@@ -0,0 +1,114 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
import { PrismaService } from '../prisma/prisma.service';
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 { NOTIFICATION_PORT, type NotificationPort } from './notification.port';
interface MsgData {
interactionId: string;
threadId: string;
senderActorId: string;
mentions?: string[];
}
/**
* Turns `message.sent` into push notifications for absent recipients. Three gates:
* 1. policy — DM always; group only if @mentioned or a reply to that recipient
* 2. presence — skip if the recipient is currently focused on that thread
* 3. mute — skip if the recipient muted the thread
* Then dispatches to each of the recipient's subscriptions; a 'gone' result prunes it.
* Idempotent per event id (same pattern as InboxProjector). Generic: DM-vs-group is read
* from the opaque `membership` thread attribute here in the notification *policy*, not the kernel.
*/
@Injectable()
export class NotificationProjector implements OnModuleInit {
private readonly consumer = 'notification-projector';
constructor(
private readonly prisma: PrismaService,
private readonly bus: OutboxBus,
private readonly dlq: DlqService,
private readonly cursor: ProjectionCursorService,
private readonly presence: PresenceService,
@Inject(NOTIFICATION_PORT) private readonly port: NotificationPort,
) {}
onModuleInit(): void {
this.dlq.registerHandler(this.consumer, (e) => this.onMessageSent(e));
this.bus.on(IIOS_EVENTS.messageSent, (p) =>
void this.onMessageSent(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)),
);
}
async onMessageSent(event: CloudEvent): Promise<void> {
if (!(await this.claim(event.id))) return; // duplicate → no apply, no cursor advance
await this.apply(event);
await this.cursor.advance(this.consumer, event);
}
private async apply(event: CloudEvent): Promise<void> {
const data = event.data as MsgData;
const thread = await this.prisma.iiosThread.findUnique({ where: { id: data.threadId } });
if (!thread) return;
const membership = (thread.metadata as { membership?: string } | null)?.membership;
const interaction = await this.prisma.iiosInteraction.findUnique({
where: { id: data.interactionId },
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
});
const senderName = interaction?.actor?.sourceHandle?.externalId ?? 'Someone';
const body = interaction?.parts[0]?.bodyText ?? 'sent an attachment';
const mentions = data.mentions ?? [];
// Parent author (for reply-to-you) — one lookup.
let parentAuthorActorId: string | null = null;
if (interaction?.parentInteractionId) {
const parent = await this.prisma.iiosInteraction.findUnique({
where: { id: interaction.parentInteractionId },
select: { actorId: true },
});
parentAuthorActorId = parent?.actorId ?? null;
}
const participants = await this.prisma.iiosThreadParticipant.findMany({
where: { threadId: data.threadId },
include: { actor: { include: { sourceHandle: true } } },
});
for (const p of participants) {
if (p.actorId === data.senderActorId) continue; // never the sender
const userId = p.actor?.sourceHandle?.externalId;
// gate 1 — policy
const mentioned = userId != null && mentions.includes(userId);
const repliedToMe = parentAuthorActorId != null && parentAuthorActorId === p.actorId;
if (!(membership === 'dm' || mentioned || repliedToMe)) continue;
// gate 2 — presence
if (userId && this.presence.isViewing(userId, data.threadId)) continue;
// gate 3 — mute
if (p.muted) continue;
const subs = await this.prisma.iiosNotificationSubscription.findMany({ where: { actorId: p.actorId } });
for (const s of subs) {
const result = await this.port.deliver(
{ endpoint: s.endpoint, p256dh: s.p256dh, auth: s.auth },
{ title: senderName, body, data: { threadId: data.threadId, interactionId: data.interactionId } },
);
if (result === 'gone') await this.prisma.iiosNotificationSubscription.delete({ where: { id: s.id } });
}
}
}
private async claim(eventId: string): Promise<boolean> {
const res = await this.prisma.iiosProcessedEvent.createMany({
data: [{ consumerName: this.consumer, eventId }],
skipDuplicates: true,
});
return res.count > 0;
}
}
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { PresenceService } from './presence.service';
describe('PresenceService', () => {
it('reports viewing only for the focused thread, and clears on disconnect', () => {
const p = new PresenceService();
expect(p.isViewing('alice', 'T1')).toBe(false);
p.setFocus('sock1', 'alice', 'T1');
expect(p.isViewing('alice', 'T1')).toBe(true);
expect(p.isViewing('alice', 'T2')).toBe(false); // joined-elsewhere ≠ viewing
p.setFocus('sock1', 'alice', 'T2'); // moved focus
expect(p.isViewing('alice', 'T1')).toBe(false);
expect(p.isViewing('alice', 'T2')).toBe(true);
p.clearSocket('sock1');
expect(p.isViewing('alice', 'T2')).toBe(false);
});
it('any of the actors sockets counts as viewing', () => {
const p = new PresenceService();
p.setFocus('sockA', 'bob', 'T9');
p.setFocus('sockB', 'bob', null); // a second tab, no focus
expect(p.isViewing('bob', 'T9')).toBe(true);
});
});
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
/**
* In-memory focus tracker (single instance). Keyed on userId (the stable externalId the
* gateway has as principal.userId). NOTE: room membership ≠ viewing — the sidebar joins
* every thread room for live updates, so presence uses an explicit `focus_thread` signal.
* Prod (multi-replica): back this with Redis.
*/
@Injectable()
export class PresenceService {
private readonly focus = new Map<string, { userId: string; threadId: string | null }>(); // socketId → focus
setFocus(socketId: string, userId: string, threadId: string | null): void {
this.focus.set(socketId, { userId, threadId });
}
clearSocket(socketId: string): void {
this.focus.delete(socketId);
}
isViewing(userId: string, threadId: string): boolean {
for (const f of this.focus.values()) if (f.userId === userId && f.threadId === threadId) return true;
return false;
}
}
@@ -0,0 +1,34 @@
import { describe, it, expect, vi } from 'vitest';
import { WebPushDelivery } from './web-push.delivery';
const sub = { endpoint: 'https://push/x', p256dh: 'k', auth: 'a' };
const payload = { title: 'Bob', body: 'hi', data: { threadId: 'T1' } };
const vapid = { publicKey: 'p', privateKey: 'k', subject: 'mailto:x' };
describe('WebPushDelivery', () => {
it('returns "sent" on success', async () => {
const send = vi.fn().mockResolvedValue({ statusCode: 201 });
const d = new WebPushDelivery(vapid, send as never);
expect(await d.deliver(sub, payload)).toBe('sent');
expect(send).toHaveBeenCalledOnce();
});
it('returns "gone" on 404/410 so the caller prunes', async () => {
const d410 = new WebPushDelivery(vapid, vi.fn().mockRejectedValue({ statusCode: 410 }) as never);
expect(await d410.deliver(sub, payload)).toBe('gone');
const d404 = new WebPushDelivery(vapid, vi.fn().mockRejectedValue({ statusCode: 404 }) as never);
expect(await d404.deliver(sub, payload)).toBe('gone');
});
it('returns "failed" on other errors', async () => {
const d = new WebPushDelivery(vapid, vi.fn().mockRejectedValue({ statusCode: 500 }) as never);
expect(await d.deliver(sub, payload)).toBe('failed');
});
it('is disabled (returns "failed", never calls send) without VAPID keys', async () => {
const send = vi.fn();
const d = new WebPushDelivery(undefined, send as never);
expect(await d.deliver(sub, payload)).toBe('failed');
expect(send).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import webpush from 'web-push';
import type { NotificationPayload, NotificationPort, PushSub } from './notification.port';
type SendFn = typeof webpush.sendNotification;
export interface Vapid {
publicKey: string;
privateKey: string;
subject: string;
}
/** Web Push (VAPID) delivery. A dead subscription (404/410) → 'gone' so the caller prunes it. */
@Injectable()
export class WebPushDelivery implements NotificationPort {
private readonly vapid?: Vapid;
constructor(
vapid?: Vapid,
private readonly send: SendFn = webpush.sendNotification,
) {
// Store VAPID; pass it per-send (not global setVapidDetails) so construction never
// validates keys — keeps the adapter unit-testable with a mocked transport.
this.vapid = vapid?.publicKey ? vapid : undefined;
}
async deliver(sub: PushSub, payload: NotificationPayload): Promise<'sent' | 'gone' | 'failed'> {
if (!this.vapid) return 'failed'; // push disabled (no VAPID keys)
try {
await this.send(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload),
{ vapidDetails: this.vapid },
);
return 'sent';
} catch (e) {
const code = (e as { statusCode?: number }).statusCode;
return code === 404 || code === 410 ? 'gone' : 'failed';
}
}
}