1
29import { Inject, Injectable } from "@nestjs/common";
30import type { Request } from "express";
31import { PrismaService } from "../../common/prisma.service.js";
32import { RedisService } from "../../common/redis.service.js";
33import { requireUser } from "../../common/types.js";
34import { assertOrgAdmin } from "./guards.js";
35
36
37const FAILED_LIST_CAP = 50;
38
39const FAILED_SCAN = 200;
40
41@Injectable()
42export class OrgNotificationsService {
43 constructor(
44 @Inject(PrismaService) private readonly prisma: PrismaService,
45 @Inject(RedisService) private readonly redis: RedisService
46 ) {}
47
48 async health(orgId: string, req: Request) {
49 const userId = requireUser(req);
50 await assertOrgAdmin(this.prisma, orgId, userId);
51
52 const workspaces = await this.prisma.workspace.findMany({
53 where: { organizationId: orgId },
54 select: { id: true }
55 });
56 const workspaceIds = workspaces.map((w) => w.id);
57
58
59 const [inAppTotal, inAppUnread] = await Promise.all([
60 this.prisma.notification.count({ where: { workspaceId: { in: workspaceIds }, channel: "IN_APP" } }),
61 this.prisma.notification.count({
62 where: { workspaceId: { in: workspaceIds }, channel: "IN_APP", readAt: null }
63 })
64 ]);
65
66
67
68
69
70 const notificationIds = await this.prisma.notification.findMany({
71 where: { workspaceId: { in: workspaceIds } },
72 select: { id: true }
73 });
74 const orgNotificationIds = new Set(notificationIds.map((n) => n.id));
75
76
77
78
79
80
81
82
83
84
85 const q = await this.openEmailQueue();
86 let emailQueued = 0;
87 let emailFailed = 0;
88 const failedJobs: Array<Record<string, unknown>> = [];
89 try {
90 const waiting = await q.getJobCountByTypes("waiting", "delayed");
91 const active = await q.getJobCountByTypes("active");
92 emailQueued = waiting + active;
93
94
95
96
97 const failedAll = await q.getFailed(0, FAILED_SCAN - 1);
98 for (const j of failedAll) {
99 const ids = notificationIdsFromPayload(j.data);
100 const matched = ids.find((id) => orgNotificationIds.has(id));
101 if (!matched) continue;
102 emailFailed++;
103 if (failedJobs.length < FAILED_LIST_CAP) {
104 failedJobs.push({
105 notificationId: matched,
106 jobId: j.id,
107 name: j.name,
108 failedReason: j.failedReason ? String(j.failedReason).slice(0, 500) : null,
109 failedAt: j.finishedOn ? new Date(j.finishedOn).toISOString() : null,
110 attemptsMade: j.attemptsMade
111 });
112 }
113 }
114 failedJobs.sort((a, b) => String(b.failedAt).localeCompare(String(a.failedAt)));
115 } finally {
116 await q.close();
117 }
118
119 return {
120 workspaces: workspaceIds.length,
121 inAppTotal,
122 inAppUnread,
123 emailQueued,
124 emailFailed,
125 failedJobs
126 };
127 }
128
129 private async openEmailQueue() {
130 const { Queue } = await import("bullmq");
131 return new Queue("email", { connection: this.redis.getRedis().options as never });
132 }
133}
134
135
140function notificationIdsFromPayload(data: unknown): string[] {
141 if (!data || typeof data !== "object") return [];
142 const ids = (data as Record<string, unknown>).notificationIds;
143 if (!Array.isArray(ids)) return [];
144 return ids.filter((id): id is string => typeof id === "string" && id.length > 0);
145}
146