Files
gerbeur/api/services/notification-service.ts
khannurien 0d4e344d77 v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:31:14 +00:00

434 lines
12 KiB
TypeScript

/**
* 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 { 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";
/**
* 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<Notification> {
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. 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}`,
);
}
});
}