feat(iios): notification engine — presence-gated Web Push (engine phase)
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:
@@ -4,6 +4,7 @@ import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
OnGatewayInit,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
@@ -15,6 +16,7 @@ import { logJson } from '../observability/logger';
|
||||
import { MessageService, type MessagePrincipal } from './message.service';
|
||||
import { SessionVerifier } from '../platform/session.verifier';
|
||||
import { OutboxBus } from '../outbox/outbox.bus';
|
||||
import { PresenceService } from '../notifications/presence.service';
|
||||
|
||||
interface SocketState {
|
||||
principal: MessagePrincipal;
|
||||
@@ -27,7 +29,7 @@ interface SocketState {
|
||||
* propagates messages persisted elsewhere (REST/ingest) to connected clients.
|
||||
*/
|
||||
@WebSocketGateway({ namespace: '/message', cors: { origin: true } })
|
||||
export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
|
||||
export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||
private readonly logger = new Logger(MessageGateway.name);
|
||||
private readonly emittedLocally = new Set<string>();
|
||||
|
||||
@@ -37,6 +39,7 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
|
||||
private readonly messages: MessageService,
|
||||
private readonly session: SessionVerifier,
|
||||
private readonly bus: OutboxBus,
|
||||
private readonly presence: PresenceService,
|
||||
) {}
|
||||
|
||||
afterInit(): void {
|
||||
@@ -65,6 +68,18 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket): void {
|
||||
this.presence.clearSocket(client.id);
|
||||
}
|
||||
|
||||
/** The app reports which thread is in the foreground (or null when blurred) — presence. */
|
||||
@SubscribeMessage('focus_thread')
|
||||
focusThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string | null }): void {
|
||||
const state = client.data as SocketState | undefined;
|
||||
if (!state?.principal) return;
|
||||
this.presence.setFocus(client.id, state.principal.userId, body?.threadId ?? null);
|
||||
}
|
||||
|
||||
@SubscribeMessage('open_thread')
|
||||
async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string; membership?: string; creatorRole?: string; subject?: string }) {
|
||||
const { principal } = client.data as SocketState;
|
||||
|
||||
Reference in New Issue
Block a user