Skip to content

Commit

Permalink
Merge pull request #797 from desci-labs/comment-reply
Browse files Browse the repository at this point in the history
Comment reply
  • Loading branch information
shadrach-tayo authored Feb 18, 2025
2 parents 458b167 + 333c1f2 commit c74370d
Show file tree
Hide file tree
Showing 8 changed files with 123 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Annotation" ADD COLUMN "replyToId" INTEGER;

-- AddForeignKey
ALTER TABLE "Annotation" ADD CONSTRAINT "Annotation_replyToId_fkey" FOREIGN KEY ("replyToId") REFERENCES "Annotation"("id") ON DELETE SET NULL ON UPDATE CASCADE;
3 changes: 3 additions & 0 deletions desci-server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,9 @@ model Annotation {
createdAt DateTime?
updatedAt DateTime? @updatedAt
CommentVote CommentVote[]
replies Annotation[] @relation("CommentReply")
replyTo Annotation? @relation("CommentReply", fields: [replyToId], references: [id])
replyToId Int?
}

//An emoji reaction to a node
Expand Down
21 changes: 14 additions & 7 deletions desci-server/src/controllers/attestations/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { asyncMap, ensureUuidEndsWithDot } from '../../utils.js';

export const getAttestationComments = async (req: RequestWithUser, res: Response, next: NextFunction) => {
const { claimId } = req.params;
const { cursor, limit } = req.query as zod.infer<typeof getAttestationCommentsSchema>['query'];
const { cursor, limit, replyTo } = req.query as zod.infer<typeof getAttestationCommentsSchema>['query'];
const claim = await attestationService.findClaimById(parseInt(claimId));
if (!claim) throw new NotFoundError('Claim not found');

Expand All @@ -30,6 +30,7 @@ export const getAttestationComments = async (req: RequestWithUser, res: Response
const comments = await attestationService.getAllClaimComments(
{
nodeAttestationId: claim.id,
...(replyTo ? { replyToId: { equals: replyTo } } : { replyToId: null }),
},
{ cursor: cursor ? parseInt(cursor.toString()) : undefined, limit: parseInt(limit.toString()) },
);
Expand All @@ -48,6 +49,7 @@ export const getAttestationComments = async (req: RequestWithUser, res: Response
meta: {
upvotes,
downvotes,
replyCount: comment._count.replies,
isUpvoted: vote?.type === VoteType.Yes,
isDownVoted: vote?.type === VoteType.No,
},
Expand Down Expand Up @@ -99,8 +101,11 @@ export const removeComment = async (req: Request<RemoveCommentBody, any, any>, r

type AddCommentBody = zod.infer<typeof createCommentSchema>;

export const addComment = async (req: Request<any, any, AddCommentBody['body']>, res: Response<AddCommentResponse>) => {
const { authorId, claimId, body, highlights, links, uuid, visible } = req.body;
export const postComment = async (
req: Request<any, any, AddCommentBody['body']>,
res: Response<AddCommentResponse>,
) => {
const { authorId, claimId, body, highlights, links, uuid, visible, replyTo } = req.body;
const user = (req as any).user;

if (parseInt(authorId.toString()) !== user.id) throw new ForbiddenError();
Expand Down Expand Up @@ -129,12 +134,13 @@ export const addComment = async (req: Request<any, any, AddCommentBody['body']>,
});
logger.info({ processedHighlights }, 'processedHighlights');
annotation = await attestationService.createHighlight({
claimId: claimId && parseInt(claimId.toString()),
authorId: user.id,
comment: body,
links,
highlights: processedHighlights as unknown as HighlightBlock[],
visible,
replyTo,
comment: body,
authorId: user.id,
claimId: claimId && parseInt(claimId.toString()),
highlights: processedHighlights as unknown as HighlightBlock[],
...(uuid && { uuid: ensureUuidEndsWithDot(uuid) }),
});
await saveInteraction(req, ActionType.ADD_COMMENT, { annotationId: annotation.id, claimId, authorId });
Expand All @@ -145,6 +151,7 @@ export const addComment = async (req: Request<any, any, AddCommentBody['body']>,
comment: body,
links,
visible,
replyTo,
...(uuid && { uuid: ensureUuidEndsWithDot(uuid) }),
});
}
Expand Down
7 changes: 6 additions & 1 deletion desci-server/src/controllers/nodes/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,22 @@ import { asyncMap, ensureUuidEndsWithDot } from '../../utils.js';
const parentLogger = logger.child({ module: 'Comments' });
export const getGeneralComments = async (req: RequestWithNode, res: Response, _next: NextFunction) => {
const { uuid } = req.params as z.infer<typeof getCommentsSchema>['params'];
const { cursor, limit } = req.query as z.infer<typeof getCommentsSchema>['query'];
const { cursor, limit, replyTo } = req.query as z.infer<typeof getCommentsSchema>['query'];
const node = await prisma.node.findFirst({ where: { uuid: ensureUuidEndsWithDot(uuid) } });
if (!node) throw new NotFoundError("Can't comment on unknown research object");

const restrictVisibility = node.ownerId !== req?.user?.id;

const count = await attestationService.countComments({
uuid: ensureUuidEndsWithDot(uuid),
...(replyTo ? { replyToId: { equals: replyTo } } : { replyToId: null }),
...(restrictVisibility && { visible: true }),
});

const data = await attestationService.getComments(
{
uuid: ensureUuidEndsWithDot(uuid),
...(replyTo ? { replyToId: { equals: replyTo } } : { replyToId: null }),
...(restrictVisibility && { visible: true }),
},
{ cursor: cursor ? parseInt(cursor.toString()) : undefined, limit: limit ? parseInt(limit.toString()) : undefined },
Expand All @@ -38,12 +40,15 @@ export const getGeneralComments = async (req: RequestWithNode, res: Response, _n
const upvotes = await attestationService.getCommentUpvotes(comment.id);
const downvotes = await attestationService.getCommentDownvotes(comment.id);
const vote = await attestationService.getUserCommentVote(req.user.id, comment.id);
const count = comment._count;
delete comment._count;
return {
...comment,
highlights: comment.highlights.map((h) => JSON.parse(h as string)),
meta: {
upvotes,
downvotes,
replyCount: count.replies,
isUpvoted: vote?.type === VoteType.Yes,
isDownVoted: vote?.type === VoteType.No,
},
Expand Down
4 changes: 2 additions & 2 deletions desci-server/src/routes/v1/attestations/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Router } from 'express';

import { claimAttestation, claimEntryRequirements, removeClaim } from '../../../controllers/attestations/claims.js';
import { addComment, getAttestationComments, removeComment } from '../../../controllers/attestations/comments.js';
import { postComment, getAttestationComments, removeComment } from '../../../controllers/attestations/comments.js';
import { addReaction, getAttestationReactions, removeReaction } from '../../../controllers/attestations/reactions.js';
import {
getAllRecommendations,
Expand Down Expand Up @@ -64,7 +64,7 @@ router.post('/claim', [ensureUser, validate(claimAttestationSchema)], asyncHandl
router.post('/unclaim', [ensureUser, validate(removeClaimSchema)], asyncHandler(removeClaim));
router.post('/claimAll', [ensureUser, validate(claimEntryAttestationsSchema)], asyncHandler(claimEntryRequirements));

router.post('/comments', [ensureUser, validate(createCommentSchema)], asyncHandler(addComment));
router.post('/comments', [ensureUser, validate(createCommentSchema)], asyncHandler(postComment));
router.post('/reaction', [ensureUser, validate(addReactionSchema)], asyncHandler(addReaction));
router.post('/verification', [ensureUser, validate(addVerificationSchema)], asyncHandler(addVerification));

Expand Down
3 changes: 3 additions & 0 deletions desci-server/src/routes/v1/attestations/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const getAttestationCommentsSchema = z.object({
query: z.object({
cursor: z.coerce.number().optional(),
limit: z.coerce.number().optional().default(20),
replyTo: z.coerce.number().optional(),
}),
});

Expand All @@ -48,6 +49,7 @@ export const getCommentsSchema = z.object({
query: z.object({
cursor: z.coerce.number().optional(),
limit: z.coerce.number().optional(),
replyTo: z.coerce.number().optional(),
}),
});

Expand Down Expand Up @@ -164,6 +166,7 @@ const commentSchema = z
highlights: z.array(highlightBlockSchema).optional(),
uuid: z.string(),
visible: z.boolean().default(true),
replyTo: z.coerce.number().optional(),
})
.refine((comment) => comment.body?.length > 0 || !!comment?.highlights?.length, {
message: 'Either Comment body or highlights is required',
Expand Down
12 changes: 8 additions & 4 deletions desci-server/src/services/Attestation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,13 +659,15 @@ export class AttestationService {
links,
uuid,
visible = true,
replyTo,
}: {
claimId?: number;
authorId: number;
comment: string;
links: string[];
uuid?: string;
visible: boolean;
replyTo?: number;
}) {
assert(authorId > 0, 'Error: authorId is zero');
claimId && assert(claimId > 0, 'Error: claimId is zero');
Expand All @@ -688,6 +690,7 @@ export class AttestationService {
links,
uuid,
visible,
replyToId: replyTo,
createdAt: new Date(),
};
return this.createAnnotation(data);
Expand Down Expand Up @@ -749,6 +752,7 @@ export class AttestationService {
links,
uuid,
visible,
replyTo,
}: {
claimId: number;
authorId: number;
Expand All @@ -757,6 +761,7 @@ export class AttestationService {
highlights: HighlightBlock[];
uuid?: string;
visible: boolean;
replyTo?: number;
}) {
assert(authorId > 0, 'Error: authorId is zero');
claimId && assert(claimId > 0, 'Error: claimId is zero');
Expand All @@ -781,6 +786,7 @@ export class AttestationService {
uuid,
visible,
createdAt: new Date(),
replyToId: replyTo,
};
return this.createAnnotation(data);
}
Expand Down Expand Up @@ -918,7 +924,7 @@ export class AttestationService {
where: filter,
include: {
_count: {
select: { CommentVote: true },
select: { CommentVote: true, replies: true },
},
CommentVote: { select: { id: true, type: true } },
author: { select: { id: true, name: true, orcid: true } },
Expand All @@ -941,10 +947,8 @@ export class AttestationService {
return prisma.annotation.findMany({
where: filter,
include: {
_count: { select: { replies: true } },
author: { select: { id: true, name: true, orcid: true } },
// CommentVote: {
// select: { id: true, userId: true, annotationId: true, type: true },
// },
attestation: {
include: {
attestationVersion: { select: { name: true, description: true, image_url: true, createdAt: true } },
Expand Down
82 changes: 82 additions & 0 deletions desci-server/test/integration/Attestation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,9 +846,14 @@ describe('Attestations Service', async () => {
let author: User;
let comment: Annotation;

let author1: User;
let reply: Annotation;

before(async () => {
node = nodes[0];
author = users[0];
author1 = users[1];

assert(node.uuid);
const versions = await attestationService.getAttestationVersions(reproducibilityAttestation.id);
attestationVersion = versions[versions.length - 1];
Expand Down Expand Up @@ -942,6 +947,83 @@ describe('Attestations Service', async () => {
expect(editedComment.body).to.be.equal('edit comment via api');
expect(editedComment.links[0]).to.be.equal('https://desci.com');
});

it('should reply a comment', async () => {
comment = await attestationService.createComment({
links: [],
claimId: claim.id,
authorId: users[1].id,
comment: 'Old comment to be edited',
visible: true,
});

const reply = await attestationService.createComment({
authorId: users[2].id,
replyTo: comment.id,
links: [],
claimId: claim.id,
comment: 'Reply to Old comment to be edited',
visible: true,
});
expect(reply.body).to.be.equal('Reply to Old comment to be edited');
expect(reply.replyToId).to.be.equal(comment.id);
});

// should post comments and reply, should validate comments length, getComments Api, replyCount and pull reply via api
it('should create and reply a comment', async () => {
const authorJwtToken = jwt.sign({ email: author.email }, process.env.JWT_SECRET!, {
expiresIn: '1y',
});
const authorJwtHeader = `Bearer ${authorJwtToken}`;

// send create a comment request
let res = await request(app).post(`/v1/attestations/comments`).set('authorization', authorJwtHeader).send({
authorId: author.id,
body: 'post comment with reply',
links: [],
uuid: node.uuid,
visible: true,
});
expect(res.statusCode).to.equal(200);
console.log('comment', res.body.data);
comment = res.body.data as Annotation;
expect(comment.body).to.equal('post comment with reply');

const author1JwtToken = jwt.sign({ email: author1.email }, process.env.JWT_SECRET!, {
expiresIn: '1y',
});
const author1JwtHeader = `Bearer ${author1JwtToken}`;
// send reply to a comment request
res = await request(app).post(`/v1/attestations/comments`).set('authorization', author1JwtHeader).send({
authorId: author1.id,
body: 'reply to post comment with reply',
links: [],
uuid: node.uuid,
visible: true,
replyTo: comment.id,
});
reply = res.body.data as Annotation;

expect(res.statusCode).to.equal(200);
expect(reply.replyToId).to.equal(comment.id);

// check comment
res = await request(app).get(`/v1/nodes/${node.uuid}/comments`).set('authorization', authorJwtHeader).send();
expect(res.statusCode).to.equal(200);
expect(res.body.data.count).to.be.equal(1);
const data = (await res.body.data.comments) as (Annotation & {
meta: {
upvotes: number;
downvotes: number;
replyCount: number;
isUpvoted: boolean;
isDownVoted: boolean;
};
})[];
console.log('commentsss', data);
const parentComment = data.find((c) => c.id === comment.id);
expect(parentComment?.meta.replyCount).to.be.equal(1);
});
});

describe('Node Attestation Verification', async () => {
Expand Down

0 comments on commit c74370d

Please sign in to comment.