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>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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
|
||||
*/
|
||||
@@ -23,11 +25,9 @@ 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.
|
||||
* a second one for the same `(userId, sourceKey)` is neither stored nor sent.
|
||||
*/
|
||||
function createNotification(
|
||||
userId: string,
|
||||
@@ -43,7 +43,6 @@ function createNotification(
|
||||
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, ?, ?);`,
|
||||
@@ -72,7 +71,12 @@ function createNotification(
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a page of `userId`'s notifications, newest first. */
|
||||
/**
|
||||
* 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,
|
||||
@@ -93,7 +97,10 @@ export function getNotificationsForUser(
|
||||
return pageOf(rawRows.map(notificationRowToApi), limit);
|
||||
}
|
||||
|
||||
/** Returns how many of `userId`'s notifications are unread. */
|
||||
/**
|
||||
* 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;`,
|
||||
@@ -101,23 +108,30 @@ export function getUnreadCount(userId: string): number {
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
/** Marks every notification of `userId` read. */
|
||||
/**
|
||||
* 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; someone else's is left alone. */
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// ── Trigger helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Notifies `followedUserId` that `followerId` follows them. */
|
||||
/**
|
||||
* Notifies `followedUserId` that `followerId` follows them, once however often
|
||||
* they unfollow and follow again. `followUser` calls it.
|
||||
*/
|
||||
export function notifyUserNewFollower(
|
||||
followerId: string,
|
||||
followedUserId: string,
|
||||
@@ -138,7 +152,8 @@ export function notifyUserNewFollower(
|
||||
|
||||
/**
|
||||
* Notifies a playlist's owner that `followerId` follows it, unless they are
|
||||
* the owner.
|
||||
* the owner, and once however often they unfollow and follow again.
|
||||
* `followPlaylist` calls it.
|
||||
*/
|
||||
export function notifyPlaylistOwnerNewFollower(
|
||||
followerId: string,
|
||||
@@ -168,7 +183,10 @@ export function notifyPlaylistOwnerNewFollower(
|
||||
);
|
||||
}
|
||||
|
||||
/** Notifies everyone following `dumperId` that they posted a dump. */
|
||||
/**
|
||||
* 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,
|
||||
@@ -196,10 +214,8 @@ export function notifyUserFollowersNewDump(
|
||||
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.
|
||||
// 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, ?, ?);`,
|
||||
@@ -236,7 +252,11 @@ export function notifyUserFollowersNewDump(
|
||||
});
|
||||
}
|
||||
|
||||
/** Notifies a dump's poster that `voterId` upvoted it, unless they are the poster. */
|
||||
/**
|
||||
* 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,
|
||||
@@ -250,7 +270,7 @@ export function notifyDumpOwnerUpvote(
|
||||
).get(dumpId) as { title: string; user_id: string } | undefined;
|
||||
|
||||
if (!voterRow || !dumpRow) return;
|
||||
if (voterId === dumpRow.user_id) return; // no self-notification
|
||||
if (voterId === dumpRow.user_id) return;
|
||||
|
||||
createNotification(
|
||||
dumpRow.user_id,
|
||||
@@ -267,10 +287,10 @@ export function notifyDumpOwnerUpvote(
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* 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,
|
||||
@@ -317,7 +337,7 @@ export function notifyMentions(
|
||||
|
||||
/**
|
||||
* Notifies a dump's poster that `commenterId` commented on it, unless they are
|
||||
* the poster.
|
||||
* the poster. `createComment` calls it for replies too, but not on an edit.
|
||||
*/
|
||||
export function notifyDumpOwnerNewComment(
|
||||
commenterId: string,
|
||||
@@ -333,7 +353,7 @@ export function notifyDumpOwnerNewComment(
|
||||
).get(dumpId) as { title: string; user_id: string } | undefined;
|
||||
|
||||
if (!commenterRow || !dumpRow) return;
|
||||
if (commenterId === dumpRow.user_id) return; // no self-notification
|
||||
if (commenterId === dumpRow.user_id) return;
|
||||
|
||||
createNotification(
|
||||
dumpRow.user_id,
|
||||
@@ -351,7 +371,7 @@ export function notifyDumpOwnerNewComment(
|
||||
|
||||
/**
|
||||
* Notifies a comment's author that `likerId` likes it, unless they are the
|
||||
* author.
|
||||
* author, and once however often the like is withdrawn and given again.
|
||||
*/
|
||||
export function notifyCommentOwnerLike(
|
||||
likerId: string,
|
||||
@@ -369,7 +389,7 @@ export function notifyCommentOwnerLike(
|
||||
| undefined;
|
||||
|
||||
if (!likerRow || !commentRow) return;
|
||||
if (likerId === commentRow.user_id) return; // no self-notification
|
||||
if (likerId === commentRow.user_id) return;
|
||||
|
||||
createNotification(
|
||||
commentRow.user_id,
|
||||
@@ -385,7 +405,11 @@ export function notifyCommentOwnerLike(
|
||||
);
|
||||
}
|
||||
|
||||
/** Notifies everyone following a playlist that a dump was added to it. */
|
||||
/**
|
||||
* 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,
|
||||
|
||||
Reference in New Issue
Block a user