Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UTOPIA-697: Comment Reply Endpoints #1892

Merged
merged 4 commits into from
Dec 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion src/backend/src/modules/comments/comments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import { CreateCommentDto } from './dto/create-comment.dto';
import { FindCommentsCountDto } from './dto/find-comments-count.dto';
import { FindCommentsDto } from './dto/find-comments.dto';
import { CommentsCountRO } from './ro/comments-count-ro';
import { CommentRO } from './ro/get-comment.ro';
import { CommentRO, ReplyRO } from './ro/get-comment.ro';
import { CreateReplyDto } from './dto/create-reply.dto';

@Controller('comments')
@ApiTags('Comments')
Expand Down Expand Up @@ -63,6 +64,37 @@ export class CommentsController {
);
}

// Create Reply
@Post('reply')
@HttpCode(201)
@ApiOperation({
description: 'Create a new comment reply',
})
@ApiCreatedResponse({
description: 'Successfully created a new comment reply',
})
@ApiForbiddenResponse({
description:
'Failed to create comment reply: User lacks permission to create comment reply to this PIA',
})
@ApiNotFoundResponse({
description:
'Failed to create comment reply: Comment for the id provided not found',
})
@ApiGoneResponse({
description: 'Failed to create comment reply: The PIA is not active',
})
createReply(
@Body() createReplyDto: CreateReplyDto,
@Req() req: IRequest,
): Promise<ReplyRO> {
return this.commentsService.createReply(
createReplyDto,
req.user,
req.userRoles,
);
}

