257 lines
7.4 KiB
TypeScript
257 lines
7.4 KiB
TypeScript
import type {
|
|
Notification,
|
|
NotificationData,
|
|
NotificationType,
|
|
} from "../model/interfaces.ts";
|
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
|
import { db, isNotificationRow, notificationRowToApi } from "../model/db.ts";
|
|
import { sendToUser } from "./ws-service.ts";
|
|
|
|
// Regex: matches @username not already inside a markdown link ([...] or (...)
|
|
const MENTION_RE = /(?<![[(\\w])@([\w]+)/g;
|
|
|
|
// ── Core CRUD ─────────────────────────────────────────────────────────────────
|
|
|
|
// sourceKey: if set, INSERT OR IGNORE — same (user_id, source_key) pair is a no-op.
|
|
function createNotification(
|
|
userId: string,
|
|
type: NotificationType,
|
|
data: NotificationData,
|
|
sourceKey: string | null = null,
|
|
): void {
|
|
const id = crypto.randomUUID();
|
|
const createdAt = new Date().toISOString();
|
|
const dataJson = JSON.stringify(data);
|
|
|
|
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, ?, ?);`,
|
|
).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 },
|
|
});
|
|
}
|
|
}
|
|
|
|
export function getNotificationsForUser(
|
|
userId: string,
|
|
page: number,
|
|
limit: number,
|
|
): { items: Notification[]; total: number } {
|
|
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, offset) as Parameters<typeof isNotificationRow>[0][];
|
|
|
|
const totalRow = db.prepare(
|
|
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ?;`,
|
|
).get(userId) as { count: number } | undefined;
|
|
|
|
if (!rawRows.every(isNotificationRow)) {
|
|
throw new APIException(
|
|
APIErrorCode.SERVER_ERROR,
|
|
500,
|
|
"Malformed notification data",
|
|
);
|
|
}
|
|
return {
|
|
items: rawRows.map(notificationRowToApi),
|
|
total: totalRow?.count ?? 0,
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export function markAllRead(userId: string): void {
|
|
db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run(
|
|
userId,
|
|
);
|
|
}
|
|
|
|
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 ───────────────────────────────────────────────────────────
|
|
|
|
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}`,
|
|
);
|
|
}
|
|
|
|
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}`,
|
|
);
|
|
}
|
|
|
|
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 }[];
|
|
|
|
for (const row of followerRows) {
|
|
createNotification(
|
|
row.follower_id,
|
|
"user_dump_posted",
|
|
{ dumperId, dumperUsername: posterRow.username, dumpId, dumpTitle },
|
|
`dump:${dumpId}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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; // no self-notification
|
|
|
|
createNotification(
|
|
dumpRow.user_id,
|
|
"dump_upvoted",
|
|
{
|
|
voterId,
|
|
voterUsername: voterRow.username,
|
|
dumpId,
|
|
dumpTitle: dumpRow.title,
|
|
},
|
|
`upvote:${dumpId}:${voterId}`,
|
|
);
|
|
}
|
|
|
|
export function notifyMentions(
|
|
mentionerUserId: string,
|
|
body: string,
|
|
contextType: "comment" | "dump" | "playlist",
|
|
contextId: string,
|
|
contextTitle: string,
|
|
dumpId?: string,
|
|
): 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;
|
|
|
|
createNotification(
|
|
mentionedRow.id,
|
|
"user_mentioned",
|
|
{
|
|
mentionerId: mentionerUserId,
|
|
mentionerUsername: mentionerRow.username,
|
|
contextType,
|
|
contextId,
|
|
contextTitle,
|
|
dumpId,
|
|
},
|
|
`mention:${contextType}:${contextId}:${mentionedRow.id}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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 }[];
|
|
|
|
for (const row of followerRows) {
|
|
createNotification(
|
|
row.follower_id,
|
|
"playlist_dump_added",
|
|
{ dumpId, dumpTitle, playlistId, playlistTitle },
|
|
`pdump:${playlistId}:${dumpId}`,
|
|
);
|
|
}
|
|
}
|