476 lines
14 KiB
TypeScript
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}`,
|
|
);
|
|
}
|
|
});
|
|
}
|