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

This commit is contained in:
khannurien
2026-06-21 14:17:56 +00:00
parent 888aae45cf
commit 7afb5d3f07
25 changed files with 1475 additions and 840 deletions

View File

@@ -15,9 +15,11 @@ import {
ORPHANED_ATTACHMENTS_RETENTION_HOURS,
UNUSED_INVITES_RETENTION_DAYS,
} from "../config.ts";
import { runMigrations } from "../sql/migrate.ts";
export const db = new DatabaseSync(DB_PATH);
db.exec("PRAGMA foreign_keys = ON;");
runMigrations(db);
// Purge expired/used password reset tokens on startup
db.prepare(
@@ -236,6 +238,7 @@ export interface CommentRow {
created_at: string;
updated_at: string | null;
deleted: number;
like_count: number;
author_username: string;
author_avatar_mime: string | null;
[key: string]: SQLOutputValue;
@@ -255,6 +258,7 @@ export function isCommentRow(
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"deleted" in obj && typeof obj.deleted === "number" &&
"like_count" in obj && typeof obj.like_count === "number" &&
"author_username" in obj && typeof obj.author_username === "string" &&
"author_avatar_mime" in obj &&
(typeof obj.author_avatar_mime === "string" ||
@@ -271,6 +275,7 @@ export function commentRowToApi(row: CommentRow): Comment {
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
deleted: Boolean(row.deleted),
likeCount: row.like_count,
authorUsername: row.author_username,
authorAvatarMime: row.author_avatar_mime ?? undefined,
};

View File

@@ -255,6 +255,7 @@ export interface Comment {
createdAt: Date;
updatedAt?: Date;
deleted: boolean;
likeCount: number;
authorUsername: string;
authorAvatarMime?: string;
}
@@ -469,11 +470,23 @@ export interface VoteRemoveMessage {
dumpId: string;
}
export interface CommentLikeCastMessage {
type: "comment_like_cast";
commentId: string;
}
export interface CommentLikeRemoveMessage {
type: "comment_like_remove";
commentId: string;
}
export type ClientToServerMessage =
| PingMessage
| PongMessage
| VoteCastMessage
| VoteRemoveMessage;
| VoteRemoveMessage
| CommentLikeCastMessage
| CommentLikeRemoveMessage;
// ── Server → Client ──────────────────────────────────────────────────────────
@@ -488,6 +501,7 @@ export interface WelcomeMessage {
type: "welcome";
users: OnlineUser[];
myVotes: string[];
myCommentLikes: string[];
unreadNotificationCount: number;
}
@@ -511,6 +525,21 @@ export interface VoteAckMessage {
voteCount: number;
}
export interface CommentLikesUpdateMessage {
type: "comment_likes_update";
commentId: string;
likeCount: number;
likerId: string;
action: "cast" | "remove";
}
export interface CommentLikeAckMessage {
type: "comment_like_ack";
commentId: string;
action: "cast" | "remove";
likeCount: number;
}
export interface DumpCreatedMessage {
type: "dump_created";
dump: Dump;
@@ -589,6 +618,8 @@ export type ServerToClientMessage =
| PresenceUpdateMessage
| VotesUpdateMessage
| VoteAckMessage
| CommentLikesUpdateMessage
| CommentLikeAckMessage
| DumpCreatedMessage
| DumpUpdatedMessage
| DumpDeletedMessage
@@ -624,7 +655,8 @@ export type NotificationType =
| "playlist_dump_added"
| "dump_upvoted"
| "user_mentioned"
| "dump_commented";
| "dump_commented"
| "comment_liked";
export interface PlaylistFollowedData {
followerId: string;
@@ -676,6 +708,14 @@ export interface DumpCommentedData {
dumpTitle: string;
}
export interface CommentLikedData {
likerId: string;
likerUsername: string;
commentId: string;
dumpId: string;
dumpTitle: string;
}
export type NotificationData =
| PlaylistFollowedData
| UserFollowedData
@@ -683,7 +723,8 @@ export type NotificationData =
| PlaylistDumpAddedData
| DumpUpvotedData
| UserMentionedData
| DumpCommentedData;
| DumpCommentedData
| CommentLikedData;
export interface Notification {
id: string;

View File

@@ -9,12 +9,15 @@ import {
} from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import {
createComment,
deleteComment,
getCommentDumpId,
getComments,
updateComment,
} from "../services/comment-service.ts";
import { getCommentLikers } from "../services/comment-like-service.ts";
import { getDump } from "../services/dump-service.ts";
import {
broadcastCommentCreated,
@@ -83,6 +86,27 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
ctx.response.body = responseBody;
});
// GET /api/comments/:commentId/likers — optional auth (to access private dump's comment likers)
router.get("/comments/:commentId/likers", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dumpId = getCommentDumpId(ctx.params.commentId);
getDump(dumpId, requestingUserId); // enforces privacy, 404s if not visible
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getCommentLikers(
ctx.params.commentId,
page,
limit,
);
ctx.response.body = {
success: true,
data: {
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub),
total,
hasMore: page * limit < total,
},
};
});
// DELETE /api/comments/:commentId — auth required
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
const userId = ctx.state.user.userId;

View File

@@ -23,6 +23,7 @@ import {
replaceFileDump,
updateDump,
} from "../services/dump-service.ts";
import { getDumpVoters } from "../services/vote-service.ts";
const router = new Router({ prefix: "/api/dumps" });
@@ -84,6 +85,21 @@ router.get("/:dumpId", async (ctx) => {
ctx.response.body = responseBody;
});
router.get("/:dumpId/voters", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dump = getDump(ctx.params.dumpId, requestingUserId);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getDumpVoters(dump.id, page, limit);
ctx.response.body = {
success: true,
data: {
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub),
total,
hasMore: page * limit < total,
},
};
});
router.get("/", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const { page, limit } = parsePagination(ctx.request.url.searchParams);

View File

@@ -2,6 +2,7 @@ import { Router } from "@oak/oak";
import { ALLOWED_ORIGINS } from "../config.ts";
import { verifyJWT } from "../lib/jwt.ts";
import {
broadcastCommentLikeUpdate,
broadcastPresence,
broadcastVoteUpdate,
getOnlineUsers,
@@ -15,6 +16,11 @@ import {
getUserVotes,
removeVote,
} from "../services/vote-service.ts";
import {
castCommentLike,
getUserCommentLikes,
removeCommentLike,
} from "../services/comment-like-service.ts";
import { getUnreadCount } from "../services/notification-service.ts";
import { getUserById } from "../services/user-service.ts";
import {
@@ -63,6 +69,9 @@ router.get("/ws", async (ctx) => {
try {
const myVotes = authPayload ? getUserVotes(authPayload.userId) : [];
const myCommentLikes = authPayload
? getUserCommentLikes(authPayload.userId)
: [];
const unreadNotificationCount = authPayload
? getUnreadCount(authPayload.userId)
: 0;
@@ -70,6 +79,7 @@ router.get("/ws", async (ctx) => {
type: "welcome",
users: getOnlineUsers(),
myVotes,
myCommentLikes,
unreadNotificationCount,
}));
} catch (err) {
@@ -98,6 +108,12 @@ router.get("/ws", async (ctx) => {
case "vote_remove":
handleVote(client, msg.dumpId, "remove");
break;
case "comment_like_cast":
handleCommentLike(client, msg.commentId, "cast");
break;
case "comment_like_remove":
handleCommentLike(client, msg.commentId, "remove");
break;
}
});
@@ -141,4 +157,37 @@ function handleVote(
}
}
function handleCommentLike(
client: WsClient,
commentId: string,
action: "cast" | "remove",
): void {
const { socket } = client;
if (!client.userId) {
socket.send(
JSON.stringify({ type: "error", message: "Authentication required" }),
);
return;
}
try {
const newCount = action === "cast"
? castCommentLike(commentId, client.userId)
: removeCommentLike(commentId, client.userId);
socket.send(JSON.stringify({
type: "comment_like_ack",
commentId,
action,
likeCount: newCount,
}));
broadcastCommentLikeUpdate(commentId, newCount, client.userId, action);
} catch (err) {
const message = err instanceof APIException ? err.message : "Like failed";
socket.send(JSON.stringify({ type: "error", message }));
}
}
export default router;

View 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 };
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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 };
}

View File

@@ -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,

43
api/sql/migrate.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { DatabaseSync } from "node:sqlite";
import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts";
interface Migration {
name: string;
up: (db: DatabaseSync) => void;
}
// Append-only — never edit or reorder a migration once it has shipped.
// Each `up` must be idempotent: it also runs against fresh databases created
// straight from schema.sql, where the change it makes already exists.
const MIGRATIONS: Migration[] = [
{ name: "0001_comment_likes", up: up0001CommentLikes },
];
export function runMigrations(db: DatabaseSync): void {
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);`);
const applied = new Set(
(db.prepare(`SELECT name FROM schema_migrations;`).all() as {
name: string;
}[]).map((r) => r.name),
);
for (const { name, up } of MIGRATIONS) {
if (applied.has(name)) continue;
db.exec("BEGIN;");
try {
up(db);
db.prepare(
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?);`,
).run(name, new Date().toISOString());
db.exec("COMMIT;");
console.log(`[migrate] applied ${name}`);
} catch (err) {
db.exec("ROLLBACK;");
throw err;
}
}
}

