302 lines
9.7 KiB
TypeScript
302 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 { type Page, pageOf } from "../lib/pagination.ts";
|
|
import { attachCategoryIds } 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 = 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<typeof isDumpRow>[0][];
|
|
if (!userFeedRows.every(isDumpRow)) {
|
|
throw new APIException(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<Dump> {
|
|
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(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<Playlist> {
|
|
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(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<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);
|
|
}
|