Files
gerbeur/api/services/notification-service.ts

567 lines
17 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";
import { localHandle } from "../../shared/validation.ts";
import { isSelfHost } from "../lib/self-url.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 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(localHandle(username, isSelfHost)) 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 every user but its author of chat message `messageId`, which says
* `@everyone`. The WebSocket route calls it, after {@link notifyMentions} so
* that a user also named is told by name, when the message is sent flagged
* `mentionsEveryone`, or when an edit flags it. Each user is notified once per
* message, however often it is edited. `skipUserId` spares the users who have
* the chat open, as for a mention.
*/
export function notifyEveryone(
mentionerUserId: string,
messageId: 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 userRows = db.prepare(`SELECT id FROM users WHERE id != ?;`).all(
mentionerUserId,
) as { id: string }[];
// One transaction for the fan-out, as for a dump's followers.
transaction(() => {
for (const row of userRows) {
if (skipUserId?.(row.id)) continue;
createNotification(
row.id,
"user_mentioned",
{
mentionerId: mentionerUserId,
mentionerUsername: mentionerRow.username,
contextType: "chat",
contextId: messageId,
contextTitle: "",
everyone: true,
},
// The key of a mention by name, so a user named too gets one.
`mention:chat:${messageId}:${row.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,
);
}
});
}