1import { Inject, Injectable } from "@nestjs/common";
2import {
3 NotificationPrefsSchema,
4 mergeNotificationPrefs,
5 type NotificationPrefs,
6 type NotificationTypeValue
7} from "@factory/shared";
8import { PrismaService } from "../../common/prisma.service.js";
9import { EventBus } from "../../common/event-bus.js";
10import { ApiError } from "../../common/errors.js";
11import { parsePagination } from "../../lib/pagination.js";
12import { EVENTS } from "../../constants.js";
13import { actorLevel } from "../../lib/authorize.js";
14
15const NOTIF_SELECT = {
16 id: true,
17 workspaceId: true,
18 type: true,
19 channel: true,
20 title: true,
21 body: true,
22 link: true,
23 readAt: true,
24
25
26 actorId: true,
27 createdAt: true
28} as const;
29
30export interface NotifyInput {
31 workspaceId: string;
32
33 userId: string;
34 type: NotificationTypeValue;
35 title: string;
36 body?: string | null;
37
41 link: { type: string; id: string; pageId?: string };
42
43 actorId?: string | null;
44}
45
46
65@Injectable()
66export class NotificationsService {
67 constructor(
68 @Inject(PrismaService) private readonly prisma: PrismaService,
69 @Inject(EventBus) private readonly events: EventBus
70 ) {}
71
72
79 async notifyWatchersForPageUpdate(pageId: string, actorId: string | null): Promise<number> {
80 const watches = await this.prisma.pageWatch.findMany({ where: { pageId } });
81 const page = await this.prisma.page.findUnique({
82 where: { id: pageId },
83 select: { workspaceId: true, title: true }
84 });
85 if (!watches.length || !page) return 0;
86 let sent = 0;
87 for (const w of watches) {
88 if (w.userId === actorId) continue;
89 const level = await actorLevel(this.prisma, { workspaceId: page.workspaceId, userId: w.userId });
90 if (level === "NONE") continue;
91 try {
92 await this.notify({
93 workspaceId: page.workspaceId,
94 userId: w.userId,
95 type: "WATCH_UPDATE",
96 title: `Watched page updated: ${page.title || "Untitled"}`,
97 link: { type: "page", id: pageId, pageId },
98 actorId: actorId ?? undefined
99 });
100 sent++;
101 } catch {
102
103
104 }
105 }
106 return sent;
107 }
108
109 async list(userId: string, query: { cursor?: string; read?: string; limit?: string }) {
110 const { cursor, limit } = parsePagination(query);
111 const read = query.read;
112 const where: Record<string, unknown> = { userId };
113 if (read === "true") where.readAt = { not: null };
114 else if (read === "false") where.readAt = null;
115
116 const rows = await this.prisma.notification.findMany({
117 where,
118 ...(cursor ? { cursor: { id: cursor.id }, skip: 1 } : {}),
119 orderBy: { createdAt: "desc" },
120 take: limit + 1,
121 select: NOTIF_SELECT
122 });
123 const page = rows.slice(0, limit);
124 const next = rows.length > limit ? page[page.length - 1] : undefined;
125
126
127
128
129
130 const actorIds = [...new Set(page.map((r) => r.actorId).filter((id): id is string => Boolean(id)))];
131 const actors = actorIds.length
132 ? await this.prisma.user.findMany({
133 where: { id: { in: actorIds } },
134 select: { id: true, name: true, avatarUrl: true }
135 })
136 : [];
137 const actorById = new Map(actors.map((u) => [u.id, u]));
138
139 return {
140 data: page.map((r) => ({
141 ...r,
142 actor: r.actorId ? (actorById.get(r.actorId) ?? null) : null
143 })),
144 nextCursor: next
145 ? Buffer.from(JSON.stringify({ id: next.id, position: next.id })).toString("base64url")
146 : null
147 };
148 }
149
150 async markRead(notificationId: string, userId: string) {
151 const row = await this.prisma.notification.findFirst({
152 where: { id: notificationId, userId },
153 select: { id: true, readAt: true }
154 });
155 if (!row) throw ApiError.notFound("Notification not found.");
156 if (!row.readAt) {
157 await this.prisma.notification.update({ where: { id: row.id }, data: { readAt: new Date() } });
158 }
159 return { read: true, id: row.id };
160 }
161
162 async markAllRead(userId: string) {
163 const result = await this.prisma.notification.updateMany({
164 where: { userId, readAt: null },
165 data: { readAt: new Date() }
166 });
167 return { marked: result.count };
168 }
169
170 async unreadCount(userId: string) {
171 const count = await this.prisma.notification.count({ where: { userId, readAt: null } });
172 return { count };
173 }
174
175
182 async notify(input: NotifyInput) {
183 const actor = input.actorId
184 ? await this.prisma.user.findUnique({
185 where: { id: input.actorId },
186 select: { name: true, displayName: true }
187 })
188 : null;
189 const actorName = actor ? actor.displayName ?? actor.name : null;
190
191 const row = await this.prisma.notification.create({
192 data: {
193 workspaceId: input.workspaceId,
194 userId: input.userId,
195 type: input.type,
196 channel: "IN_APP",
197 title: input.title,
198 body: input.body ?? null,
199 link: input.link as object,
200 actorId: input.actorId ?? null
201 }
202 });
203
204 await this.events.publish(EVENTS.NOTIFICATION_CREATED, {
205 workspaceId: input.workspaceId,
206 notificationId: row.id,
207 userId: input.userId,
208 type: input.type,
209 title: input.title,
210 deepLink: input.link,
211 actorId: input.actorId ?? undefined,
212 actorName: actorName ?? undefined
213 });
214
215 return row;
216 }
217
218
219
220
226 async getPrefs(userId: string): Promise<NotificationPrefs> {
227 const user = await this.prisma.user.findUnique({
228 where: { id: userId },
229 select: { notificationPrefs: true }
230 });
231 if (!user) throw ApiError.notFound("User not found.");
232 return mergeNotificationPrefs(user.notificationPrefs);
233 }
234
235
239 async updatePrefs(userId: string, patch: Record<string, string>): Promise<NotificationPrefs> {
240 const current = await this.getPrefs(userId);
241 const next: Record<string, string> = { ...current };
242 for (const [type, mode] of Object.entries(patch)) {
243 next[type] = mode as string;
244 }
245 const validated = NotificationPrefsSchema.parse(next);
246 await this.prisma.user.update({
247 where: { id: userId },
248 data: { notificationPrefs: validated as object }
249 });
250 return validated as unknown as NotificationPrefs;
251 }
252}
253