/** * Notifications: storing them, pushing each new one to its user's sockets, * and the triggers that create them. A trigger passes a source key, so the same * event never notifies a user twice. * * @module */ import type { Notification, NotificationData, NotificationType, UserDumpPostedData, } from "../../shared/notification.ts"; import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { db, transaction } from "../db/connection.ts"; import { isNotificationRow, notificationRowToApi, } from "../model/notification.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { sendToUser } from "./ws-service.ts"; import { MENTION_RE } from "../../shared/mention.ts"; // ── Core CRUD ───────────────────────────────────────────────────────────────── /** * Stores a notification for `userId` and pushes it live. With a `sourceKey`, * a second one for the same `(userId, sourceKey)` is a no-op. */ function createNotification( userId: string, type: NotificationType, data: NotificationData, sourceKey: string | null = null, ): void { const id = crypto.randomUUID(); const now = new Date(); const createdAt = now.toISOString(); const dataJson = JSON.stringify(data); let changes: number; if (sourceKey) { // INSERT OR IGNORE: idempotent — same (user_id, source_key) pair is a no-op const result = db.prepare( `INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key) VALUES (?, ?, ?, ?, 0, ?, ?);`, ).run(id, userId, type, dataJson, createdAt, sourceKey); changes = result.changes as number; } else { const result = db.prepare( `INSERT INTO notifications (id, user_id, type, data, read, created_at, source_key) VALUES (?, ?, ?, ?, 0, ?, NULL);`, ).run(id, userId, type, dataJson, createdAt); changes = result.changes as number; } if (changes > 0) { sendToUser(userId, { type: "notification_created", notification: { id, userId, type, data, read: false, createdAt: now, }, }); } } /** Returns a page of `userId`'s notifications, newest first. */ export function getNotificationsForUser( userId: string, page: number, limit: number, ): Page { const offset = (page - 1) * limit; const rawRows = db.prepare( `SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?;`, ).all(userId, limit + 1, offset); if (!rawRows.every(isNotificationRow)) { throw new APIException( APIErrorCode.SERVER_ERROR, 500, "Malformed notification data", ); } return pageOf(rawRows.map(notificationRowToApi), limit); } /** Returns how many of `userId`'s notifications are unread. */ export function getUnreadCount(userId: string): number { const row = db.prepare( `SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read = 0;`, ).get(userId) as { count: number } | undefined; return row?.count ?? 0; } /** Marks every notification of `userId` read. */ export function markAllRead(userId: string): void { db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run( userId, ); } /** Marks one of `userId`'s notifications read; someone else's is left alone. */ export function markOneRead(notificationId: string, userId: string): void { db.prepare( `UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?;`, ).run(notificationId, userId); } // ── Trigger helpers ─────────────────────────────────────────────────────────── /** Notifies `followedUserId` that `followerId` follows them. */ export function notifyUserNewFollower( followerId: string, followedUserId: string, ): void { const followerRow = db.prepare( `SELECT username FROM users WHERE id = ?;`, ).get(followerId) as { username: string } | undefined; if (!followerRow) return; createNotification( followedUserId, "user_followed", { followerId, followerUsername: followerRow.username }, `user-followed:${followedUserId}:${followerId}`, ); } /** * Notifies a playlist's owner that `followerId` follows it, unless they are * the owner. */ export function notifyPlaylistOwnerNewFollower( followerId: string, playlistId: string, ): void { const followerRow = db.prepare( `SELECT username FROM users WHERE id = ?;`, ).get(followerId) as { username: string } | undefined; const playlistRow = db.prepare( `SELECT title, user_id FROM playlists WHERE id = ?;`, ).get(playlistId) as { title: string; user_id: string } | undefined; if (!followerRow || !playlistRow) return; if (followerId === playlistRow.user_id) return; createNotification( playlistRow.user_id, "playlist_followed", { followerId, followerUsername: followerRow.username, playlistId, playlistTitle: playlistRow.title, }, `followed:${playlistId}:${followerId}`, ); } /** Notifies everyone following `dumperId` that they posted a dump. */ export function notifyUserFollowersNewDump( dumperId: string, dumpId: string, dumpTitle: string, ): void { const posterRow = db.prepare( `SELECT username FROM users WHERE id = ?;`, ).get(dumperId) as { username: string } | undefined; if (!posterRow) return; const followerRows = db.prepare( `SELECT follower_id FROM follows WHERE followed_user_id = ?;`, ).all(dumperId) as { follower_id: string }[]; if (followerRows.length === 0) return; const data: UserDumpPostedData = { dumperId, dumperUsername: posterRow.username, dumpId, dumpTitle, }; const dataJson = JSON.stringify(data); const now = new Date(); const createdAt = now.toISOString(); const sourceKey = `dump:${dumpId}`; // Insert per follower so we can broadcast only the rows that were actually // created (INSERT OR IGNORE skips followers who already have this dump's // notification) and reuse each persisted id in the live event — otherwise a // later refetch would dedupe against a mismatched id and show a duplicate. const insert = db.prepare( `INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key) VALUES (?, ?, ?, ?, 0, ?, ?);`, ); // One transaction for the whole fan-out: a commit per follower is what made // a popular user's dump slow to post. The live events go out inside it, but // the commit follows in the same tick, before any client can ask back. transaction(() => { for (const row of followerRows) { const id = crypto.randomUUID(); const result = insert.run( id, row.follower_id, "user_dump_posted", dataJson, createdAt, sourceKey, ); if ((result.changes as number) > 0) { sendToUser(row.follower_id, { type: "notification_created", notification: { id, userId: row.follower_id, type: "user_dump_posted", data, read: false, createdAt: now, }, }); } } }); } /** Notifies a dump's poster that `voterId` upvoted it, unless they are the poster. */ export function notifyDumpOwnerUpvote( voterId: string, dumpId: string, ): void { const voterRow = db.prepare( `SELECT username FROM users WHERE id = ?;`, ).get(voterId) as { username: string } | undefined; const dumpRow = db.prepare( `SELECT title, user_id FROM dumps WHERE id = ?;`, ).get(dumpId) as { title: string; user_id: string } | undefined; if (!voterRow || !dumpRow) return; if (voterId === dumpRow.user_id) return; // no self-notification createNotification( dumpRow.user_id, "dump_upvoted", { voterId, voterUsername: voterRow.username, dumpId, dumpTitle: dumpRow.title, }, `upvote:${dumpId}:${voterId}`, ); } /** * Notifies every user @mentioned in `body`, except its author, that they were * mentioned in the given comment, dump, playlist or chat message. Each is * notified once per mention context, however often the body is edited. * `skipUserId` spares a mentioned user (e.g. one already reading the chat * live, for whom the persistent notification would be redundant). */ export function notifyMentions( mentionerUserId: string, body: string, contextType: "comment" | "dump" | "playlist" | "chat", contextId: string, contextTitle: string, dumpId?: string, skipUserId?: (userId: string) => boolean, ): void { const mentionerRow = db.prepare( `SELECT username FROM users WHERE id = ?;`, ).get(mentionerUserId) as { username: string } | undefined; if (!mentionerRow) return; const usernames = [ ...new Set( [...body.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase()), ), ]; for (const username of usernames) { const mentionedRow = db.prepare( `SELECT id FROM users WHERE lower(username) = ?;`, ).get(username) as { id: string } | undefined; if (!mentionedRow || mentionedRow.id === mentionerUserId) continue; if (skipUserId?.(mentionedRow.id)) continue; createNotification( mentionedRow.id, "user_mentioned", { mentionerId: mentionerUserId, mentionerUsername: mentionerRow.username, contextType, contextId, contextTitle, dumpId, }, `mention:${contextType}:${contextId}:${mentionedRow.id}`, ); } } /** * Notifies a dump's poster that `commenterId` commented on it, unless they are * the poster. */ export function notifyDumpOwnerNewComment( commenterId: string, commentId: string, dumpId: string, ): void { const commenterRow = db.prepare( `SELECT username FROM users WHERE id = ?;`, ).get(commenterId) as { username: string } | undefined; const dumpRow = db.prepare( `SELECT title, user_id FROM dumps WHERE id = ?;`, ).get(dumpId) as { title: string; user_id: string } | undefined; if (!commenterRow || !dumpRow) return; if (commenterId === dumpRow.user_id) return; // no self-notification createNotification( dumpRow.user_id, "dump_commented", { commenterId, commenterUsername: commenterRow.username, commentId, dumpId, dumpTitle: dumpRow.title, }, `comment:${commentId}`, ); } /** * Notifies a comment's author that `likerId` likes it, unless they are the * author. */ export function notifyCommentOwnerLike( likerId: string, commentId: string, ): void { const likerRow = db.prepare( `SELECT username FROM users WHERE id = ?;`, ).get(likerId) as { username: string } | undefined; const commentRow = db.prepare( `SELECT c.user_id, c.dump_id, d.title as dump_title FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`, ).get(commentId) as | { user_id: string; dump_id: string; dump_title: string } | undefined; if (!likerRow || !commentRow) return; if (likerId === commentRow.user_id) return; // no self-notification createNotification( commentRow.user_id, "comment_liked", { likerId, likerUsername: likerRow.username, commentId, dumpId: commentRow.dump_id, dumpTitle: commentRow.dump_title, }, `like:${commentId}:${likerId}`, ); } /** Notifies everyone following a playlist that a dump was added to it. */ export function notifyPlaylistFollowersNewDump( playlistId: string, playlistTitle: string, dumpId: string, dumpTitle: string, ): void { const followerRows = db.prepare( `SELECT follower_id FROM follows WHERE followed_playlist_id = ?;`, ).all(playlistId) as { follower_id: string }[]; transaction(() => { for (const row of followerRows) { createNotification( row.follower_id, "playlist_dump_added", { dumpId, dumpTitle, playlistId, playlistTitle }, `pdump:${playlistId}:${dumpId}`, ); } }); }