Files
gerbeur/api/services/follow-service.ts
2026-03-24 18:47:05 +00:00

259 lines
8.0 KiB
TypeScript

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.slug, d.comment, d.user_id, d.created_at, d.updated_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);
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!),
};
}
// ── 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<typeof isDumpRow>[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;
if (!rawRows.every(isDumpRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed dump data",
);
}
return {
items: rawRows.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);
if (!rawRows.every(isPlaylistRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed playlist data",
);
}
return { items: rawRows.map(playlistRowToApi), total: totalRow?.count ?? 0 };
}