View File

@@ -0,0 +1,27 @@
import type { DatabaseSync } from "node:sqlite";
// Idempotent: safe to run against a fresh db (already created from schema.sql
// with these columns/tables) or an existing one that predates them.
export function up(db: DatabaseSync): void {
const commentCols = db.prepare(`PRAGMA table_info(comments);`).all() as {
name: string;
}[];
if (!commentCols.some((c) => c.name === "like_count")) {
db.exec(
`ALTER TABLE comments ADD COLUMN like_count INTEGER NOT NULL DEFAULT 0;`,
);
}
db.exec(`CREATE TABLE IF NOT EXISTS comment_likes (
comment_id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (comment_id, user_id),
FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_comment_likes_user ON comment_likes(user_id);`,
);
}

View File

@@ -71,17 +71,28 @@ CREATE TABLE comments (
created_at TEXT NOT NULL,
updated_at TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
like_count INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
);
CREATE TABLE comment_likes (
comment_id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (comment_id, user_id),
FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_dumps_user ON dumps(user_id);
CREATE INDEX idx_votes_user ON votes(user_id);
CREATE INDEX idx_playlists_user ON playlists(user_id);
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id);
CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
CREATE INDEX idx_comment_likes_user ON comment_likes(user_id);
CREATE TABLE follows (
id TEXT PRIMARY KEY,