Validation: the dump validators refuse the nulls their shared types don't allow (a null title or note on creation, a null URL on edit); a playlist title of spaces is refused and titles are stored trimmed; usernames and emails are checked by the same USERNAME_RE and EMAIL_RE in shared/validation.ts on sign-up, on PATCH /api/users/me (which checked other limits) and in the sign-up form. Mentions are read by shared/mention.ts's mentionedUsernames, which skips code and URLs as Markdown shows them: https://mastodon.social/@bob or a `@bob` in code notified bob; the editor's suggestions derive their trigger from the same pattern instead of a copy. Following an unknown user is a 404 rather than a foreign-key 500, and following a private playlist a 404 rather than a 403 that confirmed it exists. `ws` and `emojibase` are reserved category slugs, since the server answers them before the app. GET /api/users/me no longer turns every error into a 500. Dead code goes: adminOnlyMiddleware, verifyEmailTransport, listUsers, deleteUser with disconnectUser and the force_logout message only it sent, PATCH /api/notifications/:id/read (with markOneRead), isSlugAvailable, the unreachable 500 in fetchComment, and the `if (!ctx.state.user)` checks behind authMiddleware. 214 backend and 627 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
5.4 KiB
TypeScript
158 lines
5.4 KiB
TypeScript
/**
|
|
* Stores likes on comments and keeps each comment's `like_count` in step with
|
|
* them. Likes have no HTTP endpoint to cast or remove. The WebSocket route
|
|
* (`routes/ws.ts`) calls this module on `comment_like_cast` and
|
|
* `comment_like_remove` messages, then broadcasts the new count. The comment
|
|
* routes use it only to list a comment's likers.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { APIException } from "../lib/api-exception.ts";
|
|
import { isUserRow, type User, userRowToApi } from "../model/user.ts";
|
|
import { db, transaction } from "../db/connection.ts";
|
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
|
import { notifyCommentOwnerLike } from "./notification-service.ts";
|
|
|
|
/**
|
|
* Returned to the WebSocket route by {@link castCommentLike} and
|
|
* {@link removeCommentLike}. The route acks the liker with `likeCount`, then
|
|
* broadcasts it to every client, or to the liker's alone when the comment's
|
|
* dump `isPrivate`.
|
|
*/
|
|
export interface LikeResult {
|
|
likeCount: number;
|
|
isPrivate: boolean;
|
|
}
|
|
|
|
/**
|
|
* Returns whether the comment's dump is private, or throws a 404 when `userId`
|
|
* can't see it.
|
|
*/
|
|
function commentVisibility(commentId: string, userId: string): boolean {
|
|
const row = db.prepare(
|
|
`SELECT d.is_private, d.user_id FROM comments c JOIN dumps d ON d.id = c.dump_id
|
|
WHERE c.id = ?;`,
|
|
).get(commentId) as { is_private: number; user_id: string } | undefined;
|
|
if (!row || (row.is_private && row.user_id !== userId)) {
|
|
throw new APIException(404, "Comment not found");
|
|
}
|
|
return Boolean(row.is_private);
|
|
}
|
|
|
|
/**
|
|
* Records `userId`'s like on a comment and notifies the comment's author. The
|
|
* notification is best-effort: if it fails, the like still counts.
|
|
*
|
|
* @throws {APIException} 404 when the comment doesn't exist or sits on someone
|
|
* else's private dump, 409 when the user already likes it.
|
|
*/
|
|
export function castCommentLike(
|
|
commentId: string,
|
|
userId: string,
|
|
): LikeResult {
|
|
const isPrivate = commentVisibility(commentId, userId);
|
|
let likeCount: number;
|
|
try {
|
|
likeCount = transaction(() => {
|
|
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 };
|
|
return row.like_count;
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
|
|
throw new APIException(409, "Already liked");
|
|
}
|
|
throw err;
|
|
}
|
|
// The like is committed: a failed notification must not report it as failed.
|
|
try {
|
|
notifyCommentOwnerLike(userId, commentId);
|
|
} catch { /* ignore */ }
|
|
return { likeCount, isPrivate };
|
|
}
|
|
|
|
/**
|
|
* Takes back `userId`'s like on a comment. It doesn't check that the user can
|
|
* still see the comment, since a like on a dump that has gone private is still
|
|
* theirs to take back.
|
|
*
|
|
* @throws {APIException} 404 when the user doesn't like the comment.
|
|
*/
|
|
export function removeCommentLike(
|
|
commentId: string,
|
|
userId: string,
|
|
): LikeResult {
|
|
const dumpRow = db.prepare(
|
|
`SELECT d.is_private FROM comments c JOIN dumps d ON d.id = c.dump_id
|
|
WHERE c.id = ?;`,
|
|
).get(commentId) as { is_private: number } | undefined;
|
|
const isPrivate = Boolean(dumpRow?.is_private);
|
|
return transaction(() => {
|
|
const result = db.prepare(
|
|
`DELETE FROM comment_likes WHERE comment_id = ? AND user_id = ?;`,
|
|
).run(commentId, userId);
|
|
if (result.changes === 0) {
|
|
throw new APIException(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 };
|
|
return { likeCount: row.like_count, isPrivate };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the ids of the comments `userId` likes. The WebSocket route sends
|
|
* them in its `welcome` message, and the client shows those like buttons
|
|
* pressed.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Returns a page of the users who like a comment, latest like first, for
|
|
* `GET /api/comments/:commentId/likers`. It doesn't check that the caller may
|
|
* see the comment: the route does that first, with `getDump`.
|
|
*
|
|
* @throws {APIException} 500 when a row doesn't parse as a user.
|
|
*/
|
|
export function getCommentLikers(
|
|
commentId: 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 comment_likes cl ON cl.comment_id = ? AND cl.user_id = u.id
|
|
ORDER BY cl.created_at DESC
|
|
LIMIT ? OFFSET ?;`,
|
|
).all(commentId, limit + 1, offset);
|
|
|
|
if (!rawRows.every(isUserRow)) {
|
|
throw new APIException(500, "Malformed user data");
|
|
}
|
|
|
|
return pageOf(rawRows.map(userRowToApi), limit);
|
|
}
|