From 84f6cc2fab07f2f3982b80d1af65e1838b7c827d Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Tue, 17 Dec 2024 20:20:43 -0500 Subject: [PATCH 1/4] add address --- libs/model/src/comment/GetComments.query.ts | 15 +++---- .../test/thread/thread-lifecycle.spec.ts | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/libs/model/src/comment/GetComments.query.ts b/libs/model/src/comment/GetComments.query.ts index 3ce65199f03..61f4f294b71 100644 --- a/libs/model/src/comment/GetComments.query.ts +++ b/libs/model/src/comment/GetComments.query.ts @@ -13,14 +13,15 @@ export function GetComments(): Query { const { thread_id, comment_id, include_user, include_reactions } = payload; - const includeArray = []; + const include = []; if (include_user) { - includeArray.push({ + include.push({ model: models.Address, + required: true, + attributes: ['address', 'last_active'], include: [ { model: models.User, - as: 'User', required: true, attributes: ['id', 'profile'], }, @@ -28,20 +29,19 @@ export function GetComments(): Query { }); } + // TODO: sequelize is broken here, find workaround if (include_reactions) { - includeArray.push({ + include.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'], }, @@ -53,7 +53,8 @@ export function GetComments(): Query { const { count, rows: comments } = await models.Comment.findAndCountAll({ where: removeUndefined({ thread_id, id: comment_id }), - include: includeArray, + attributes: { exclude: ['search'] }, + include, ...formatSequelizePagination(payload), paranoid: false, }); diff --git a/libs/model/test/thread/thread-lifecycle.spec.ts b/libs/model/test/thread/thread-lifecycle.spec.ts index 641b96f5905..49b673fe665 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,44 @@ describe('Thread lifecycle', () => { }), ).rejects.toThrowError(InvalidActor); }); + + test('should get comments', 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_user: true, + 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?.address).to.equal(actors.member.address); + expect(last!.Address?.User?.id).to.equal(actors.member.user.id); + expect(last!.body).to.equal('world'); + expect(stl!.Address?.address).to.equal(actors.admin.address); + expect(stl!.Address?.User?.id).to.equal(actors.admin.user.id); + expect(stl!.body).to.equal('hello'); + }); }); describe('thread reaction', () => { From 253e389f6e34c443b7b2769aafdc09a87048c65c Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Thu, 19 Dec 2024 15:54:13 -0500 Subject: [PATCH 2/4] replace query with sql --- libs/model/src/comment/GetComments.query.ts | 150 ++++++++++++------ .../model/src/utils/sanitizeDeletedComment.ts | 7 +- .../test/thread/thread-lifecycle.spec.ts | 60 ++++++- libs/schemas/src/queries/comment.schemas.ts | 10 +- 4 files changed, 163 insertions(+), 64 deletions(-) diff --git a/libs/model/src/comment/GetComments.query.ts b/libs/model/src/comment/GetComments.query.ts index 61f4f294b71..f3845fca88c 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,64 +12,110 @@ 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 include = []; - if (include_user) { - include.push({ - model: models.Address, - required: true, - attributes: ['address', 'last_active'], - include: [ - { - model: models.User, - required: true, - attributes: ['id', 'profile'], - }, - ], - }); - } - - // TODO: sequelize is broken here, find workaround - if (include_reactions) { - include.push({ - model: models.Reaction, - as: 'reactions', - include: [ - { - model: models.Address, - required: true, - attributes: ['address', 'last_active'], - include: [ - { - model: models.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 }), - attributes: { exclude: ['search'] }, - include, - ...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 total_count = comments?.length ? comments!.at(0)!.total_count : 0; 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, + total_count, + 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 49b673fe665..981eeaf6d00 100644 --- a/libs/model/test/thread/thread-lifecycle.spec.ts +++ b/libs/model/test/thread/thread-lifecycle.spec.ts @@ -847,7 +847,7 @@ describe('Thread lifecycle', () => { ).rejects.toThrowError(InvalidActor); }); - test('should get comments', async () => { + test('should get comments with reactions', async () => { await command(CreateComment(), { actor: actors.admin, payload: { @@ -870,19 +870,67 @@ describe('Thread lifecycle', () => { limit: 50, cursor: 1, thread_id: thread.id!, - include_user: true, + 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?.address).to.equal(actors.member.address); - expect(last!.Address?.User?.id).to.equal(actors.member.user.id); + 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?.address).to.equal(actors.admin.address); - expect(stl!.Address?.User?.id).to.equal(actors.admin.user.id); + 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; }); }); 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), }), }; From 131e0cd5ff0c6246ff8f2688a5bf4a74115d9235 Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Thu, 19 Dec 2024 15:57:18 -0500 Subject: [PATCH 3/4] fix lint --- libs/model/test/thread/thread-lifecycle.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/model/test/thread/thread-lifecycle.spec.ts b/libs/model/test/thread/thread-lifecycle.spec.ts index 981eeaf6d00..0320c6d1b79 100644 --- a/libs/model/test/thread/thread-lifecycle.spec.ts +++ b/libs/model/test/thread/thread-lifecycle.spec.ts @@ -894,7 +894,7 @@ describe('Thread lifecycle', () => { include_reactions: true, }, }); - const second = response2?.results.at(0)!; + const second = response2!.results.at(0)!; expect(second!.reactions!.length).to.equal(1); }); @@ -929,7 +929,7 @@ describe('Thread lifecycle', () => { include_reactions: false, }, }); - const second = response2?.results.at(0)!; + const second = response2!.results.at(0)!; expect(second!.reactions).to.be.undefined; }); }); From c0d6eb4d8cba2d7f014ae7b8f3d026fbdf0b2bfb Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Thu, 19 Dec 2024 16:14:45 -0500 Subject: [PATCH 4/4] fix lint --- libs/model/src/comment/GetComments.query.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libs/model/src/comment/GetComments.query.ts b/libs/model/src/comment/GetComments.query.ts index f3845fca88c..b6a4a841280 100644 --- a/libs/model/src/comment/GetComments.query.ts +++ b/libs/model/src/comment/GetComments.query.ts @@ -100,7 +100,6 @@ LIMIT :limit OFFSET :offset; type: QueryTypes.SELECT, }); - const total_count = comments?.length ? comments!.at(0)!.total_count : 0; const sanitizedComments = comments.map((c) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { total_count, ...rest } = c; @@ -113,7 +112,7 @@ LIMIT :limit OFFSET :offset; return schemas.buildPaginatedResponse( sanitizedComments, - total_count, + comments?.length ? comments!.at(0)!.total_count : 0, payload, ); },