diff --git a/libs/model/src/comment/GetComments.query.ts b/libs/model/src/comment/GetComments.query.ts index 3ce65199f03..b6a4a841280 100644 --- a/libs/model/src/comment/GetComments.query.ts +++ b/libs/model/src/comment/GetComments.query.ts @@ -1,8 +1,10 @@ import { type Query } from '@hicommonwealth/core'; import * as schemas from '@hicommonwealth/schemas'; +import { CommentsView } from '@hicommonwealth/schemas'; +import { QueryTypes } from 'sequelize'; +import { z } from 'zod'; import { models } from '../database'; -import { removeUndefined, sanitizeDeletedComment } from '../utils/index'; -import { formatSequelizePagination } from '../utils/paginationUtils'; +import { sanitizeDeletedComment } from '../utils/index'; export function GetComments(): Query { return { @@ -10,63 +12,109 @@ export function GetComments(): Query { auth: [], secure: false, body: async ({ payload }) => { - const { thread_id, comment_id, include_user, include_reactions } = + const { thread_id, comment_id, include_reactions, limit, cursor } = payload; - const includeArray = []; - if (include_user) { - includeArray.push({ - model: models.Address, - include: [ - { - model: models.User, - as: 'User', - required: true, - attributes: ['id', 'profile'], - }, - ], - }); - } - - if (include_reactions) { - includeArray.push({ - model: models.Reaction, - as: 'reactions', - include: [ - { - model: models.Address, - as: 'Address', - required: true, - attributes: ['address', 'last_active'], - include: [ - { - model: models.User, - as: 'User', - required: true, - attributes: ['id', 'profile'], - }, - ], - }, - ], - }); - } + const sql = ` + SELECT + C.id, + C.body, + C.created_at, + C.updated_at, + C.deleted_at, + C.marked_as_spam_at, + C.reaction_count, + CA.address, + CA.last_active, + CU.id AS "user_id", + CU.profile->>'name' AS "profile_name", + CU.profile->>'avatar_url' AS "avatar_url", + ${ + include_reactions + ? ` + json_agg(json_strip_nulls(json_build_object( + 'id', R.id, + 'address_id', R.address_id, + 'reaction', R.reaction, + 'created_at', R.created_at::text, + 'updated_at', R.updated_at::text, + 'calculated_voting_weight', R.calculated_voting_weight::text, + 'address', RA.address, + 'last_active', RA.last_active::text, + 'profile_name', RU.profile->>'name', + 'avatar_url', RU.profile->>'avatar_url' + ))) AS "reactions", + ` + : '' + } + COUNT(*) OVER() AS total_count +FROM + "Comments" AS C + JOIN "Addresses" AS CA ON C."address_id" = CA."id" + JOIN "Users" AS CU ON CA."user_id" = CU."id" + ${ + include_reactions + ? ` + LEFT JOIN "Reactions" AS R ON C."id" = R."comment_id" + LEFT JOIN "Addresses" AS RA ON R."address_id" = RA."id" + LEFT JOIN "Users" AS RU ON RA."user_id" = RU."id" + ` + : '' + } +WHERE + C."thread_id" = :thread_id + ${comment_id ? ' AND C."id" = :comment_id' : ''} +${ + include_reactions + ? ` +GROUP BY + C.id, + C.created_at, + C.updated_at, + C.deleted_at, + C.marked_as_spam_at, + CA.address, + CA.last_active, + CU.id, + CU.profile->>'name', + CU.profile->>'avatar_url' +` + : '' +} +ORDER BY + C."created_at" +LIMIT :limit OFFSET :offset; + `; - const { count, rows: comments } = await models.Comment.findAndCountAll({ - where: removeUndefined({ thread_id, id: comment_id }), - include: includeArray, - ...formatSequelizePagination(payload), - paranoid: false, + const comments = await models.sequelize.query< + z.infer & { + total_count: number; + } + >(sql, { + replacements: { + thread_id, + comment_id, + limit, + offset: (cursor - 1) * limit, + }, + type: QueryTypes.SELECT, }); const sanitizedComments = comments.map((c) => { - const data = c.toJSON(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { total_count, ...rest } = c; return { - ...sanitizeDeletedComment(data), - last_edited: data.updated_at, - }; + ...sanitizeDeletedComment( + rest as unknown as z.infer, + ), + } as unknown as z.infer; }); - return schemas.buildPaginatedResponse(sanitizedComments, count, payload); + return schemas.buildPaginatedResponse( + sanitizedComments, + comments?.length ? comments!.at(0)!.total_count : 0, + payload, + ); }, }; } diff --git a/libs/model/src/utils/sanitizeDeletedComment.ts b/libs/model/src/utils/sanitizeDeletedComment.ts index f5eae614df6..a75dead2bbc 100644 --- a/libs/model/src/utils/sanitizeDeletedComment.ts +++ b/libs/model/src/utils/sanitizeDeletedComment.ts @@ -1,8 +1,9 @@ -import { CommentAttributes } from '../models/comment'; +import { Comment } from '@hicommonwealth/schemas'; +import { z } from 'zod'; export function sanitizeDeletedComment( - comment: CommentAttributes, -): CommentAttributes { + comment: z.infer, +): z.infer { if (!comment.deleted_at) { return comment; } diff --git a/libs/model/test/thread/thread-lifecycle.spec.ts b/libs/model/test/thread/thread-lifecycle.spec.ts index 641b96f5905..0320c6d1b79 100644 --- a/libs/model/test/thread/thread-lifecycle.spec.ts +++ b/libs/model/test/thread/thread-lifecycle.spec.ts @@ -33,6 +33,7 @@ import { CreateCommentErrors, CreateCommentReaction, DeleteComment, + GetComments, MAX_COMMENT_DEPTH, UpdateComment, } from '../../src/comment'; @@ -845,6 +846,92 @@ describe('Thread lifecycle', () => { }), ).rejects.toThrowError(InvalidActor); }); + + test('should get comments with reactions', async () => { + await command(CreateComment(), { + actor: actors.admin, + payload: { + parent_msg_id: thread!.canvas_msg_id, + thread_id: thread.id!, + body: 'hello', + }, + }); + await command(CreateComment(), { + actor: actors.member, + payload: { + parent_msg_id: thread!.canvas_msg_id, + thread_id: thread.id!, + body: 'world', + }, + }); + const response = await query(GetComments(), { + actor: actors.member, + payload: { + limit: 50, + cursor: 1, + thread_id: thread.id!, + include_reactions: true, + }, + }); + expect(response!.results.length).to.equal(15); + const last = response!.results.at(-1)!; + const stl = response!.results.at(-2)!; + expect(last!.address).to.equal(actors.member.address); + expect(last!.user_id).to.equal(actors.member.user.id); + expect(last!.body).to.equal('world'); + expect(stl!.address).to.equal(actors.admin.address); + expect(stl!.user_id).to.equal(actors.admin.user.id); + expect(stl!.body).to.equal('hello'); + + // get second comment with reactions + const response2 = await query(GetComments(), { + actor: actors.member, + payload: { + limit: 50, + cursor: 1, + thread_id: thread.id!, + comment_id: response?.results.at(1)!.id, + include_reactions: true, + }, + }); + const second = response2!.results.at(0)!; + expect(second!.reactions!.length).to.equal(1); + }); + + test('should get comments without reactions', async () => { + const response = await query(GetComments(), { + actor: actors.member, + payload: { + limit: 50, + cursor: 1, + thread_id: thread.id!, + include_reactions: false, + }, + }); + expect(response!.results.length).to.equal(15); + const last = response!.results.at(-1)!; + const stl = response!.results.at(-2)!; + expect(last!.address).to.equal(actors.member.address); + expect(last!.user_id).to.equal(actors.member.user.id); + expect(last!.body).to.equal('world'); + expect(stl!.address).to.equal(actors.admin.address); + expect(stl!.user_id).to.equal(actors.admin.user.id); + expect(stl!.body).to.equal('hello'); + + // get second comment without reactions + const response2 = await query(GetComments(), { + actor: actors.member, + payload: { + limit: 50, + cursor: 1, + thread_id: thread.id!, + comment_id: response?.results.at(1)!.id, + include_reactions: false, + }, + }); + const second = response2!.results.at(0)!; + expect(second!.reactions).to.be.undefined; + }); }); describe('thread reaction', () => { diff --git a/libs/schemas/src/queries/comment.schemas.ts b/libs/schemas/src/queries/comment.schemas.ts index f46ec773b40..80d3007c929 100644 --- a/libs/schemas/src/queries/comment.schemas.ts +++ b/libs/schemas/src/queries/comment.schemas.ts @@ -2,6 +2,7 @@ import z from 'zod'; import { Comment } from '../entities'; import { PG_INT, zBoolean } from '../utils'; import { PaginatedResultSchema, PaginationParamsSchema } from './pagination'; +import { CommentView, ReactionView } from './thread.schemas'; export const SearchComments = { input: z.object({ @@ -17,16 +18,17 @@ export const SearchComments = { }), }; +export const CommentsView = CommentView.extend({ + reactions: z.array(ReactionView).nullish(), +}); + export const GetComments = { input: PaginationParamsSchema.extend({ thread_id: PG_INT, comment_id: PG_INT.optional(), - include_user: zBoolean.default(false), include_reactions: zBoolean.default(false), }), output: PaginatedResultSchema.extend({ - results: Comment.extend({ - last_edited: z.coerce.date().optional(), - }).array(), + results: z.array(CommentsView), }), };