@Get()
@HttpCode(200)
@ApiOperation({
Expand Down Expand Up @@ -144,6 +176,31 @@ export class CommentsController {
return this.commentsService.remove(+id, req.user, req.userRoles);
}

// Delete Reply
@Delete('reply/:id')
@ApiOperation({
description: 'Deletes a comment reply',
})
@ApiOkResponse({
description: 'Successfully deleted a comment reply',
})
@ApiBadRequestResponse({
description: 'Failed to delete comment reply: Invalid request',
})
@ApiForbiddenResponse({
description:
'Failed to delete comment: User lacks permission to delete comment reply of this PIA',
})
@ApiNotFoundResponse({
description: 'Failed to delete comment reply: Comment not found',
})
@ApiGoneResponse({
description: 'Failed to delete comment reply: The PIA is not active',
})
removeReply(@Param('id') id: string, @Req() req: IRequest): Promise<ReplyRO> {
return this.commentsService.removeReply(+id, req.user, req.userRoles);
}

@Post(':id/resolve')
resolve(@Param('id') id: string) {
return this.commentsService.resolve(+id);
Expand Down
151 changes: 149 additions & 2 deletions src/backend/src/modules/comments/comments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,21 @@ import {
} from './ro/comments-count-ro';
import {
CommentRO,
ReplyRO,
getFormattedComment,
getFormattedComments,
getFormattedReply,
} from './ro/get-comment.ro';
import { CreateReplyDto } from './dto/create-reply.dto';
import { ReplyEntity } from './entities/reply.entity';

@Injectable()
export class CommentsService {
constructor(
@InjectRepository(CommentEntity)
private commentRepository: Repository<CommentEntity>,
@InjectRepository(ReplyEntity)
private replyRepository: Repository<ReplyEntity>,
private readonly piaService: PiaIntakeService,
) {}

Expand All @@ -46,6 +52,19 @@ export class CommentsService {
return comment;
}

async findOneReplyBy(
where: FindOptionsWhere<ReplyEntity>,
): Promise<ReplyEntity> {
const reply: ReplyEntity = await this.replyRepository.findOneBy(where);

// If the record is not found, throw an exception
if (!reply) {
throw new NotFoundException();
}

return reply;
}

async create(
createCommentDto: CreateCommentDto,
user: KeycloakUser,
Expand Down Expand Up @@ -101,6 +120,68 @@ export class CommentsService {
return getFormattedComment(comment);
}

async createReply(
createReplyDto: CreateReplyDto,
user: KeycloakUser,
userRoles: Array<RolesEnum>,
): Promise<ReplyRO> {
// extract user input dto
const { commentId, text } = createReplyDto;
const parentComment = await this.findOneBy({ id: commentId });

// validate comment exists
if (!parentComment)
throw new NotFoundException({
commentId,
message: 'No comment found by the id provided.',
});

// validate access to PIA. Throw error if not
const pia = await this.piaService.validatePiaAccess(
parentComment.piaId,
user,
userRoles,
);

// validate blank text
if ((text || '').trim() === '') {
throw new ForbiddenException({
piaId: pia.id,
message:
'Forbidden: Failed to add comment reply to the PIA. Text cannot be blank.',
});
}

// check if adding comments to this PIA allowed
const isActionAllowed = checkUpdatePermissions({
status: pia?.status,
entityType: 'comment',
entityAction: 'add',
});

if (!isActionAllowed) {
throw new ForbiddenException({
piaId: pia.id,
message: 'Forbidden: Failed to add comment reply to the PIA',
});
}

// create resource
const reply: ReplyEntity = await this.replyRepository.save({
comment: parentComment,
text,
isResolved: false,
createdByGuid: user.idir_user_guid,
createdByUsername: user.idir_username,
updatedByGuid: user.idir_user_guid,
updatedByUsername: user.idir_username,
createdByDisplayName: user.display_name,
});

// return formatted object
return getFormattedReply(reply);
}

async findByPiaAndPath(
piaId: number,
path: AllowedCommentPaths,
Expand All @@ -121,8 +202,21 @@ export class CommentsService {
order: { createdAt: 1 },
});

// Add replies
const commentsWithReplies = [];
for (const comment of comments) {
// Fetch reply
const replies = await this.replyRepository.find({
where: {
commentId: comment.id,
},
order: { createdAt: 1 },
});
commentsWithReplies.push({ ...comment, replies });
}

// return formatted objects
return getFormattedComments(comments);
return getFormattedComments(commentsWithReplies);
}

async findCountByPia(
Expand Down Expand Up @@ -157,7 +251,7 @@ export class CommentsService {
// if the comment person who created the comment is not the one deleting, throw error
if (user.idir_user_guid !== comment.createdByGuid) {
throw new ForbiddenException({
message: "Forbidden: You're are not authorized to remoe this comment",
message: "Forbidden: You're are not authorized to remove this comment",
});
}

Expand Down Expand Up @@ -197,6 +291,59 @@ export class CommentsService {
return getFormattedComment(updatedComment);
}

// Remove Reply
async removeReply(
id: number,
user: KeycloakUser,
userRoles: Array<RolesEnum>,
): Promise<ReplyRO> {
// fetch reply
const reply = await this.findOneReplyBy({ id });
const parentComment = await this.findOneBy({ id: reply.commentId });

// if the comment person who created the comment is not the one deleting, throw error
if (user.idir_user_guid !== reply.createdByGuid) {
throw new ForbiddenException({
message: "Forbidden: You're are not authorized to remove this reply",
});
}

// validate access to PIA. Throw error if not
const pia = await this.piaService.validatePiaAccess(
parentComment.piaId,
user,
userRoles,
);

// check if deleting comments to this PIA allowed
const isActionAllowed = checkUpdatePermissions({
status: pia?.status,
entityType: 'comment',
entityAction: 'remove',
});

if (!isActionAllowed) {
throw new ForbiddenException({
piaId: pia.id,
message: 'Forbidden: Failed to remove comment reply of the PIA',
});
}

// throw error if comment already deleted
if (reply.isActive === false) {
throw new BadRequestException('Reply already deleted');
}

// soft delete
const updatedReply = await this.replyRepository.save({
...reply,
isActive: false,
text: null,
});

return getFormattedReply(updatedReply);
}

// TODO
async resolve(id: number) {
return `This is a resolve method yet to be developed for comment ${id}`;
Expand Down
20 changes: 20 additions & 0 deletions src/backend/src/modules/comments/dto/create-reply.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { IsNumber, IsString } from '@nestjs/class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class CreateReplyDto {
@IsNumber()
@ApiProperty({
type: Number,
required: true,
example: 1,
})
commentId: number;

@IsString()
@ApiProperty({
type: String,
required: true,
example: 'This is a sample comment',
})
text: string;
}
11 changes: 11 additions & 0 deletions src/backend/src/modules/comments/ro/get-comment.ro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,29 @@ import {
omitBaseKeys,
} from 'src/common/helpers/base-helper';
import { CommentEntity } from '../entities/comment.entity';
import { ReplyEntity } from '../entities/reply.entity';

export const excludeCommentKeys = { pia: true };
export const excludeReplyKeys = { comment: true };

export type CommentRO = Omit<
CommentEntity,
keyof ExcludeBaseSelection | keyof typeof excludeCommentKeys
>;

export type ReplyRO = Omit<
ReplyEntity,
keyof ExcludeBaseSelection | keyof typeof excludeReplyKeys
>;

export const getFormattedComment = (comment: CommentEntity) => {
return omitBaseKeys<CommentRO>(comment, Object.keys(excludeCommentKeys));
};

export const getFormattedReply = (reply: ReplyEntity) => {
return omitBaseKeys<ReplyRO>(reply, Object.keys(excludeReplyKeys));
};

export const getFormattedComments = (comments: CommentEntity[]) => {
return comments.map((comment) => getFormattedComment(comment));
};
Loading
Loading