Barazo AppView backend barazo.forum
at main 340 lines 11 kB view raw
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}