/** * Stores notifications, pushes each new one to its user's sockets as * `notification_created`, and reads them back for `routes/notifications.ts`. * The services of follows, votes, likes, comments, dumps, playlists and the * chat call its `notify…` triggers. Every 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 { 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"; /** * Stores a notification for `userId` and pushes it live. With a `sourceKey`, * a second one for the same `(userId, sourceKey)` is neither stored nor sent. */ 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) { 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, for * `GET /api/notifications`. * * @throws {APIException} 500 when a row doesn't parse. */ 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(500, "Malformed notification data"); } return pageOf(rawRows.map(notificationRowToApi), limit); } /** * Returns how many of `userId`'s notifications are unread. The WebSocket route * sends it in its `welcome` message, for the bell's count. */ 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, when they open the * `/notifications` page. */ 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. An id that isn't theirs changes * nothing, and says nothing. */ export function markOneRead(notificationId: string, userId: string): void { db.prepare( `UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?;`, ).run(notificationId, userId); } /** * Notifies `followedUserId` that `followerId` follows them, once however often * they unfollow and follow again. `followUser` calls it. */ 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, and once however often they unfollow and follow again. * `followPlaylist` calls it. */ 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. The dump * service calls it for a public dump only, when it is created. */ 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}`; // One insert per follower, so that only the rows actually stored are sent, // each with its stored id: the client dedupes a refetch against it. 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, and once however often the vote is withdrawn and cast again. * `castVote` calls it. */ 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; 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 comment, dump, playlist or chat message `contextId`. Call * it again after each edit: a user already notified for this context isn't * notified twice. `skipUserId` spares a mentioned user; the chat uses it for * those who have it open. */ 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. `createComment` calls it for replies too, but not on an edit. */ 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; 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, and once however often the like is withdrawn and given again. */ 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; 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. The * playlist service calls it only when both are public, since a follower can't * open a private one. */ 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}`, ); } }); }