Files
gerbeur/api/services/notification-service.ts
khannurien 4468438ba7 v4: every page and API URL follows one of two rules, written down in API.md — pages are a hierarchy where @name is a user and any other root word a category or an app page, tabs and views are plain segments and a default view is the bare path; API resources are /api/<plural>/<key>, a user's key being an id or @name, the caller's things under /api/me, media a sub-resource, lookups by another key a query filter. ~ is gone: the feed is /, /hot, /new, /journal, /follow and /music/hot, a profile /@bob with its overview and the tabs dumps, upvoted, playlists (her own only), follow (users, and /follow/playlists), invitees and manage (role and name, for user:manage), which replace the separate UserDumps, UserUpvoted and UserPlaylists pages; /settings holds the account (now with the username) and /manage the category manager, both linked from the user menu and one's profile. Dumps and playlists live under their owner, /@bob/dumps/<slug> and /@bob/playlists/<slug>, named by slug alone: migration 0021 re-slugs them to the bare slugify(title), unique per owner and kind (the oldest keeps it, clashes get -2), renames the categories whose slug v4 reserves (hot, follow, settings…), and rewrites this site's v3 links in dump notes, comments, chat, and playlist and profile descriptions. A slug changes only when edited, through the edit forms' new link field, which proposes slugify(title) as the title changes; a username changes from /settings or, for user:manage, from /@name/manage. Every slug or name left behind stays an alias of its item or user for good (dump_slug_aliases, playlist_slug_aliases, user_aliases), so old links redirect without chaining and nobody else can take one; slugs change 3 times a day per item, names once in 30 days, moderators exempt. An alias, or a name in another case, answers the item with its current path: the pages put it in their URL (useCanonicalPath, without fetching again), and the OG middleware answers a 301. shared/paths.ts builds and parses every page path, for the client's links, the emails' (digest-service lost its own builders and its id fallbacks, and links /follow and /settings) and the server's matchers (OG tags, self previews, backlinks, self-url); shared/slug.ts holds slugify and the format categories share. Dumps and playlists carry ownerUsername and a required slug, and notifications join their users' current names and their items' paths each time they are sent or read, so renames reach them and a deleted item's has no link. The API was renamed per the plan's table: POST /api/users and /api/sessions, /api/password-resets, /api/me (with settings, password, avatar, media-token, playlists?contains=, follow, feed?from=, notifications), /api/users/:key/{dumps,upvoted,playlists,follow/users,follow/playlists,invitees,avatar} and by path /api/users/:key/{dumps,playlists}/:slug, /api/dumps?url= and ?category=, /api/dumps/:id/{file,thumbnail,metadata}, /api/playlists/:id/cover, /api/remote/{preview,image,bandcamp,youtube} (one router), and /api/unsubscribe, the v3 POST /api/users/unsubscribe staying for the digests already sent. A stored default feed tab "followed" reads as "follow" once. dumps.slug and playlists.slug stay nullable, since SQLite can't add NOT NULL without rebuilding tables others reference; every writer sets them and unique (user_id, slug) indexes hold them. 254 backend tests (new: migration 0021 on a v3 fixture, slug aliases, swaps and rate limits, username renames, aliases and limits, reads by path and their privacy, the OG 301s, both unsubscribe paths, v4-shaped digest links with no ids, notification joins, shared paths and slugs) and 668 frontend tests (new: the profile's tabs and follow views, Settings, Manage, the link field, canonical URLs on the dump, playlist and profile pages, bare default feed paths) pass, and tsc, eslint, deno check, deno lint, deno fmt and doc:lint are clean; the migration was also run on a copy of the development database.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:08:59 +00:00

476 lines
14 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. 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<string, string | undefined>;
const out: Record<string, string | undefined> = { ...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<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(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.
*/
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}`,
);
}
});
}