/** * T-178 — org-admin NOTIFICATIONS tab (delivery health). * * The email pipeline is: API creates the Notification row (channel IN_APP) + * publishes `notification.created` → the worker's email-decision consumer * decides + enqueues ONE email job per notification on the `email` queue (its * payload carries `notificationIds: [id]`) → the email processor sends and * stamps `emailSentAt`. * * This service reports, for the org's workspaces: * - `inAppTotal` / `inAppUnread` — the bell volume + the unread backlog; * - `emailQueued` — email-channel jobs in the queue (waiting/delayed/ * active); * - `emailFailed` — email-channel jobs in the FAILED state (each carries * its failedReason + attemptsMade); * - `failedJobs` — the recent failed email jobs (the "recent failures" * list the tab shows), newest first. * * Org-scoping (no-leak) is done in Postgres: a queue job is only surfaced * when its PAYLOAD's `notificationIds` contain a notification in THIS org * (another org's failed emails are never listed). Correlation is by payload, * NOT by the BullMQ job id — the worker's instant-email dedup key is * `email_{id}` (colon-FREE: BullMQ 5.x rejects custom jobIds containing `:` * unless exactly 3-segment; the old `email:{id}` made every instant-enqueue * throw — see the T-178 conflict note, FIXED 2026-10-12). Parseable but * payload correlation stays the robust path (digest jobs carry many ids; * automation/webhook jobs carry none). */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import { PrismaService } from "../../common/prisma.service.js"; import { RedisService } from "../../common/redis.service.js"; import { requireUser } from "../../common/types.js"; import { assertOrgAdmin } from "./guards.js"; /** Cap on the recent-failures list (one screenful). */ const FAILED_LIST_CAP = 50; /** How many failed jobs to pull per queue page before correlating. */ const FAILED_SCAN = 200; @Injectable() export class OrgNotificationsService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(RedisService) private readonly redis: RedisService ) {} async health(orgId: string, req: Request) { const userId = requireUser(req); await assertOrgAdmin(this.prisma, orgId, userId); const workspaces = await this.prisma.workspace.findMany({ where: { organizationId: orgId }, select: { id: true } }); const workspaceIds = workspaces.map((w) => w.id); // Bell volume (the IN_APP channel is the one the API writes). const [inAppTotal, inAppUnread] = await Promise.all([ this.prisma.notification.count({ where: { workspaceId: { in: workspaceIds }, channel: "IN_APP" } }), this.prisma.notification.count({ where: { workspaceId: { in: workspaceIds }, channel: "IN_APP", readAt: null } }) ]); // Email-channel jobs: correlate the queue's email jobs (jobId // `email_{notificationId}` — colon-free; the old `email:{id}` was rejected // by BullMQ 5.x) to THIS org's notifications. The correlation // is done in Postgres (org-scoped), never by trusting the queue. const notificationIds = await this.prisma.notification.findMany({ where: { workspaceId: { in: workspaceIds } }, select: { id: true } }); const orgNotificationIds = new Set(notificationIds.map((n) => n.id)); // The email queue is the only one that matters here. Correlation is done // by the job's PAYLOAD (`data.notificationIds`) — the field the worker's // `buildInstantEmailData` + the digest enqueue set (and `emailJobSchema` // reads to stamp `emailSentAt`) — NOT by the BullMQ job id. The instant // key is now colon-free (`email_{id}`; the old `email:{id}` was rejected // by BullMQ 5.x — see the T-178 conflict note, fixed 2026-10-12), but // payload correlation stays the robust path: a digest job carries MANY // ids and other email jobs (verify/invite/admin) carry none. It is the // no-leak guarantee (org-scoped in Postgres). const q = await this.openEmailQueue(); let emailQueued = 0; let emailFailed = 0; const failedJobs: Array> = []; try { const waiting = await q.getJobCountByTypes("waiting", "delayed"); const active = await q.getJobCountByTypes("active"); emailQueued = waiting + active; // Correlate: pull the queue's failed jobs, keep only those whose payload // maps to a notification in THIS org. The payload carries `notificationIds` // (an array — one for an instant email, many for a digest). const failedAll = await q.getFailed(0, FAILED_SCAN - 1); for (const j of failedAll) { const ids = notificationIdsFromPayload(j.data); const matched = ids.find((id) => orgNotificationIds.has(id)); if (!matched) continue; emailFailed++; if (failedJobs.length < FAILED_LIST_CAP) { failedJobs.push({ notificationId: matched, jobId: j.id, name: j.name, failedReason: j.failedReason ? String(j.failedReason).slice(0, 500) : null, failedAt: j.finishedOn ? new Date(j.finishedOn).toISOString() : null, attemptsMade: j.attemptsMade }); } } failedJobs.sort((a, b) => String(b.failedAt).localeCompare(String(a.failedAt))); } finally { await q.close(); } return { workspaces: workspaceIds.length, inAppTotal, inAppUnread, emailQueued, emailFailed, failedJobs }; } private async openEmailQueue() { const { Queue } = await import("bullmq"); return new Queue("email", { connection: this.redis.getRedis().options as never }); } } /** Extract the notification ids from an email job's payload. The worker's * `buildInstantEmailData` (instant) and the digest enqueue both set * `notificationIds: string[]` (one for instant, many for digest) — the field * `emailJobSchema` reads to stamp `emailSentAt`. Returns [] for non- * notification payloads (verify / invite / admin alerts omit it). */ function notificationIdsFromPayload(data: unknown): string[] { if (!data || typeof data !== "object") return []; const ids = (data as Record).notificationIds; if (!Array.isArray(ids)) return []; return ids.filter((id): id is string => typeof id === "string" && id.length > 0); }