/** * 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. What a notification names by id is * joined each time it is sent ({@link withCurrentRefs}), so its names and * links follow renames. * * @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 { mentionedUsernames } from "../../shared/mention.ts"; /** Each id of a user a notification names, beside the field of their name. */ const USER_FIELDS = [ ["followerId", "followerUsername"], ["dumperId", "dumperUsername"], ["voterId", "voterUsername"], ["mentionerId", "mentionerUsername"], ["commenterId", "commenterUsername"], ["likerId", "likerUsername"], ] as const; /** * Returns `data` with what may have changed since it was stored joined from * the database: each user's current name, and the slug and owner of its dump * and playlist, which the client builds their paths from. A user since deleted * keeps their stored name; a deleted dump or playlist gets no ref, and so no * link. Every notification the server sends goes through it, and so do the * digests. */ export function withCurrentRefs(data: NotificationData): NotificationData { const fields = data as unknown as Record; const out: Record = { ...fields }; for (const [idField, nameField] of USER_FIELDS) { const id = fields[idField]; if (!id) continue; const row = db.prepare(`SELECT username FROM users WHERE id = ?;`).get( id, ) as { username: string } | undefined; if (row) out[nameField] = row.username; } const context = fields.contextType; const dumpId = fields.dumpId ?? (context === "dump" ? fields.contextId : undefined); const playlistId = fields.playlistId ?? (context === "playlist" ? fields.contextId : undefined); const ref = (table: string, id: string) => db.prepare( `SELECT t.slug, u.username FROM ${table} t JOIN users u ON u.id = t.user_id WHERE t.id = ?;`, ).get(id) as { slug: string; username: string } | undefined; if (dumpId) { const row = ref("dumps", dumpId); out.dumpSlug = row?.slug; out.dumpOwnerUsername = row?.username; } if (playlistId) { const row = ref("playlists", playlistId); out.playlistSlug = row?.slug; out.playlistOwnerUsername = row?.username; } return out as unknown as NotificationData; } /** * 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: withCurrentRefs(data), read: false, createdAt: now, }, }); } } /** * Returns a page of `userId`'s notifications, newest first, with their refs * joined, for `GET /api/me/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).map((n) => ({ ...n, data: withCurrentRefs(n.data), })), 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, ); } /** * 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 or goes public. * A follower is told of a dump once, however often it goes private and public * again. */ 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 sent = withCurrentRefs(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: sent, 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 = mentionedUsernames(body); 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}`, ); } }); } /** * Notifies the followers of each playlist of the dumps in it that became * visible to them: a playlist's followers are told of a dump once both are * public. The dump service calls it with `dumpId` when a dump goes public, for * each public playlist holding it; the playlist service with `playlistId` * when a playlist goes public, for each public dump in it, from the bottom of * the playlist up. A dump the followers were told of before either went * private isn't notified again. Adding a dump to a public playlist calls * {@link notifyPlaylistFollowersNewDump} directly. */ export function notifyPlaylistFollowersNowPublic( of: { dumpId: string } | { playlistId: string }, ): void { const [column, id] = "dumpId" in of ? ["pd.dump_id", of.dumpId] : ["pd.playlist_id", of.playlistId]; const rows = db.prepare( `SELECT p.id AS playlist_id, p.title AS playlist_title, d.id AS dump_id, d.title AS dump_title FROM playlist_dumps pd JOIN playlists p ON p.id = pd.playlist_id JOIN dumps d ON d.id = pd.dump_id WHERE ${column} = ? AND p.is_public = 1 AND d.is_private = 0 ORDER BY pd.position DESC;`, ).all(id) as { playlist_id: string; playlist_title: string; dump_id: string; dump_title: string; }[]; transaction(() => { for (const row of rows) { notifyPlaylistFollowersNewDump( row.playlist_id, row.playlist_title, row.dump_id, row.dump_title, ); } }); }