Files
gerbeur/api/services/follow-service.ts

301 lines
9.7 KiB
TypeScript

/**
* 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 { 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 { VISIBLE_DUMP_COUNT } from "../lib/playlist-members.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { attachCategoryIds, categoryJoin } from "./category-service.ts";
import { sendToUser } from "./ws-service.ts";
/**
* Makes `followerId` follow `followedUserId`, and notifies the followed user.
* Following again changes nothing. The follower's other tabs are told.
*
* @throws {APIException} 400 when a user tries to follow themselves, 404 when
* there is no such user.
*/
export function followUser(followerId: string, followedUserId: string): void {
if (followerId === followedUserId) {
throw new APIException(400, "Cannot follow yourself");
}
if (!db.prepare(`SELECT 1 FROM users WHERE id = ?;`).get(followedUserId)) {
throw new APIException(404, "User not found");
}
// 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);
sendToUser(followerId, { type: "follows_changed" });
}
/** 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);
sendToUser(followerId, { type: "follows_changed" });
}
/**
* 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, or 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(404, "Playlist not found");
}
// Private is answered as missing, so as not to confirm that it exists.
if (!row.is_public) {
throw new APIException(404, "Playlist not found");
}
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);
sendToUser(followerId, { type: "follows_changed" });
}
/** 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);
sendToUser(followerId, { type: "follows_changed" });
}
/**
* Returns the ids of every user and playlist `followerId` follows, private
* playlists included, for `GET /api/me/follow`.
*
* @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(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<Dump> {
const offset = (page - 1) * limit;
const [catJoin, catParams] = categoryJoin("dump", "d", 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<typeof isDumpRow>[0][];
if (!userFeedRows.every(isDumpRow)) {
throw new APIException(500, "Malformed dump data");
}
const items = userFeedRows.map(dumpRowToApi);
attachCategoryIds("dump", 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<Dump> {
const offset = (page - 1) * limit;
const [catJoin, catParams] = categoryJoin("dump", "d", 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(500, "Malformed dump data");
}
const items = rawRows.map(dumpRowToApi);
attachCategoryIds("dump", items);
return pageOf(items, limit);
}
/**
* Returns a page of the public playlists `userId` follows, most recently
* followed first, for their profile and playlists pages. Their dumps are
* counted for `requestingUserId`, null being a signed-out viewer.
*
* @throws {APIException} 500 when a row doesn't parse as a playlist.
*/
export function getFollowedPlaylistsByUser(
userId: string,
requestingUserId: string | null,
page: number,
limit: number,
): Page<Playlist> {
const offset = (page - 1) * limit;
const rawRows = db.prepare(
`SELECT p.*, u.username as owner_username,
${VISIBLE_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(requestingUserId ?? "", userId, limit + 1, offset);
if (!rawRows.every(isPlaylistRow)) {
throw new APIException(500, "Malformed playlist data");
}
const items = rawRows.map(playlistRowToApi);
attachCategoryIds("playlist", items);
return pageOf(items, 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<User> {
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(500, "Malformed user data");
}
return pageOf(rawRows.map(userRowToApi), limit);
}