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"; // ── 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[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 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}`, ); } }