import { APIErrorCode, APIException, type Dump, type FollowStatus, type Playlist, } from "../model/interfaces.ts"; import { notifyPlaylistOwnerNewFollower, notifyUserNewFollower, } from "./notification-service.ts"; import { db, dumpRowToApi, isDumpRow, isFollowRow, isPlaylistRow, playlistRowToApi, } from "../model/db.ts"; // Mirrors dump-service SELECT_COLS_ALIASED — kept local to avoid circular imports const SELECT_COLS_ALIASED = "d.id, d.kind, d.title, d.comment, d.user_id, d.created_at, d.url, d.rich_content, " + "d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," + " (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count"; // ── Follow / unfollow a user ────────────────────────────────────────────────── export function followUser(followerId: string, followedUserId: string): void { if (followerId === followedUserId) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "Cannot follow yourself", ); } let isNew = true; try { db.prepare( `INSERT INTO follows (id, follower_id, followed_user_id, created_at) VALUES (?, ?, ?, ?);`, ).run( crypto.randomUUID(), followerId, followedUserId, new Date().toISOString(), ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.toLowerCase().includes("unique")) { isNew = false; } else { throw err; } } if (isNew) notifyUserNewFollower(followerId, followedUserId); } export function unfollowUser(followerId: string, followedUserId: string): void { db.prepare( `DELETE FROM follows WHERE follower_id = ? AND followed_user_id = ?;`, ).run(followerId, followedUserId); } // ── Follow / unfollow a playlist ───────────────────────────────────────────── export function followPlaylist(followerId: string, playlistId: string): void { const row = db.prepare( `SELECT id, is_public FROM playlists WHERE id = ?;`, ).get(playlistId) as { id: string; is_public: number } | undefined; if (!row) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found"); } if (!row.is_public) { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, "Cannot follow a private playlist", ); } let isNew = true; try { db.prepare( `INSERT INTO follows (id, follower_id, followed_playlist_id, created_at) VALUES (?, ?, ?, ?);`, ).run( crypto.randomUUID(), followerId, playlistId, new Date().toISOString(), ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.toLowerCase().includes("unique")) { isNew = false; } else { throw err; } } if (isNew) notifyPlaylistOwnerNewFollower(followerId, playlistId); } export function unfollowPlaylist(followerId: string, playlistId: string): void { db.prepare( `DELETE FROM follows WHERE follower_id = ? AND followed_playlist_id = ?;`, ).run(followerId, playlistId); } // ── Follow status ───────────────────────────────────────────────────────────── export function getFollowStatus(followerId: string): FollowStatus { const rawUserRows = db.prepare( `SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at FROM follows WHERE follower_id = ? AND followed_user_id IS NOT NULL;`, ).all(followerId) as Parameters[0][]; const rawPlaylistRows = db.prepare( `SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at FROM follows WHERE follower_id = ? AND followed_playlist_id IS NOT NULL;`, ).all(followerId) as Parameters[0][]; if (!rawUserRows.every(isFollowRow) || !rawPlaylistRows.every(isFollowRow)) { throw new APIException( APIErrorCode.SERVER_ERROR, 500, "Malformed follow data", ); } return { followedUserIds: rawUserRows.map((r) => r.followed_user_id!), followedPlaylistIds: rawPlaylistRows.map((r) => r.followed_playlist_id!), }; } // ── Followed-users feed ─────────────────────────────────────────────────────── export function getFollowedUsersDumpFeed( followerId: string, page: number, limit: number, ): { items: Dump[]; total: number } { const offset = (page - 1) * limit; const rawRows = db.prepare( `SELECT ${SELECT_COLS_ALIASED} FROM dumps d INNER JOIN follows f ON f.followed_user_id = d.user_id WHERE f.follower_id = ? AND d.is_private = 0 ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`, ).all(followerId, limit, offset); const totalRow = db.prepare( `SELECT COUNT(*) as count FROM dumps d INNER JOIN follows f ON f.followed_user_id = d.user_id WHERE f.follower_id = ? AND d.is_private = 0;`, ).get(followerId) as { count: number } | undefined; const userFeedRows = rawRows as Parameters[0][]; if (!userFeedRows.every(isDumpRow)) { throw new APIException( APIErrorCode.SERVER_ERROR, 500, "Malformed dump data", ); } return { items: userFeedRows.map(dumpRowToApi), total: totalRow?.count ?? 0 }; } // ── Followed-playlists dump feed ────────────────────────────────────────────── export function getFollowedPlaylistsDumpFeed( followerId: string, page: number, limit: number, ): { items: Dump[]; total: number } { const offset = (page - 1) * limit; const rawRows = db.prepare( `SELECT ${SELECT_COLS_ALIASED} FROM dumps d INNER JOIN playlist_dumps pd ON pd.dump_id = d.id INNER JOIN playlists p ON p.id = pd.playlist_id INNER JOIN follows f ON f.followed_playlist_id = p.id WHERE f.follower_id = ? AND p.is_public = 1 AND d.is_private = 0 GROUP BY d.id ORDER BY MAX(pd.added_at) DESC LIMIT ? OFFSET ?;`, ).all(followerId, limit, offset); const totalRow = db.prepare( `SELECT COUNT(DISTINCT d.id) as count FROM dumps d INNER JOIN playlist_dumps pd ON pd.dump_id = d.id INNER JOIN playlists p ON p.id = pd.playlist_id INNER JOIN follows f ON f.followed_playlist_id = p.id WHERE f.follower_id = ? AND p.is_public = 1 AND d.is_private = 0;`, ).get(followerId) as { count: number } | undefined; const playlistFeedRows = rawRows as Parameters[0][]; if (!playlistFeedRows.every(isDumpRow)) { throw new APIException( APIErrorCode.SERVER_ERROR, 500, "Malformed dump data", ); } return { items: playlistFeedRows.map(dumpRowToApi), total: totalRow?.count ?? 0, }; } // ── Followed playlists (as playlist objects) ────────────────────────────────── export function getFollowedPlaylistsByUser( userId: string, page: number, limit: number, ): { items: Playlist[]; total: number } { const offset = (page - 1) * limit; const totalRow = db.prepare( `SELECT COUNT(*) as count FROM follows f WHERE f.follower_id = ? AND f.followed_playlist_id IS NOT NULL;`, ).get(userId) as { count: number } | undefined; const rawRows = db.prepare( `SELECT p.*, u.username as owner_username, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count FROM playlists p LEFT JOIN users u ON u.id = p.user_id INNER JOIN follows f ON f.followed_playlist_id = p.id WHERE f.follower_id = ? AND p.is_public = 1 ORDER BY f.created_at DESC LIMIT ? OFFSET ?;`, ).all(userId, limit, offset) as Parameters[0][]; if (!rawRows.every(isPlaylistRow)) { throw new APIException( APIErrorCode.SERVER_ERROR, 500, "Malformed playlist data", ); } return { items: rawRows.map(playlistRowToApi), total: totalRow?.count ?? 0 }; }