260 lines
8.2 KiB
TypeScript
260 lines
8.2 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) as Parameters<typeof isFollowRow>[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<typeof isFollowRow>[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<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;
|
|
|
|
const playlistFeedRows = rawRows as Parameters<typeof isDumpRow>[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<typeof isPlaylistRow>[0][];
|
|
|
|
if (!rawRows.every(isPlaylistRow)) {
|
|
throw new APIException(
|
|
APIErrorCode.SERVER_ERROR,
|
|
500,
|
|
"Malformed playlist data",
|
|
);
|
|
}
|
|
return { items: rawRows.map(playlistRowToApi), total: totalRow?.count ?? 0 };
|
|
}
|