Barazo AppView backend
barazo.forum
1import { eq, inArray } from 'drizzle-orm'
2import type { Database } from '../db/index.js'
3import type { Logger } from '../lib/logger.js'
4import { notifications } from '../db/schema/notifications.js'
5import { topics } from '../db/schema/topics.js'
6import { replies } from '../db/schema/replies.js'
7import { users } from '../db/schema/users.js'
8
9// ---------------------------------------------------------------------------
10// Constants
11// ---------------------------------------------------------------------------
12
13/** Maximum unique @mentions that generate notifications per post. */
14const MAX_MENTION_NOTIFICATIONS = 10
15
16/**
17 * Regex to extract @mentions from content.
18 * Matches `@handle.domain.tld` patterns (AT Protocol handles).
19 * Does NOT match bare `@word` without a dot -- that avoids false positives.
20 */
21const MENTION_REGEX =
22 /@([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)/g
23
24// ---------------------------------------------------------------------------
25// Types
26// ---------------------------------------------------------------------------
27
28export type NotificationType =
29 | 'reply'
30 | 'reaction'
31 | 'mention'
32 | 'mod_action'
33 | 'cross_post_failed'
34 | 'cross_post_revoked'
35
36export interface CrossPostScopeRevokedNotificationParams {
37 /** DID of the user whose cross-post scopes were revoked (notification recipient). */
38 authorDid: string
39 /** Community DID. */
40 communityDid: string
41}
42
43export interface NotificationService {
44 notifyOnReply(params: ReplyNotificationParams): Promise<void>
45 notifyOnReaction(params: ReactionNotificationParams): Promise<void>
46 notifyOnModAction(params: ModActionNotificationParams): Promise<void>
47 notifyOnMentions(params: MentionNotificationParams): Promise<void>
48 notifyOnCrossPostFailure(params: CrossPostFailureNotificationParams): Promise<void>
49 notifyOnCrossPostScopeRevoked(params: CrossPostScopeRevokedNotificationParams): Promise<void>
50}
51
52export interface ReplyNotificationParams {
53 /** The reply URI (used as subjectUri in the notification). */
54 replyUri: string
55 /** DID of the user who created the reply. */
56 actorDid: string
57 /** URI of the root topic. */
58 topicUri: string
59 /** URI of the parent (topic URI if direct reply, reply URI if nested). */
60 parentUri: string
61 /** Community DID. */
62 communityDid: string
63}
64
65export interface ReactionNotificationParams {
66 /** The subject URI that was reacted to. */
67 subjectUri: string
68 /** DID of the user who reacted. */
69 actorDid: string
70 /** Community DID. */
71 communityDid: string
72}
73
74export interface ModActionNotificationParams {
75 /** URI of the content affected by the mod action. */
76 targetUri: string
77 /** DID of the moderator. */
78 moderatorDid: string
79 /** DID of the content author (the notification recipient). */
80 targetDid: string
81 /** Community DID. */
82 communityDid: string
83}
84
85export interface MentionNotificationParams {
86 /** The content containing @mentions. */
87 content: string
88 /** URI of the post/reply containing the mentions. */
89 subjectUri: string
90 /** DID of the user who wrote the content. */
91 actorDid: string
92 /** Community DID. */
93 communityDid: string
94}
95
96export interface CrossPostFailureNotificationParams {
97 /** URI of the topic that failed to cross-post. */
98 topicUri: string
99 /** DID of the topic author (notification recipient). */
100 authorDid: string
101 /** Which cross-post service failed ("bluesky" or "frontpage"). */
102 service: string
103 /** Community DID. */
104 communityDid: string
105}
106
107// ---------------------------------------------------------------------------
108// Helpers
109// ---------------------------------------------------------------------------
110
111/**
112 * Extract unique AT Protocol handles from content text.
113 * Returns at most MAX_MENTION_NOTIFICATIONS handles.
114 */
115export function extractMentions(content: string): string[] {
116 const matches = new Set<string>()
117 let match: RegExpExecArray | null
118
119 // Reset regex lastIndex for safety
120 MENTION_REGEX.lastIndex = 0
121
122 while ((match = MENTION_REGEX.exec(content)) !== null) {
123 const handle = match[1]
124 if (handle) {
125 matches.add(handle.toLowerCase())
126 }
127 if (matches.size >= MAX_MENTION_NOTIFICATIONS) {
128 break
129 }
130 }
131
132 return [...matches]
133}
134
135// ---------------------------------------------------------------------------
136// Factory
137// ---------------------------------------------------------------------------
138
139/**
140 * Create a notification service that generates notifications for forum events.
141 *
142 * Notifications are fire-and-forget: failures are logged but never block
143 * the calling flow. Self-notifications are suppressed (you don't get
144 * notified about your own actions).
145 */
146export function createNotificationService(db: Database, logger: Logger): NotificationService {
147 /**
148 * Insert a single notification row.
149 * Skips silently if recipientDid === actorDid (no self-notifications).
150 */
151 async function insertNotification(
152 recipientDid: string,
153 type: NotificationType,
154 subjectUri: string,
155 actorDid: string,
156 communityDid: string
157 ): Promise<void> {
158 if (recipientDid === actorDid) {
159 return
160 }
161
162 await db.insert(notifications).values({
163 recipientDid,
164 type,
165 subjectUri,
166 actorDid,
167 communityDid,
168 })
169 }
170
171 return {
172 async notifyOnReply(params: ReplyNotificationParams): Promise<void> {
173 try {
174 // Look up topic author from DB
175 const topicRows = await db
176 .select({ authorDid: topics.authorDid })
177 .from(topics)
178 .where(eq(topics.uri, params.topicUri))
179
180 const topicAuthor = topicRows[0]?.authorDid
181
182 if (topicAuthor) {
183 await insertNotification(
184 topicAuthor,
185 'reply',
186 params.replyUri,
187 params.actorDid,
188 params.communityDid
189 )
190 }
191
192 // If this is a nested reply (parentUri !== topicUri), also notify
193 // the parent reply author (if different from topic author)
194 if (params.parentUri !== params.topicUri) {
195 const parentReplyRows = await db
196 .select({ authorDid: replies.authorDid })
197 .from(replies)
198 .where(eq(replies.uri, params.parentUri))
199
200 const parentAuthor = parentReplyRows[0]?.authorDid
201 if (parentAuthor && parentAuthor !== topicAuthor) {
202 await insertNotification(
203 parentAuthor,
204 'reply',
205 params.replyUri,
206 params.actorDid,
207 params.communityDid
208 )
209 }
210 }
211 } catch (err: unknown) {
212 logger.error({ err, replyUri: params.replyUri }, 'Failed to generate reply notifications')
213 }
214 },
215
216 async notifyOnReaction(params: ReactionNotificationParams): Promise<void> {
217 try {
218 // Look up the content author from topics or replies
219 const topicRows = await db
220 .select({ authorDid: topics.authorDid })
221 .from(topics)
222 .where(eq(topics.uri, params.subjectUri))
223
224 let contentAuthor = topicRows[0]?.authorDid
225
226 if (!contentAuthor) {
227 const replyRows = await db
228 .select({ authorDid: replies.authorDid })
229 .from(replies)
230 .where(eq(replies.uri, params.subjectUri))
231
232 contentAuthor = replyRows[0]?.authorDid
233 }
234
235 if (contentAuthor) {
236 await insertNotification(
237 contentAuthor,
238 'reaction',
239 params.subjectUri,
240 params.actorDid,
241 params.communityDid
242 )
243 }
244 } catch (err: unknown) {
245 logger.error(
246 { err, subjectUri: params.subjectUri },
247 'Failed to generate reaction notification'
248 )
249 }
250 },
251
252 async notifyOnModAction(params: ModActionNotificationParams): Promise<void> {
253 try {
254 await insertNotification(
255 params.targetDid,
256 'mod_action',
257 params.targetUri,
258 params.moderatorDid,
259 params.communityDid
260 )
261 } catch (err: unknown) {
262 logger.error(
263 { err, targetUri: params.targetUri },
264 'Failed to generate mod action notification'
265 )
266 }
267 },
268
269 async notifyOnMentions(params: MentionNotificationParams): Promise<void> {
270 try {
271 const handles = extractMentions(params.content)
272 if (handles.length === 0) {
273 return
274 }
275
276 // Resolve handles to DIDs via the users table
277 const resolvedUsers = await db
278 .select({ did: users.did, handle: users.handle })
279 .from(users)
280 .where(inArray(users.handle, handles))
281
282 // Generate a notification for each resolved user
283 for (const resolved of resolvedUsers) {
284 await insertNotification(
285 resolved.did,
286 'mention',
287 params.subjectUri,
288 params.actorDid,
289 params.communityDid
290 )
291 }
292 } catch (err: unknown) {
293 logger.error(
294 { err, subjectUri: params.subjectUri },
295 'Failed to generate mention notifications'
296 )
297 }
298 },
299
300 async notifyOnCrossPostFailure(params: CrossPostFailureNotificationParams): Promise<void> {
301 try {
302 // Use communityDid as actorDid since this is a system-generated
303 // notification (avoids self-notification suppression)
304 await db.insert(notifications).values({
305 recipientDid: params.authorDid,
306 type: 'cross_post_failed',
307 subjectUri: params.topicUri,
308 actorDid: params.communityDid,
309 communityDid: params.communityDid,
310 })
311 } catch (err: unknown) {
312 logger.error(
313 { err, topicUri: params.topicUri, service: params.service },
314 'Failed to generate cross-post failure notification'
315 )
316 }
317 },
318
319 async notifyOnCrossPostScopeRevoked(
320 params: CrossPostScopeRevokedNotificationParams
321 ): Promise<void> {
322 try {
323 // Use communityDid as actorDid since this is a system-generated
324 // notification (avoids self-notification suppression)
325 await db.insert(notifications).values({
326 recipientDid: params.authorDid,
327 type: 'cross_post_revoked',
328 subjectUri: params.communityDid,
329 actorDid: params.communityDid,
330 communityDid: params.communityDid,
331 })
332 } catch (err: unknown) {
333 logger.error(
334 { err, authorDid: params.authorDid },
335 'Failed to generate cross-post scope revoked notification'
336 )
337 }
338 },
339 }
340}