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,30 @@
-- AlterTable
ALTER TABLE "IiosThreadParticipant" ADD COLUMN "muted" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "IiosNotificationSubscription" (
"id" TEXT NOT NULL,
"scopeId" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"kind" TEXT NOT NULL DEFAULT 'webpush',
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IiosNotificationSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "IiosNotificationSubscription_endpoint_key" ON "IiosNotificationSubscription"("endpoint");
-- CreateIndex
CREATE INDEX "IiosNotificationSubscription_actorId_idx" ON "IiosNotificationSubscription"("actorId");
-- AddForeignKey
ALTER TABLE "IiosNotificationSubscription" ADD CONSTRAINT "IiosNotificationSubscription_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IiosNotificationSubscription" ADD CONSTRAINT "IiosNotificationSubscription_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "IiosActorRef"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -309,6 +309,7 @@ model IiosScope {
supportQueues IiosSupportQueue[]
tickets IiosTicket[]
callbacks IiosCallbackRequest[]
notificationSubscriptions IiosNotificationSubscription[]
@@index([orgId, appId, tenantId])
}
@@ -358,6 +359,7 @@ model IiosActorRef {
ticketsRequested IiosTicket[] @relation("TicketRequester")
ticketsAssigned IiosTicket[] @relation("TicketAssignee")
callbacksRequested IiosCallbackRequest[]
notificationSubscriptions IiosNotificationSubscription[]
}
/// A configured channel surface (PORTAL in P1).
@@ -412,12 +414,32 @@ model IiosThreadParticipant {
actorId String
actor IiosActorRef @relation(fields: [actorId], references: [id])
participantRole String @default("MEMBER")
muted Boolean @default(false)
joinedAt DateTime @default(now())
leftAt DateTime?
@@id([threadId, actorId])
}
/// A device/browser push subscription for an actor (Web Push endpoint + keys).
/// The notification engine delivers to these when the actor is absent.
model IiosNotificationSubscription {
id String @id @default(cuid())
scopeId String
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
actorId String
actor IiosActorRef @relation(fields: [actorId], references: [id])
kind String @default("webpush")
endpoint String @unique
p256dh String
auth String
userAgent String?
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
@@index([actorId])
}
/// The semantic envelope. Idempotent per (scope, idempotencyKey).
model IiosInteraction {
id String @id @default(cuid())