1/** 2 * @factory/realtime — comment-event → `comment.event` frame builder 3 * (TASK-0094 R1). 4 * 5 * Pure (no sockets, no Redis — unit-testable): maps a `comment.*` domain 6 * event from the `factory:events` stream to the shared 7 * {@link CommentEventFrame} the server fans out over the page room. 8 * 9 * The API's CommentModule is the ONLY emitter (one service call → one 10 * stream event; the frame is derived from it). Payload shape (the emitters 11 * in `apps/api/src/modules/comments` include these fields — kept in 12 * lockstep with the zod validation below): 13 * 14 * comment.created { workspaceId, commentId, pageId?, blockId?, recordId?, 15 * replyId?, actorId, body } 16 * comment.resolved { workspaceId, commentId, pageId?, actorId } 17 * comment.reopened { workspaceId, commentId, pageId?, actorId } 18 * comment.reaction { workspaceId, commentId, pageId?, actorId, emoji, active } 19 * comment.deleted { workspaceId, commentId, pageId?, actorId } 20 * 21 * A frame is ONLY built when a `pageId` is present (the fan-out room is 22 * `comment:{pageId}` — a comment with no resolvable page has no room; the 23 * API's target resolver always resolves one for blocks/pages/records, so 24 * this is defensive). 25 */ 26 27import { z } from "zod"; 28import type { DomainEvent } from "@factory/shared"; 29import { CommentEventFrameSchema, type CommentEventFrame } from "@factory/shared/realtime/frames"; 30import { commentEventKindForEvent } from "@factory/shared/realtime/comment-events"; 31 32/** The preview length (the full comment is fetched on demand — R2). */ 33export const COMMENT_PREVIEW_MAX = 200; 34 35/** The comment stream-event payload (the fields the frame needs). */ 36const CommentEventPayloadSchema = z.object({ 37 commentId: z.string().min(1), 38 /** The fan-out room's page (the API resolves it from the target). */ 39 pageId: z.string().min(1).optional(), 40 /** The acting user (server-side — the client never supplies identity). */ 41 actorId: z.string().min(1), 42 /** New-thread / reply body (the preview source). */ 43 body: z.string().optional(), 44 /** A reply (a `comment.created` with a `replyId` → kind `replied`). */ 45 replyId: z.string().optional(), 46}); 47type CommentEventPayload = z.infer<typeof CommentEventPayloadSchema>; 48 49/** 50 * Build the `comment.event` frame for a domain event. `null` when the event 51 * does not map to a comment frame (another event name), the payload is 52 * malformed, or no `pageId` is present (no room to fan out to). 53 */ 54export function commentEventFrameFor(event: DomainEvent): CommentEventFrame | null { 55 const kind = commentEventKindForEvent(event); 56 if (kind === null) return null; 57 58 const parsed = CommentEventPayloadSchema.safeParse(event.payload); 59 if (!parsed.success) return null; 60 const p = parsed.data as CommentEventPayload; 61 if (p.pageId === undefined) return null; 62 63 const preview = 64 kind === "created" || kind === "replied" ? (p.body ?? "").slice(0, COMMENT_PREVIEW_MAX) : ""; 65 66 const frame = CommentEventFrameSchema.safeParse({ 67 type: "comment.event", 68 kind, 69 commentId: p.commentId, 70 pageId: p.pageId, 71 actorId: p.actorId, 72 preview, 73 }); 74 return frame.success ? frame.data : null; 75} 76