/** * Stores who follows which users and playlists, and builds the feeds and * lists made from it. `routes/follows.ts` serves the caller's own, and * `routes/users.ts` what any user follows. * * @module */ import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { Dump } from "../../shared/dump.ts"; import type { FollowStatus } from "../../shared/follow.ts"; import type { Playlist } from "../../shared/playlist.ts"; import { isUserRow, type User, userRowToApi } from "../model/user.ts"; import { notifyPlaylistOwnerNewFollower, notifyUserNewFollower, } from "./notification-service.ts"; import { db } from "../db/connection.ts"; import { DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED, dumpRowToApi, isDumpRow, } from "../model/dump.ts"; import { isFollowRow } from "../model/follow.ts"; import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { attachCategoryIds } from "./category-service.ts"; /** * Makes `followerId` follow `followedUserId`, and notifies the followed user. * Following again changes nothing. * * @throws {APIException} 400 when a user tries to follow themselves. */ export function followUser(followerId: string, followedUserId: string): void { if (followerId === followedUserId) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "Cannot follow yourself", ); } // The unique index answers "already following" without a race. 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); } /** Makes `followerId` stop following `followedUserId`, if they did. */ export function unfollowUser(followerId: string, followedUserId: string): void { db.prepare( `DELETE FROM follows WHERE follower_id = ? AND followed_user_id = ?;`, ).run(followerId, followedUserId); } /** * Makes `followerId` follow a public playlist, and notifies its owner. * Following again changes nothing. A follow outlives the playlist going * private, but the feeds and lists skip it while it is. * * @throws {APIException} 404 when there is no such playlist, 403 when it is * private. */ 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); } /** Makes `followerId` stop following a playlist, if they did. */ export function unfollowPlaylist(followerId: string, playlistId: string): void { db.prepare( `DELETE FROM follows WHERE follower_id = ? AND followed_playlist_id = ?;`, ).run(followerId, playlistId); } /** * Returns the ids of every user and playlist `followerId` follows, private * playlists included, for `GET /api/follows/status`. * * @throws {APIException} 500 when a row doesn't parse. */ 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); 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); 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!), }; } /** * Returns a page of the public dumps posted by the users `followerId` follows, * newest first, optionally only those in `categoryId`. It feeds the "users" * half of the index's "followed" tab. * * @throws {APIException} 500 when a row doesn't parse as a dump. */ export function getFollowedUsersDumpFeed( followerId: string, page: number, limit: number, categoryId?: string, ): Page { const offset = (page - 1) * limit; const catJoin = categoryId ? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?" : ""; const catParams = categoryId ? [categoryId] : []; const rawRows = db.prepare( `SELECT ${SELECT_COLS_ALIASED} FROM dumps d INNER JOIN follows f ON f.followed_user_id = d.user_id ${catJoin} WHERE f.follower_id = ? AND d.is_private = 0 ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`, ).all(...catParams, followerId, limit + 1, offset); const userFeedRows = rawRows as Parameters[0][]; if (!userFeedRows.every(isDumpRow)) { throw new APIException( APIErrorCode.SERVER_ERROR, 500, "Malformed dump data", ); } const items = userFeedRows.map(dumpRowToApi); attachCategoryIds(items); return pageOf(items, limit); } /** * Returns a page of the public dumps in the public playlists `followerId` * follows, optionally only those in `categoryId`. It feeds the "playlists" * half of the index's "followed" tab. A dump in several of them shows once, * dated by its latest addition. * * @throws {APIException} 500 when a row doesn't parse as a dump. */ export function getFollowedPlaylistsDumpFeed( followerId: string, page: number, limit: number, categoryId?: string, ): Page { const offset = (page - 1) * limit; const catJoin = categoryId ? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?" : ""; const catParams = categoryId ? [categoryId] : []; 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 ${catJoin} 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(...catParams, followerId, limit + 1, offset); if (!rawRows.every(isDumpRow)) { throw new APIException( APIErrorCode.SERVER_ERROR, 500, "Malformed dump data", ); } const items = rawRows.map(dumpRowToApi); attachCategoryIds(items); return pageOf(items, limit); } /** * Returns a page of the public playlists `userId` follows, most recently * followed first, for their profile and playlists pages. * * @throws {APIException} 500 when a row doesn't parse as a playlist. */ export function getFollowedPlaylistsByUser( userId: string, page: number, limit: number, ): Page { const offset = (page - 1) * limit; 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 + 1, offset); if (!rawRows.every(isPlaylistRow)) { throw new APIException( APIErrorCode.SERVER_ERROR, 500, "Malformed playlist data", ); } return pageOf(rawRows.map(playlistRowToApi), limit); } /** * Returns a page of the users `userId` follows, most recently followed first, * for their profile page. The users carry their password hash and email: pass * them through `toPublicUser` before sending them. * * @throws {APIException} 500 when a row doesn't parse as a user. */ export function getFollowedUsersByUser( userId: string, page: number, limit: number, ): Page { const offset = (page - 1) * limit; const rawRows = db.prepare( `SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email, i.username as invited_by_username FROM users u LEFT JOIN users i ON i.id = u.invited_by INNER JOIN follows f ON f.followed_user_id = u.id WHERE f.follower_id = ? ORDER BY f.created_at DESC LIMIT ? OFFSET ?;`, ).all(userId, limit + 1, offset); if (!rawRows.every(isUserRow)) { throw new APIException( APIErrorCode.SERVER_ERROR, 500, "Malformed user data", ); } return pageOf(rawRows.map(userRowToApi), limit); }