/** * @factory/realtime — notification-event → `notification.push` frame builder * (TASK-0094 R7). * * Pure (no sockets, no Redis — unit-testable): maps a * `notification.created` domain event from the `factory:events` stream to * the shared {@link NotificationPushFrame} the server fans out to the * recipient's `user:{userId}` control room. * * Payload shape (the emitter in `apps/api/src/modules/notifications` * includes these fields — kept in lockstep with the zod validation below): * * notification.created { workspaceId, notificationId, userId, type, * title, deepLink: { type, id }, actorId?, * actorName? } * * The `kind` field is derived from the type (the shared * `notification-policy` table — the frame schema's `kind` is a free string, * but the policy table is the single source for its value). The push is * fire-and-forget (the row is the durable state — a missed push is healed * by the next poll/pickup, R7). */ import { z } from "zod"; import type { DomainEvent } from "@factory/shared"; import { NotificationPushFrameSchema, type NotificationPushFrame, } from "@factory/shared/realtime/frames"; import { notificationPolicy } from "@factory/shared/realtime/notification-policy"; /** The `notification.created` stream-event name (single source). */ export const NOTIFICATION_CREATED_EVENT = "notification.created"; /** The notification stream-event payload (the fields the frame needs). */ const NotificationCreatedPayloadSchema = z.object({ notificationId: z.string().min(1), /** The RECIPIENT (the user the notification row belongs to). */ userId: z.string().min(1), /** The Prisma NotificationType (drives the `kind` + the web toast). */ type: z.string().min(1), title: z.string(), deepLink: z.object({ type: z.string(), id: z.string() }), /** The acting user's display name (the bell row's actor label). */ actorName: z.string().optional(), /** The acting user (the self-suppression is decided by the API before * the event is emitted — recorded; carried for the metrics). */ actorId: z.string().optional(), }); type NotificationCreatedPayload = z.infer; export interface NotificationPushTarget { readonly userId: string; readonly frame: NotificationPushFrame; } /** * Build the `notification.push` frame + its target user for a domain event. * `null` when the event is not a `notification.created` or the payload is * malformed. The server fans the frame out to the target user's control * room ONLY (a notification for user X never appears on user Y's socket — * the isolation assert, R7 / the acceptance criteria). */ export function notificationPushFor(event: DomainEvent): NotificationPushTarget | null { if (event.name !== NOTIFICATION_CREATED_EVENT) return null; const parsed = NotificationCreatedPayloadSchema.safeParse(event.payload); if (!parsed.success) return null; const p = parsed.data as NotificationCreatedPayload; const frame = NotificationPushFrameSchema.safeParse({ type: "notification.push", notificationId: p.notificationId, kind: notificationPolicy(p.type).kind, title: p.title, deepLink: p.deepLink, ...(p.actorName !== undefined ? { actorName: p.actorName } : {}), }); if (!frame.success) return null; return { userId: p.userId, frame: frame.data }; }