feat(comments): add sorting and liking functionality

Support sorting by oldest, latest, most-liked, and most-replied.
Implement like/unlike actions for Life and Entity Discussion comments.
This commit is contained in:
2026-05-04 17:29:09 +08:00
parent 504849c14a
commit 2ff2519647
10 changed files with 993 additions and 65 deletions

View File

@@ -299,12 +299,14 @@ VALUES
('life.comments.create', 'Create Life comments', 'Create Life comments and replies.', 'Life', true),
('life.comments.delete', 'Delete own Life comments', 'Delete own Life comments.', 'Life', true),
('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true),
('life.comments.like', 'Like Life comments', 'Like and unlike Life comments.', 'Life', true),
('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true),
('life.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true),
('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', true),
('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true),
('discussions.comments.delete-any', 'Delete any discussion comment', 'Delete any entity discussion comment.', 'Discussions', true)
('discussions.comments.delete-any', 'Delete any discussion comment', 'Delete any entity discussion comment.', 'Discussions', true),
('discussions.comments.like', 'Like discussion comments', 'Like and unlike entity discussion comments.', 'Discussions', true)
ON CONFLICT (key) DO NOTHING;
INSERT INTO roles (key, name, description, level, enabled, system_role)
@@ -391,12 +393,14 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.create',
'life.comments.delete',
'life.comments.delete-any',
'life.comments.like',
'life.reactions.set',
'life.ratings.set',
'users.follow',
'discussions.comments.create',
'discussions.comments.delete',
'discussions.comments.delete-any'
'discussions.comments.delete-any',
'discussions.comments.like'
])
WHERE r.key = 'admin'
AND NOT EXISTS (
@@ -460,11 +464,13 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.posts.delete',
'life.comments.create',
'life.comments.delete',
'life.comments.like',
'life.reactions.set',
'life.ratings.set',
'users.follow',
'discussions.comments.create',
'discussions.comments.delete'
'discussions.comments.delete',
'discussions.comments.like'
])
WHERE r.key = 'editor'
AND NOT EXISTS (
@@ -508,11 +514,13 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.posts.delete',
'life.comments.create',
'life.comments.delete',
'life.comments.like',
'life.reactions.set',
'life.ratings.set',
'users.follow',
'discussions.comments.create',
'discussions.comments.delete'
'discussions.comments.delete',
'discussions.comments.like'
])
WHERE r.key = 'member'
AND NOT EXISTS (
@@ -529,6 +537,20 @@ JOIN permissions p ON p.key = 'life.ratings.set'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = 'life.comments.like'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = 'discussions.comments.like'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
@@ -744,6 +766,19 @@ CREATE INDEX IF NOT EXISTS life_post_comments_parent_idx
CREATE INDEX IF NOT EXISTS life_post_comments_user_idx
ON life_post_comments(created_by_user_id, created_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS life_comment_likes (
comment_id integer NOT NULL REFERENCES life_post_comments(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (comment_id, user_id)
);
CREATE INDEX IF NOT EXISTS life_comment_likes_comment_idx
ON life_comment_likes(comment_id);
CREATE INDEX IF NOT EXISTS life_comment_likes_user_idx
ON life_comment_likes(user_id, created_at DESC, comment_id DESC);
CREATE TABLE IF NOT EXISTS life_post_reactions (
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -1235,6 +1270,19 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_parent_idx
CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
ON entity_discussion_comments(created_by_user_id);
CREATE TABLE IF NOT EXISTS entity_discussion_comment_likes (
comment_id integer NOT NULL REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (comment_id, user_id)
);
CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_comment_idx
ON entity_discussion_comment_likes(comment_id);
CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_user_idx
ON entity_discussion_comment_likes(user_id, created_at DESC, comment_id DESC);
ALTER TABLE entity_discussion_comments
DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check;

View File

@@ -271,6 +271,9 @@ type EntityDiscussionCommentRow = {
createdAtCursor?: string;
updatedAt: Date;
author: { id: number; displayName: string } | null;
likeCount: number;
replyCount: number;
myLiked: boolean;
};
type EntityDiscussionComment = Omit<EntityDiscussionCommentRow, 'createdAtCursor'> & {
replies: EntityDiscussionComment[];
@@ -313,6 +316,9 @@ type LifeCommentRow = {
createdAtCursor?: string;
updatedAt: Date;
author: { id: number; displayName: string } | null;
likeCount: number;
replyCount: number;
myLiked: boolean;
};
type LifeComment = Omit<LifeCommentRow, 'createdAtCursor'> & {
@@ -351,6 +357,12 @@ type LifePostCursor = {
};
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
type CommentSort = 'oldest' | 'latest' | 'most-liked' | 'most-replied';
type CommentCursor = {
createdAt: string;
id: number;
count?: number;
};
type LifePostFilters = {
authorId?: number;
@@ -2866,6 +2878,20 @@ function addModerationVisibilityCondition(
conditions.push(`${alias}.ai_moderation_status = 'approved'`);
}
function moderationVisibilitySql(alias: string, ownerColumn: string, userId: number | null, canViewAll: boolean): string {
if (canViewAll) {
return 'true';
}
if (userId !== null) {
return `(${alias}.ai_moderation_status = 'approved' OR ${ownerColumn} = ${userId})`;
}
return `${alias}.ai_moderation_status = 'approved'`;
}
function visibleLifeCommentSql(alias: string, ownerColumn: string, userId: number | null): string {
return userId !== null ? `(${alias}.deleted_at IS NULL OR ${ownerColumn} = ${userId})` : `${alias}.deleted_at IS NULL`;
}
function addModerationLanguageCondition(
conditions: string[],
params: unknown[],
@@ -2966,6 +2992,92 @@ function cleanCommentLimit(value: QueryValue): number {
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxCommentLimit) : defaultCommentLimit;
}
function cleanCommentSort(value: QueryValue): CommentSort {
const sort = asString(value);
return sort === 'latest' || sort === 'most-liked' || sort === 'most-replied' ? sort : 'oldest';
}
function decodeCommentCursor(value: QueryValue): CommentCursor | null {
const rawCursor = asString(value);
if (!rawCursor) {
return null;
}
try {
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<CommentCursor>;
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
const id = Number(cursor.id);
const count = cursor.count === undefined ? undefined : Number(cursor.count);
if (
!createdAt ||
Number.isNaN(new Date(createdAt).getTime()) ||
!Number.isInteger(id) ||
id <= 0 ||
(count !== undefined && (!Number.isInteger(count) || count < 0))
) {
throw validationError('server.validation.cursorInvalid');
}
return { createdAt, id, count };
} catch (error) {
if (error instanceof Error && 'statusCode' in error) {
throw error;
}
throw validationError('server.validation.cursorInvalid');
}
}
function encodeCommentCursor(comment: LifeCommentRow | EntityDiscussionCommentRow, sort: CommentSort): string {
const count = sort === 'most-liked' ? comment.likeCount : sort === 'most-replied' ? comment.replyCount : undefined;
return Buffer.from(JSON.stringify({ createdAt: comment.createdAtCursor, id: comment.id, count }), 'utf8').toString('base64url');
}
function commentSortOrder(alias: string, sort: CommentSort): string {
if (sort === 'latest') {
return `${alias}.created_at DESC, ${alias}.id DESC`;
}
if (sort === 'most-liked') {
return `"likeCount" DESC, ${alias}.created_at DESC, ${alias}.id DESC`;
}
if (sort === 'most-replied') {
return `"replyCount" DESC, ${alias}.created_at DESC, ${alias}.id DESC`;
}
return `${alias}.created_at, ${alias}.id`;
}
function addCommentCursorCondition(
conditions: string[],
params: unknown[],
alias: string,
cursor: CommentCursor,
sort: CommentSort
): void {
if (sort === 'latest') {
params.push(cursor.createdAt, cursor.id);
conditions.push(`(${alias}.created_at, ${alias}.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
return;
}
if (sort === 'most-liked' || sort === 'most-replied') {
params.push(cursor.count ?? 0, cursor.createdAt, cursor.id);
const countExpression = sort === 'most-liked' ? 'like_stats.like_count' : 'reply_stats.reply_count';
conditions.push(`
(
${countExpression} < $${params.length - 2}::integer
OR (
${countExpression} = $${params.length - 2}::integer
AND (${alias}.created_at, ${alias}.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)
)
)
`);
return;
}
params.push(cursor.createdAt, cursor.id);
conditions.push(`(${alias}.created_at, ${alias}.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
}
function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
const rawCursor = asString(value);
if (!rawCursor) {
@@ -3101,7 +3213,17 @@ function hydrateLifePost(
};
}
function lifeCommentProjection(whereClause: string): string {
function lifeCommentProjection(whereClause: string, userId: number | null = null, canViewAll = false): string {
const myLikedExpression =
userId === null
? 'false'
: `EXISTS (SELECT 1 FROM life_comment_likes my_like WHERE my_like.comment_id = lc.id AND my_like.user_id = ${userId})`;
const replyVisibility = [
'reply.parent_comment_id = lc.id',
visibleLifeCommentSql('reply', 'reply.created_by_user_id', userId),
moderationVisibilitySql('reply', 'reply.created_by_user_id', userId, canViewAll)
].join(' AND ');
return `
SELECT
lc.id,
@@ -3115,8 +3237,21 @@ function lifeCommentProjection(whereClause: string): string {
lc.created_at AS "createdAt",
lc.created_at::text AS "createdAtCursor",
lc.updated_at AS "updatedAt",
CASE WHEN comment_user.id IS NULL THEN NULL ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name) END AS author
CASE WHEN comment_user.id IS NULL THEN NULL ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name) END AS author,
like_stats.like_count AS "likeCount",
reply_stats.reply_count AS "replyCount",
${myLikedExpression} AS "myLiked"
FROM life_post_comments lc
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS like_count
FROM life_comment_likes lcl
WHERE lcl.comment_id = lc.id
) like_stats ON true
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS reply_count
FROM life_post_comments reply
WHERE ${replyVisibility}
) reply_stats ON true
LEFT JOIN users comment_user ON comment_user.id = lc.created_by_user_id
${whereClause}
`;
@@ -3236,7 +3371,7 @@ async function lifeCommentPreviewForPosts(
) ranked
WHERE preview_rank <= $${params.length}
)
${lifeCommentProjection('WHERE lc.id IN (SELECT id FROM preview_top)')}
${lifeCommentProjection('WHERE lc.id IN (SELECT id FROM preview_top)', userId, canViewAll)}
ORDER BY lc.post_id, lc.created_at, lc.id
`,
params
@@ -3256,9 +3391,10 @@ export async function listLifeComments(
canViewAll = false
): Promise<LifeCommentsPage | null> {
const postId = requirePositiveInteger(postIdValue, 'server.validation.recordInvalid');
const cursor = decodeLifePostCursor(paramsQuery.cursor);
const cursor = decodeCommentCursor(paramsQuery.cursor);
const limit = cleanCommentLimit(paramsQuery.limit);
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
const sort = cleanCommentSort(paramsQuery.sort);
const postParams: unknown[] = [postId];
const postConditions = ['lp.id = $1', 'lp.deleted_at IS NULL'];
addModerationVisibilityCondition(postConditions, postParams, 'lp', 'lp.created_by_user_id', userId, canViewAll);
@@ -3284,15 +3420,14 @@ export async function listLifeComments(
addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode);
if (cursor) {
params.push(cursor.createdAt, cursor.id);
topLevelConditions.push(`(lc.created_at, lc.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
addCommentCursorCondition(topLevelConditions, params, 'lc', cursor, sort);
}
params.push(limit + 1);
const topLevelRows = await query<LifeCommentRow>(
`
${lifeCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`)}
ORDER BY lc.created_at, lc.id
${lifeCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`, userId, canViewAll)}
ORDER BY ${commentSortOrder('lc', sort)}
LIMIT $${params.length}
`,
params
@@ -3309,7 +3444,7 @@ export async function listLifeComments(
addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode);
return query<LifeCommentRow>(
`
${lifeCommentProjection(`WHERE ${replyConditions.join(' AND ')}`)}
${lifeCommentProjection(`WHERE ${replyConditions.join(' AND ')}`, userId, canViewAll)}
ORDER BY lc.created_at, lc.id
`,
replyParams
@@ -3334,12 +3469,7 @@ export async function listLifeComments(
items: buildLifeCommentTree([...topLevelComments, ...replyRows]),
nextCursor:
hasMore && topLevelComments.length > 0
? encodeProfileCursor({
createdAt:
topLevelComments[topLevelComments.length - 1].createdAtCursor ??
topLevelComments[topLevelComments.length - 1].createdAt.toISOString(),
id: topLevelComments[topLevelComments.length - 1].id
})
? encodeCommentCursor(topLevelComments[topLevelComments.length - 1], sort)
: null,
hasMore,
total: total?.total ?? 0
@@ -3526,10 +3656,10 @@ async function lifeRatingsForPosts(postIds: number[], userId: number | null): Pr
return myRatingsByPost;
}
async function getLifeCommentById(id: number): Promise<LifeComment | null> {
async function getLifeCommentById(id: number, userId: number | null = null, canViewAll = false): Promise<LifeComment | null> {
const row = await queryOne<LifeCommentRow>(
`
${lifeCommentProjection('WHERE lc.id = $1')}
${lifeCommentProjection('WHERE lc.id = $1', userId, canViewAll)}
`,
[id]
);
@@ -4347,6 +4477,7 @@ export async function retryLifePostModeration(id: number, userId: number, locale
WHERE id = $1
AND ($3 = true OR created_by_user_id = $2)
AND deleted_at IS NULL
AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
`,
[postId, userId, allowAny]
);
@@ -4490,7 +4621,7 @@ export async function createLifeComment(postId: number, payload: Record<string,
);
}
return result ? getLifeCommentById(result.id) : null;
return result ? getLifeCommentById(result.id, userId, false) : null;
}
export async function createLifeCommentReply(
@@ -4533,7 +4664,7 @@ export async function createLifeCommentReply(
);
}
return result ? getLifeCommentById(result.id) : null;
return result ? getLifeCommentById(result.id, userId, false) : null;
}
export async function deleteLifeComment(id: number, userId: number, allowAny = false) {
@@ -4572,7 +4703,7 @@ export async function restoreLifeComment(id: number, userId: number) {
[commentId, userId]
);
return result ? getLifeCommentById(result.id) : null;
return result ? getLifeCommentById(result.id, userId, false) : null;
}
export async function retryLifeCommentModeration(id: number, userId: number, allowAny = false) {
@@ -4584,6 +4715,7 @@ export async function retryLifeCommentModeration(id: number, userId: number, all
WHERE id = $1
AND ($3 = true OR created_by_user_id = $2)
AND deleted_at IS NULL
AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
`,
[commentId, userId, allowAny]
);
@@ -4593,7 +4725,63 @@ export async function retryLifeCommentModeration(id: number, userId: number, all
}
await requestAiModerationReview({ type: 'life-comment', id: commentId }, { incrementRetries: true });
return getLifeCommentById(commentId);
return getLifeCommentById(commentId, userId, allowAny);
}
async function approvedLifeCommentExists(commentId: number): Promise<boolean> {
const row = await queryOne<{ id: number }>(
`
SELECT lc.id
FROM life_post_comments lc
JOIN life_posts lp ON lp.id = lc.post_id
WHERE lc.id = $1
AND lc.deleted_at IS NULL
AND lc.ai_moderation_status = 'approved'
AND lp.deleted_at IS NULL
AND lp.ai_moderation_status = 'approved'
`,
[commentId]
);
return Boolean(row);
}
export async function setLifeCommentLike(id: number, userId: number): Promise<LifeComment | null> {
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
if (!(await approvedLifeCommentExists(commentId))) {
return null;
}
await queryOne<{ commentId: number }>(
`
INSERT INTO life_comment_likes (comment_id, user_id)
VALUES ($1, $2)
ON CONFLICT (comment_id, user_id) DO NOTHING
RETURNING comment_id AS "commentId"
`,
[commentId, userId]
);
return getLifeCommentById(commentId, userId);
}
export async function deleteLifeCommentLike(id: number, userId: number): Promise<LifeComment | null> {
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
if (!(await approvedLifeCommentExists(commentId))) {
return null;
}
await queryOne<{ commentId: number }>(
`
DELETE FROM life_comment_likes
WHERE comment_id = $1
AND user_id = $2
RETURNING comment_id AS "commentId"
`,
[commentId, userId]
);
return getLifeCommentById(commentId, userId);
}
function cleanDiscussionEntityType(value: unknown): DiscussionEntityType {
@@ -4627,7 +4815,21 @@ async function entityDiscussionExists(
return result.rows[0]?.exists === true;
}
function entityDiscussionCommentProjection(whereClause: string): string {
function entityDiscussionCommentProjection(whereClause: string, userId: number | null = null, canViewAll = false): string {
const myLikedExpression =
userId === null
? 'false'
: `EXISTS (
SELECT 1
FROM entity_discussion_comment_likes my_like
WHERE my_like.comment_id = edc.id AND my_like.user_id = ${userId}
)`;
const replyVisibility = [
'reply.parent_comment_id = edc.id',
'reply.deleted_at IS NULL',
moderationVisibilitySql('reply', 'reply.created_by_user_id', userId, canViewAll)
].join(' AND ');
return `
SELECT
edc.id,
@@ -4645,8 +4847,21 @@ function entityDiscussionCommentProjection(whereClause: string): string {
CASE
WHEN edc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name)
END AS author
END AS author,
like_stats.like_count AS "likeCount",
reply_stats.reply_count AS "replyCount",
${myLikedExpression} AS "myLiked"
FROM entity_discussion_comments edc
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS like_count
FROM entity_discussion_comment_likes edcl
WHERE edcl.comment_id = edc.id
) like_stats ON true
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS reply_count
FROM entity_discussion_comments reply
WHERE ${replyVisibility}
) reply_stats ON true
LEFT JOIN users comment_user ON comment_user.id = edc.created_by_user_id
${whereClause}
`;
@@ -4678,10 +4893,14 @@ function buildEntityDiscussionCommentTree(rows: EntityDiscussionCommentRow[]): E
return topLevelComments;
}
async function getEntityDiscussionCommentById(id: number): Promise<EntityDiscussionComment | null> {
async function getEntityDiscussionCommentById(
id: number,
userId: number | null = null,
canViewAll = false
): Promise<EntityDiscussionComment | null> {
const row = await queryOne<EntityDiscussionCommentRow>(
`
${entityDiscussionCommentProjection('WHERE edc.id = $1')}
${entityDiscussionCommentProjection('WHERE edc.id = $1', userId, canViewAll)}
`,
[id]
);
@@ -4703,9 +4922,10 @@ export async function listEntityDiscussionComments(
): Promise<EntityDiscussionCommentsPage | null> {
const entityType = cleanDiscussionEntityType(entityTypeValue);
const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
const cursor = decodeLifePostCursor(paramsQuery.cursor);
const cursor = decodeCommentCursor(paramsQuery.cursor);
const limit = cleanCommentLimit(paramsQuery.limit);
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
const sort = cleanCommentSort(paramsQuery.sort);
if (!(await entityDiscussionExists(pool, entityType, entityId))) {
return null;
@@ -4717,15 +4937,14 @@ export async function listEntityDiscussionComments(
addModerationLanguageCondition(topLevelConditions, params, 'edc', languageCode);
if (cursor) {
params.push(cursor.createdAt, cursor.id);
topLevelConditions.push(`(edc.created_at, edc.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
addCommentCursorCondition(topLevelConditions, params, 'edc', cursor, sort);
}
params.push(limit + 1);
const topLevelRows = await query<EntityDiscussionCommentRow>(
`
${entityDiscussionCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`)}
ORDER BY edc.created_at, edc.id
${entityDiscussionCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`, userId, canViewAll)}
ORDER BY ${commentSortOrder('edc', sort)}
LIMIT $${params.length}
`,
params
@@ -4741,7 +4960,7 @@ export async function listEntityDiscussionComments(
addModerationLanguageCondition(replyConditions, replyParams, 'edc', languageCode);
return query<EntityDiscussionCommentRow>(
`
${entityDiscussionCommentProjection(`WHERE ${replyConditions.join(' AND ')}`)}
${entityDiscussionCommentProjection(`WHERE ${replyConditions.join(' AND ')}`, userId, canViewAll)}
ORDER BY edc.created_at, edc.id
`,
replyParams
@@ -4765,12 +4984,7 @@ export async function listEntityDiscussionComments(
items: buildEntityDiscussionCommentTree([...topLevelComments, ...replyRows]),
nextCursor:
hasMore && topLevelComments.length > 0
? encodeProfileCursor({
createdAt:
topLevelComments[topLevelComments.length - 1].createdAtCursor ??
topLevelComments[topLevelComments.length - 1].createdAt.toISOString(),
id: topLevelComments[topLevelComments.length - 1].id
})
? encodeCommentCursor(topLevelComments[topLevelComments.length - 1], sort)
: null,
hasMore,
total: total?.total ?? 0
@@ -4818,7 +5032,7 @@ export async function createEntityDiscussionComment(
);
}
return id ? getEntityDiscussionCommentById(id) : null;
return id ? getEntityDiscussionCommentById(id, userId, false) : null;
}
export async function createEntityDiscussionReply(
@@ -4872,7 +5086,7 @@ export async function createEntityDiscussionReply(
);
}
return id ? getEntityDiscussionCommentById(id) : null;
return id ? getEntityDiscussionCommentById(id, userId, false) : null;
}
export async function deleteEntityDiscussionComment(id: number, userId: number, allowAny = false): Promise<boolean> {
@@ -4907,6 +5121,7 @@ export async function retryEntityDiscussionCommentModeration(
WHERE id = $1
AND ($3 = true OR created_by_user_id = $2)
AND deleted_at IS NULL
AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
`,
[commentId, userId, allowAny]
);
@@ -4916,7 +5131,60 @@ export async function retryEntityDiscussionCommentModeration(
}
await requestAiModerationReview({ type: 'discussion-comment', id: commentId }, { incrementRetries: true });
return getEntityDiscussionCommentById(commentId);
return getEntityDiscussionCommentById(commentId, userId, allowAny);
}
async function approvedEntityDiscussionCommentExists(commentId: number): Promise<boolean> {
const row = await queryOne<{ id: number }>(
`
SELECT id
FROM entity_discussion_comments
WHERE id = $1
AND deleted_at IS NULL
AND ai_moderation_status = 'approved'
`,
[commentId]
);
return Boolean(row);
}
export async function setEntityDiscussionCommentLike(id: number, userId: number): Promise<EntityDiscussionComment | null> {
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
if (!(await approvedEntityDiscussionCommentExists(commentId))) {
return null;
}
await queryOne<{ commentId: number }>(
`
INSERT INTO entity_discussion_comment_likes (comment_id, user_id)
VALUES ($1, $2)
ON CONFLICT (comment_id, user_id) DO NOTHING
RETURNING comment_id AS "commentId"
`,
[commentId, userId]
);
return getEntityDiscussionCommentById(commentId, userId);
}
export async function deleteEntityDiscussionCommentLike(id: number, userId: number): Promise<EntityDiscussionComment | null> {
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
if (!(await approvedEntityDiscussionCommentExists(commentId))) {
return null;
}
await queryOne<{ commentId: number }>(
`
DELETE FROM entity_discussion_comment_likes
WHERE comment_id = $1
AND user_id = $2
RETURNING comment_id AS "commentId"
`,
[commentId, userId]
);
return getEntityDiscussionCommentById(commentId, userId);
}
async function deleteEntityDiscussionCommentsForEntity(
@@ -6838,7 +7106,8 @@ const dataToolColumns = {
'deleted_at',
'created_at',
'updated_at'
]
],
discussionCommentLikes: ['comment_id', 'user_id', 'created_at']
} as const;
function isDataToolScope(value: unknown): value is DataToolScope {
@@ -7057,6 +7326,17 @@ async function exportGenericScopeData(client: DbClient, entityType: string, incl
ORDER BY parent_comment_id NULLS FIRST, id
`,
[entityType]
),
discussionCommentLikes: await tableRows(
client,
`
SELECT edcl.*
FROM entity_discussion_comment_likes edcl
JOIN entity_discussion_comments edc ON edc.id = edcl.comment_id
WHERE edc.entity_type = $1
ORDER BY edcl.comment_id, edcl.user_id
`,
[entityType]
)
};
@@ -7182,6 +7462,12 @@ async function importGenericScopeRows(client: DbClient, bundle: DataToolsBundle)
await insertRows(client, 'wiki_edit_logs', dataToolColumns.editLogs, dataToolTableRows(data, 'editLogs'));
await insertRows(client, 'entity_image_uploads', dataToolColumns.imageUploads, dataToolTableRows(data, 'imageUploads'));
await insertRows(client, 'entity_discussion_comments', dataToolColumns.discussionComments, dataToolTableRows(data, 'discussionComments'));
await insertRows(
client,
'entity_discussion_comment_likes',
dataToolColumns.discussionCommentLikes,
dataToolTableRows(data, 'discussionCommentLikes')
);
}
}

View File

@@ -55,7 +55,9 @@ import {
deleteHabitat,
deleteItem,
deleteLanguage,
deleteEntityDiscussionCommentLike,
deleteLifeComment,
deleteLifeCommentLike,
deleteLifePost,
deleteLifePostRating,
deleteLifePostReaction,
@@ -108,6 +110,8 @@ import {
restoreLifeComment,
setLifePostRating,
setLifePostReaction,
setEntityDiscussionCommentLike,
setLifeCommentLike,
updateConfig,
updateAncientArtifact,
updateDailyChecklistItem,
@@ -1470,6 +1474,26 @@ app.post('/api/life-comments/:id/restore', async (request, reply) => {
return comment ? comment : notFound(reply, request);
});
app.put('/api/life-comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await setLifeCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.delete('/api/life-comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await deleteLifeCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.post('/api/life-comments/:id/moderation/retry', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits(
request,
@@ -1580,6 +1604,28 @@ app.post('/api/discussions/comments/:id/moderation/retry', async (request, reply
return comment ? comment : notFound(reply, request);
});
app.put('/api/discussions/comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'discussions.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await setEntityDiscussionCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.delete('/api/discussions/comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'discussions.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await deleteEntityDiscussionCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.get('/api/pokemon', async (request) =>
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);