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
@@ -54,6 +54,19 @@ export class ThreadsController {
return this.threads.getMessages(id);
}
/** Mute / unmute notifications from this thread for the caller. */
@Post(':id/mute')
@HttpCode(200)
async mute(@Param('id') id: string, @Headers('authorization') auth?: string) {
return this.messages.muteThread(id, this.principal(auth), true);
}
@Post(':id/unmute')
@HttpCode(200)
async unmute(@Param('id') id: string, @Headers('authorization') auth?: string) {
return this.messages.muteThread(id, this.principal(auth), false);
}
/** REST/polling fallback for native send (same write as the socket path). */
@Post(':id/messages')
@HttpCode(201)