v3: added comment likes, added votes/likes list view, added db migrations mechanism, updated project dependencies
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
This commit is contained in:
107
api/services/comment-like-service.ts
Normal file
107
api/services/comment-like-service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type User,
|
||||
} from "../model/interfaces.ts";
|
||||
import { db, isUserRow, userRowToApi } from "../model/db.ts";
|
||||
import { notifyCommentOwnerLike } from "./notification-service.ts";
|
||||
|
||||
export function castCommentLike(commentId: string, userId: string): number {
|
||||
let likeCount: number;
|
||||
try {
|
||||
db.exec("BEGIN;");
|
||||
db.prepare(
|
||||
`INSERT INTO comment_likes (comment_id, user_id, created_at) VALUES (?, ?, ?);`,
|
||||
).run(commentId, userId, new Date().toISOString());
|
||||
db.prepare(
|
||||
`UPDATE comments SET like_count = like_count + 1 WHERE id = ?;`,
|
||||
).run(commentId);
|
||||
const row = db.prepare(
|
||||
`SELECT like_count FROM comments WHERE id = ?;`,
|
||||
).get(commentId) as { like_count: number } | undefined;
|
||||
db.exec("COMMIT;");
|
||||
likeCount = row?.like_count ?? 0;
|
||||
} catch (err) {
|
||||
try {
|
||||
db.exec("ROLLBACK;");
|
||||
} catch { /* ignore if no active transaction */ }
|
||||
if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
409,
|
||||
"Already liked",
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Notification is best-effort — must not prevent like_ack from being sent
|
||||
try {
|
||||
notifyCommentOwnerLike(userId, commentId);
|
||||
} catch { /* ignore */ }
|
||||
return likeCount;
|
||||
}
|
||||
|
||||
export function removeCommentLike(commentId: string, userId: string): number {
|
||||
try {
|
||||
db.exec("BEGIN;");
|
||||
const result = db.prepare(
|
||||
`DELETE FROM comment_likes WHERE comment_id = ? AND user_id = ?;`,
|
||||
).run(commentId, userId);
|
||||
if (result.changes === 0) {
|
||||
db.exec("ROLLBACK;");
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Like not found");
|
||||
}
|
||||
db.prepare(
|
||||
`UPDATE comments SET like_count = like_count - 1 WHERE id = ?;`,
|
||||
).run(commentId);
|
||||
const row = db.prepare(
|
||||
`SELECT like_count FROM comments WHERE id = ?;`,
|
||||
).get(commentId) as { like_count: number } | undefined;
|
||||
db.exec("COMMIT;");
|
||||
return row?.like_count ?? 0;
|
||||
} catch (err) {
|
||||
if (err instanceof APIException) throw err;
|
||||
db.exec("ROLLBACK;");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserCommentLikes(userId: string): string[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT comment_id FROM comment_likes WHERE user_id = ?;`,
|
||||
).all(userId) as { comment_id: string }[];
|
||||
return rows.map((r) => r.comment_id);
|
||||
}
|
||||
|
||||
export function getCommentLikers(
|
||||
commentId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
): { items: User[]; total: number } {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM comment_likes WHERE comment_id = ?;`,
|
||||
).get(commentId) as { count: number } | undefined;
|
||||
|
||||
const rawRows = db.prepare(
|
||||
`SELECT u.id, u.username, u.password_hash, u.is_admin, 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 comment_likes cl ON cl.comment_id = ? AND cl.user_id = u.id
|
||||
ORDER BY cl.created_at DESC
|
||||
LIMIT ? OFFSET ?;`,
|
||||
).all(commentId, limit, offset);
|
||||
|
||||
if (!rawRows.every(isUserRow)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed user data",
|
||||
);
|
||||
}
|
||||
|
||||
return { items: rawRows.map(userRowToApi), total: totalRow?.count ?? 0 };
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { linkAttachments } from "./attachment-service.ts";
|
||||
|
||||
const SELECT_COLS =
|
||||
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted,
|
||||
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted, c.like_count,
|
||||
u.username as author_username, u.avatar_mime as author_avatar_mime`;
|
||||
|
||||
function fetchComment(commentId: string): Comment {
|
||||
@@ -32,6 +32,16 @@ function fetchComment(commentId: string): Comment {
|
||||
return commentRowToApi(row);
|
||||
}
|
||||
|
||||
export function getCommentDumpId(commentId: string): string {
|
||||
const row = db.prepare(`SELECT dump_id FROM comments WHERE id = ?;`).get(
|
||||
commentId,
|
||||
) as { dump_id: string } | undefined;
|
||||
if (!row) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
||||
}
|
||||
return row.dump_id;
|
||||
}
|
||||
|
||||
export function getComments(dumpId: string): Comment[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id
|
||||
|
||||
@@ -306,6 +306,38 @@ export function notifyDumpOwnerNewComment(
|
||||
);
|
||||
}
|
||||
|
||||
export function notifyCommentOwnerLike(
|
||||
likerId: string,
|
||||
commentId: string,
|
||||
): void {
|
||||
const likerRow = db.prepare(
|
||||
`SELECT username FROM users WHERE id = ?;`,
|
||||
).get(likerId) as { username: string } | undefined;
|
||||
|
||||
const commentRow = db.prepare(
|
||||
`SELECT c.user_id, c.dump_id, d.title as dump_title
|
||||
FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`,
|
||||
).get(commentId) as
|
||||
| { user_id: string; dump_id: string; dump_title: string }
|
||||
| undefined;
|
||||
|
||||
if (!likerRow || !commentRow) return;
|
||||
if (likerId === commentRow.user_id) return; // no self-notification
|
||||
|
||||
createNotification(
|
||||
commentRow.user_id,
|
||||
"comment_liked",
|
||||
{
|
||||
likerId,
|
||||
likerUsername: likerRow.username,
|
||||
commentId,
|
||||
dumpId: commentRow.dump_id,
|
||||
dumpTitle: commentRow.dump_title,
|
||||
},
|
||||
`like:${commentId}:${likerId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function notifyPlaylistFollowersNewDump(
|
||||
playlistId: string,
|
||||
playlistTitle: string,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import { db } from "../model/db.ts";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type User,
|
||||
} from "../model/interfaces.ts";
|
||||
import { db, isUserRow, userRowToApi } from "../model/db.ts";
|
||||
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
|
||||
|
||||
export function castVote(dumpId: string, userId: string): number {
|
||||
@@ -68,3 +72,36 @@ export function getUserVotes(userId: string): string[] {
|
||||
).all(userId) as { dump_id: string }[];
|
||||
return rows.map((r) => r.dump_id);
|
||||
}
|
||||
|
||||
export function getDumpVoters(
|
||||
dumpId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
): { items: User[]; total: number } {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM votes WHERE dump_id = ?;`,
|
||||
).get(dumpId) as { count: number } | undefined;
|
||||
|
||||
const rawRows = db.prepare(
|
||||
`SELECT u.id, u.username, u.password_hash, u.is_admin, 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 votes v ON v.dump_id = ? AND v.user_id = u.id
|
||||
ORDER BY v.created_at DESC
|
||||
LIMIT ? OFFSET ?;`,
|
||||
).all(dumpId, limit, offset);
|
||||
|
||||
if (!rawRows.every(isUserRow)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed user data",
|
||||
);
|
||||
}
|
||||
|
||||
return { items: rawRows.map(userRowToApi), total: totalRow?.count ?? 0 };
|
||||
}
|
||||
|
||||
@@ -117,6 +117,23 @@ export function broadcastVoteUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastCommentLikeUpdate(
|
||||
commentId: string,
|
||||
likeCount: number,
|
||||
likerId: string,
|
||||
action: "cast" | "remove",
|
||||
): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, {
|
||||
type: "comment_likes_update",
|
||||
commentId,
|
||||
likeCount,
|
||||
likerId,
|
||||
action,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sendToPlaylistAudience(
|
||||
playlist: Pick<Playlist, "isPublic" | "userId">,
|
||||
data: ServerToClientMessage,
|
||||
|
||||
Reference in New Issue
Block a user