1/** 2 * @factory/realtime — notification-event → `notification.push` frame builder 3 * (TASK-0094 R7). 4 * 5 * Pure (no sockets, no Redis — unit-testable): maps a 6 * `notification.created` domain event from the `factory:events` stream to 7 * the shared {@link NotificationPushFrame} the server fans out to the 8 * recipient's `user:{userId}` control room. 9 * 10 * Payload shape (the emitter in `apps/api/src/modules/notifications` 11 * includes these fields — kept in lockstep with the zod validation below): 12 * 13 * notification.created { workspaceId, notificationId, userId, type, 14 * title, deepLink: { type, id }, actorId?, 15 * actorName? } 16 * 17 * The `kind` field is derived from the type (the shared 18 * `notification-policy` table — the frame schema's `kind` is a free string, 19 * but the policy table is the single source for its value). The push is 20 * fire-and-forget (the row is the durable state — a missed push is healed 21 * by the next poll/pickup, R7). 22 */ 23 24import { z } from "zod"; 25import type { DomainEvent } from "@factory/shared"; 26import { 27 NotificationPushFrameSchema, 28 type NotificationPushFrame, 29} from "@factory/shared/realtime/frames"; 30import { notificationPolicy } from "@factory/shared/realtime/notification-policy"; 31 32/** The `notification.created` stream-event name (single source). */ 33export const NOTIFICATION_CREATED_EVENT = "notification.created"; 34 35/** The notification stream-event payload (the fields the frame needs). */ 36const NotificationCreatedPayloadSchema = z.object({ 37 notificationId: z.string().min(1), 38 /** The RECIPIENT (the user the notification row belongs to). */ 39 userId: z.string().min(1), 40 /** The Prisma NotificationType (drives the `kind` + the web toast). */ 41 type: z.string().min(1), 42 title: z.string(), 43 deepLink: z.object({ type: z.string(), id: z.string() }), 44 /** The acting user's display name (the bell row's actor label). */ 45 actorName: z.string().optional(), 46 /** The acting user (the self-suppression is decided by the API before 47 * the event is emitted — recorded; carried for the metrics). */ 48 actorId: z.string().optional(), 49}); 50type NotificationCreatedPayload = z.infer<typeof NotificationCreatedPayloadSchema>; 51 52export interface NotificationPushTarget { 53 readonly userId: string; 54 readonly frame: NotificationPushFrame; 55} 56 57/** 58 * Build the `notification.push` frame + its target user for a domain event. 59 * `null` when the event is not a `notification.created` or the payload is 60 * malformed. The server fans the frame out to the target user's control 61 * room ONLY (a notification for user X never appears on user Y's socket — 62 * the isolation assert, R7 / the acceptance criteria). 63 */ 64export function notificationPushFor(event: DomainEvent): NotificationPushTarget | null { 65 if (event.name !== NOTIFICATION_CREATED_EVENT) return null; 66 const parsed = NotificationCreatedPayloadSchema.safeParse(event.payload); 67 if (!parsed.success) return null; 68 const p = parsed.data as NotificationCreatedPayload; 69 70 const frame = NotificationPushFrameSchema.safeParse({ 71 type: "notification.push", 72 notificationId: p.notificationId, 73 kind: notificationPolicy(p.type).kind, 74 title: p.title, 75 deepLink: p.deepLink, 76 ...(p.actorName !== undefined ? { actorName: p.actorName } : {}), 77 }); 78 if (!frame.success) return null; 79 return { userId: p.userId, frame: frame.data }; 80} 81