diff --git a/migration/etuutt_old/make-migration.ts b/migration/etuutt_old/make-migration.ts index 28a81590..7474b4be 100644 --- a/migration/etuutt_old/make-migration.ts +++ b/migration/etuutt_old/make-migration.ts @@ -289,7 +289,6 @@ const prisma = _prisma.$extends({ body, createdAt, updatedAt, - isValid, ue, semesterCode, }: { @@ -316,7 +315,6 @@ const prisma = _prisma.$extends({ isAnonymous: true, createdAt, updatedAt, - validatedAt: isValid ? updatedAt : null, ueof: { connect: { code: codes.ueof } }, semester: { connect: { code: semesterCode } }, }, @@ -324,15 +322,11 @@ const prisma = _prisma.$extends({ operation: 'created', }; } - if ( - comment.body !== body || - comment.updatedAt !== updatedAt || - (comment.validatedAt === null ? 0 : 1) !== isValid - ) { + if (comment.body !== body || comment.updatedAt !== updatedAt) { return { data: await _prisma.ueComment.update({ where: { id: comment.id }, - data: { body, updatedAt, validatedAt: isValid ? updatedAt : null }, + data: { body, updatedAt }, }), operation: 'updated', }; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee6bef46..69c22a9d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -340,18 +340,16 @@ model UeAnnalReportReason { } model UeComment { - id String @id @default(uuid()) - body String @db.Text - isAnonymous Boolean - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) + id String @id @default(uuid()) + body String @db.Text + isAnonymous Boolean + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) // Removed @updatedAt because the property is used to display the last datetime the content of the comment was altered on - deletedAt DateTime? - validatedAt DateTime? - authorId String? - lastValidatedBody String? - semesterId String - ueofCode String + deletedAt DateTime? + authorId String? + semesterId String + ueofCode String author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) semester Semester @relation(fields: [semesterId], references: [code]) @@ -377,13 +375,14 @@ model UeCommentReply { } model UeCommentReport { - id String @id @default(uuid()) - body String @db.Text - createdAt DateTime @default(now()) - mitigated Boolean @default(false) - commentId String - reasonId String - userId String + id String @id @default(uuid()) + body String @db.Text + createdAt DateTime @default(now()) + mitigated Boolean @default(false) + commentId String + reasonId String + userId String + reportedBody String comment UeComment @relation(fields: [commentId], references: [id], onDelete: Cascade) reason UeCommentReportReason @relation(fields: [reasonId], references: [name], onDelete: Cascade) @@ -393,16 +392,17 @@ model UeCommentReport { } model UeCommentReplyReport { - id String @id @default(uuid()) - body String @db.Text - createdAt DateTime @default(now()) - mitigated Boolean @default(false) - reasonId String - replyId String - userId String + id String @id @default(uuid()) + body String @db.Text + createdAt DateTime @default(now()) + mitigated Boolean @default(false) + replyId String + reasonId String + userId String + reportedBody String - reason UeCommentReportReason @relation(fields: [reasonId], references: [name], onDelete: Cascade) reply UeCommentReply @relation(fields: [replyId], references: [id], onDelete: Cascade) + reason UeCommentReportReason @relation(fields: [reasonId], references: [name], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, replyId, reasonId]) // Prevent from spam diff --git a/src/exceptions.ts b/src/exceptions.ts index 28c17707..08af2b97 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -84,6 +84,8 @@ export const enum ERROR_CODE { NO_SUCH_UE_AT_SEMESTER = 4414, NO_SUCH_ASSO_ROLE = 4415, NO_SUCH_ASSO_MEMBERSHIP = 4416, + NO_SUCH_REPORT = 4417, + NO_SUCH_REPORT_REASON = 4418, ANNAL_ALREADY_UPLOADED = 4901, RESOURCE_UNAVAILABLE = 4902, RESOURCE_INVALID_TYPE = 4903, @@ -382,6 +384,14 @@ export const ErrorData = Object.freeze({ message: 'No such membership in asso: %', httpCode: HttpStatus.NOT_FOUND, }, + [ERROR_CODE.NO_SUCH_REPORT]: { + message: 'The report does not exist', + httpCode: HttpStatus.NOT_FOUND, + }, + [ERROR_CODE.NO_SUCH_REPORT_REASON]: { + message: 'The report reason does not exist', + httpCode: HttpStatus.NOT_FOUND, + }, [ERROR_CODE.ANNAL_ALREADY_UPLOADED]: { message: 'A file has alreay been uploaded for this annal', httpCode: HttpStatus.CONFLICT, diff --git a/src/prisma/types.ts b/src/prisma/types.ts index c29ba04c..e6527486 100644 --- a/src/prisma/types.ts +++ b/src/prisma/types.ts @@ -21,6 +21,8 @@ export { UeComment as RawUeComment, UeCommentReply as RawUeCommentReply, UeCommentUpvote as RawUeCommentUpvote, + UeCommentReport as RawUeCommentReport, + UeCommentReplyReport as RawUeCommentReplyReport, UeAnnalType as RawAnnalType, UeAnnal as RawAnnal, UeCourse as RawUeCourse, diff --git a/src/ue/annals/annals.controller.ts b/src/ue/annals/annals.controller.ts index 67a47720..e0c1776c 100644 --- a/src/ue/annals/annals.controller.ts +++ b/src/ue/annals/annals.controller.ts @@ -6,7 +6,6 @@ import { UUIDParam } from '../../app.pipe'; import { GetUser, RequireApiPermission } from '../../auth/decorator'; import { AppException, ERROR_CODE } from '../../exceptions'; import { FileSize, MulterWithMime, UploadRoute, UserFile } from '../../upload.interceptor'; -import { CommentStatus } from '../comments/interfaces/comment.interface'; import { CreateAnnalReqDto } from './dto/req/create-annal-req.dto'; import { UpdateAnnalReqDto } from './dto/req/update-annal-req.dto'; import { User } from '../../users/interfaces/user.interface'; @@ -19,6 +18,7 @@ import UeAnnalMetadataResDto from './dto/res/ue-annal-metadata-res.dto'; import { GetPermissions } from '../../auth/decorator/get-permissions.decorator'; import { Permission } from '@prisma/client'; import { omit, PermissionManager } from '../../utils'; +import { AnnalStatus } from './interfaces/annal.interface'; @Controller('ue/annals') @ApiTags('Annal') @@ -131,7 +131,7 @@ export class AnnalsController { throw new AppException(ERROR_CODE.NOT_ANNAL_SENDER); if ( (await this.annalsService.getUeAnnal(annalId, user.id, permissions.can(Permission.API_MODERATE_ANNALS))) - .status !== CommentStatus.PROCESSING + .status !== AnnalStatus.PROCESSING ) throw new AppException(ERROR_CODE.ANNAL_ALREADY_UPLOADED); return omit(await this.annalsService.uploadAnnalFile(await file, annalId, rotate), 'ueof'); diff --git a/src/ue/annals/annals.service.ts b/src/ue/annals/annals.service.ts index b68f2a0b..5ce6c935 100644 --- a/src/ue/annals/annals.service.ts +++ b/src/ue/annals/annals.service.ts @@ -220,6 +220,7 @@ export class AnnalsService { annal.semesterId.slice(0, 1) === 'A' ? 1 : 0, // P should be listed before A annal.type.name, annal.createdAt.getTime(), + annal.id, ]); } diff --git a/src/ue/annals/interfaces/annal.interface.ts b/src/ue/annals/interfaces/annal.interface.ts index ac00e9d4..72d2a95d 100644 --- a/src/ue/annals/interfaces/annal.interface.ts +++ b/src/ue/annals/interfaces/annal.interface.ts @@ -1,6 +1,5 @@ import { Prisma, PrismaClient } from '@prisma/client'; import { omit } from '../../../utils'; -import { CommentStatus } from '../../comments/interfaces/comment.interface'; import { generateCustomModel } from '../../../prisma/prisma.service'; const UE_ANNAL_SELECT_FILTER = { @@ -31,7 +30,7 @@ const UE_ANNAL_SELECT_FILTER = { type UnformattedUeAnnal = Prisma.UeAnnalGetPayload; export type UeAnnalFile = Omit & { - status: CommentStatus; + status: AnnalStatus; }; export function generateCustomUeAnnalModel(prisma: PrismaClient) { @@ -42,8 +41,15 @@ export function formatAnnal(_: PrismaClient, annal: UnformattedUeAnnal): UeAnnal return { ...omit(annal, 'deletedAt', 'validatedAt', 'uploadComplete'), status: - (annal.deletedAt && CommentStatus.DELETED) | - (annal.validatedAt && CommentStatus.VALIDATED) | - (!annal.uploadComplete && CommentStatus.PROCESSING), + (annal.deletedAt && AnnalStatus.DELETED) | + (annal.validatedAt && AnnalStatus.VALIDATED) | + (!annal.uploadComplete && AnnalStatus.PROCESSING), }; } + +export const enum AnnalStatus { + UNVERIFIED = 0b000, // For typing only + VALIDATED = 0b001, + PROCESSING = 0b010, + DELETED = 0b100, +} diff --git a/src/ue/comments/comments.controller.ts b/src/ue/comments/comments.controller.ts index 1b5c86cb..00d212ba 100644 --- a/src/ue/comments/comments.controller.ts +++ b/src/ue/comments/comments.controller.ts @@ -1,22 +1,26 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Patch, Post, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Permission } from '@prisma/client'; +import { ApiAppErrorResponse, paginatedResponseDto } from '../../app.dto'; import { UUIDParam } from '../../app.pipe'; import { GetUser, RequireApiPermission } from '../../auth/decorator'; +import { GetPermissions } from '../../auth/decorator/get-permissions.decorator'; import { AppException, ERROR_CODE } from '../../exceptions'; +import { User } from '../../users/interfaces/user.interface'; +import { PermissionManager } from '../../utils'; +import { UeService } from '../ue.service'; +import { CommentsService } from './comments.service'; import UeCommentPostReqDto from './dto/req/ue-comment-post-req.dto'; -import CommentReplyReqDto from './dto/req/ue-comment-reply-req.dto'; +import UeCommentReplyReqDto from './dto/req/ue-comment-reply-req.dto'; +import UeCommentReportReqDto from './dto/req/ue-comment-report-req.dto'; import UeCommentUpdateReqDto from './dto/req/ue-comment-update-req.dto'; import GetUeCommentsReqDto from './dto/req/ue-get-comments-req.dto'; -import { UeService } from '../ue.service'; -import { User } from '../../users/interfaces/user.interface'; -import { CommentsService } from './comments.service'; +import GetReportedCommentsReqDto from './dto/req/ue-get-reported-comments-req.dto'; +import UeCommentReplyResDto from './dto/res/ue-comment-reply-res.dto'; +import UeCommentReportReasonResDto from './dto/res/ue-comment-report-reason-res.dto'; +import UeCommentReportResDto from './dto/res/ue-comment-report-res.dto'; import UeCommentResDto from './dto/res/ue-comment-res.dto'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { ApiAppErrorResponse, paginatedResponseDto } from '../../app.dto'; import { UeCommentUpvoteResDto$False, UeCommentUpvoteResDto$True } from './dto/res/ue-comment-upvote-res.dto'; -import UeCommentReplyResDto from './dto/res/ue-comment-reply-res.dto'; -import { Permission } from '@prisma/client'; -import { GetPermissions } from '../../auth/decorator/get-permissions.decorator'; -import { PermissionManager } from '../../utils'; @Controller('ue/comments') @ApiTags('UE Comment') @@ -69,6 +73,33 @@ export class CommentsController { return this.commentsService.createComment(body, user.id); } + @Get('/reports') + @RequireApiPermission('API_MODERATE_COMMENTS') + @ApiOperation({ description: 'Get all reported comments or comments with reported replies. This route is paginated' }) + @ApiOkResponse({ type: paginatedResponseDto(UeCommentResDto) }) + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, + "Thrown when the user doesn't have enough permissions", + ) + getReportedComments( + @GetUser() user: User, + @Query() dto: GetReportedCommentsReqDto, + ): Promise> { + return this.commentsService.getCommentsWithReports(user.id, dto); + } + + @Get('/reports/reasons') + @RequireApiPermission('API_SEE_OPINIONS_UE') + @ApiOperation({ description: 'Get the list of all possible report reasons' }) + @ApiOkResponse({ type: UeCommentReportReasonResDto, isArray: true }) + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, + "Thrown when the user doesn't have enough permissions", + ) + getReportReasons(): Promise { + return this.commentsService.getCommentReportReason(); + } + // TODO : en vrai la route GET /ue/comments renvoie les mêmes infos nan ? :sweat_smile: @Get(':commentId') @RequireApiPermission('API_SEE_OPINIONS_UE') @@ -105,7 +136,7 @@ export class CommentsController { @GetPermissions() permissions: PermissionManager, ): Promise { const isCommentModerator = permissions.can(Permission.API_MODERATE_COMMENTS); - if (!(await this.commentsService.doesCommentExist(commentId, user.id, isCommentModerator, isCommentModerator))) + if (!(await this.commentsService.doesCommentExist(commentId, user.id, isCommentModerator))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); if (isCommentModerator || (await this.commentsService.isUserCommentAuthor(user.id, commentId))) return this.commentsService.updateComment(body, commentId, user.id, isCommentModerator); @@ -155,7 +186,7 @@ export class CommentsController { @GetPermissions() permissions: PermissionManager, ): Promise { const commentModerator = permissions.can(Permission.API_MODERATE_COMMENTS); - if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator, commentModerator))) + if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); if (await this.commentsService.isUserCommentAuthor(user.id, commentId)) throw new AppException(ERROR_CODE.IS_COMMENT_AUTHOR); @@ -181,7 +212,7 @@ export class CommentsController { @GetPermissions() permissions: PermissionManager, ): Promise { const commentModerator = permissions.can(Permission.API_MODERATE_COMMENTS); - if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator, commentModerator))) + if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); // TODO : on est d'accord qu'on peut virer cette condition ? Puisque de toutes manières l'utilisateur ne peut pas mettre un upvote. if (await this.commentsService.isUserCommentAuthor(user.id, commentId)) @@ -200,11 +231,11 @@ export class CommentsController { async createReplyComment( @GetUser() user: User, @UUIDParam('commentId') commentId: string, - @Body() body: CommentReplyReqDto, + @Body() body: UeCommentReplyReqDto, @GetPermissions() permissions: PermissionManager, ): Promise { const isCommentModerator = permissions.can(Permission.API_MODERATE_COMMENTS); - if (!(await this.commentsService.doesCommentExist(commentId, user.id, isCommentModerator, isCommentModerator))) + if (!(await this.commentsService.doesCommentExist(commentId, user.id, isCommentModerator))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); return this.commentsService.replyComment(user.id, commentId, body); } @@ -221,7 +252,7 @@ export class CommentsController { async editReplyComment( @GetUser() user: User, @UUIDParam('replyId') replyId: string, - @Body() body: CommentReplyReqDto, + @Body() body: UeCommentReplyReqDto, @GetPermissions() permissions: PermissionManager, ): Promise { if (!(await this.commentsService.doesReplyExist(replyId))) throw new AppException(ERROR_CODE.NO_SUCH_REPLY); @@ -255,4 +286,76 @@ export class CommentsController { return this.commentsService.deleteReply(replyId); throw new AppException(ERROR_CODE.NOT_REPLY_AUTHOR); } + + @Post(':commentId/report') + @RequireApiPermission('API_SEE_OPINIONS_UE') + @ApiOperation({ description: 'Report a comment' }) + @ApiOkResponse({ type: UeCommentReportResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_COMMENT, 'there is no comment with the provided commentId') + @ApiAppErrorResponse(ERROR_CODE.IS_COMMENT_AUTHOR, 'thrown when the user is the comment author') + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_REPORT_REASON, 'the provided reason does not exist') + async reportComment( + @GetUser() user: User, + @UUIDParam('commentId') commentId: string, + @Body() body: UeCommentReportReqDto, + @GetPermissions() permissions: PermissionManager, + ) { + const commentModerator = permissions.can('API_MODERATE_COMMENTS'); + if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator))) + throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); + if (await this.commentsService.isUserCommentAuthor(user.id, commentId)) + throw new AppException(ERROR_CODE.IS_COMMENT_AUTHOR); + if (!(await this.commentsService.doesReportReasonExist(body.reason))) + throw new AppException(ERROR_CODE.NO_SUCH_REPORT_REASON); + return this.commentsService.reportComment(user.id, body, commentId, commentModerator); + } + + @Post('reply/:replyId/report') + @RequireApiPermission('API_SEE_OPINIONS_UE') + @ApiOperation({ description: 'Report a comment reply' }) + @ApiOkResponse({ type: UeCommentReportResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_REPLY, 'there is no comment reply with the provided replyId') + @ApiAppErrorResponse(ERROR_CODE.IS_COMMENT_AUTHOR, 'thrown when the user is the comment author') + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_REPORT_REASON, 'the provided reason does not exist') + async reportCommentReply( + @GetUser() user: User, + @UUIDParam('replyId') replyId: string, + @Body() body: UeCommentReportReqDto, + ) { + if (!(await this.commentsService.doesReplyExist(replyId))) throw new AppException(ERROR_CODE.NO_SUCH_REPLY); + if (await this.commentsService.isUserCommentReplyAuthor(user.id, replyId)) + throw new AppException(ERROR_CODE.IS_COMMENT_AUTHOR); + if (!(await this.commentsService.doesReportReasonExist(body.reason))) + throw new AppException(ERROR_CODE.NO_SUCH_REPORT_REASON); + return this.commentsService.reportCommentReply(user.id, body, replyId); + } + + @Patch(':commentId/:reportId') + @RequireApiPermission('API_MODERATE_COMMENTS') + @ApiOperation({ description: 'Mitigate a report' }) + @ApiOkResponse({ type: UeCommentReportResDto }) + async mitigateCommentReport( + @GetUser() user: User, + @UUIDParam('commentId') commentId: string, + @UUIDParam('reportId') reportId: string, + ) { + if (!(await this.commentsService.doesCommentExist(commentId, user.id, true))) + throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); + if (!(await this.commentsService.doesCommentReportExist(reportId))) + throw new AppException(ERROR_CODE.NO_SUCH_REPORT); + return this.commentsService.mitigateCommentReport(commentId, reportId); + } + + @Patch('/reply/:replyId/:reportId') + @RequireApiPermission('API_MODERATE_COMMENTS') + @ApiOperation({ description: 'Mitigate a comment reply report' }) + @ApiOkResponse({ type: UeCommentReportResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_REPLY, 'Thrown when the comment reply does not exist') + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_REPORT, 'Thrown when the report does not exist') + async mitigateCommentReplyReport(@UUIDParam('replyId') replyId: string, @UUIDParam('reportId') reportId: string) { + if (!(await this.commentsService.doesReplyExist(replyId))) throw new AppException(ERROR_CODE.NO_SUCH_REPLY); + if (!(await this.commentsService.doesCommentReplyReportExist(reportId))) + throw new AppException(ERROR_CODE.NO_SUCH_REPORT); + return this.commentsService.mitigateCommentReplyReport(replyId, reportId); + } } diff --git a/src/ue/comments/comments.service.ts b/src/ue/comments/comments.service.ts index 38d10f33..6ed7920f 100644 --- a/src/ue/comments/comments.service.ts +++ b/src/ue/comments/comments.service.ts @@ -1,40 +1,47 @@ import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { ConfigModule } from '../../config/config.module'; import { PrismaService } from '../../prisma/prisma.service'; -import { RawUserUeSubscription } from 'src/prisma/types'; +import { omit, pick } from '../../utils'; import UeCommentPostReqDto from './dto/req/ue-comment-post-req.dto'; -import CommentReplyReqDto from './dto/req/ue-comment-reply-req.dto'; +import UeCommentReplyReqDto from './dto/req/ue-comment-reply-req.dto'; +import UeCommentReportReqDto from './dto/req/ue-comment-report-req.dto'; import UeCommentUpdateReqDto from './dto/req/ue-comment-update-req.dto'; import GetUeCommentsReqDto from './dto/req/ue-get-comments-req.dto'; +import GetReportedCommentsReqDto from './dto/req/ue-get-reported-comments-req.dto'; +import UeCommentReportResDto from './dto/res/ue-comment-report-res.dto'; import { UeCommentReply } from './interfaces/comment-reply.interface'; -import { CommentStatus, UeComment } from './interfaces/comment.interface'; -import { ConfigModule } from '../../config/config.module'; +import { UeComment } from './interfaces/comment.interface'; +import { UeService } from '../ue.service'; @Injectable() export class CommentsService { constructor( readonly prisma: PrismaService, readonly config: ConfigModule, + readonly ueService: UeService, ) {} /** * Retrieves a page of {@link UeComment} matching the user query * @param userId the user fetching the comments. Used to determine if an anonymous comment should include its author * @param dto the query parameters of this route - * @param bypassAnonymousData if true, the author of an anonymous comment will be included in the response (this is the case if the user is a moderator) + * @param bypassRestrictedData if true, deleted comments, deleted replies, hidden comments, and anonymous author will be included in the response (only for moderators) * @returns a page of {@link UeComment} matching the user query */ async getComments( userId: string, dto: GetUeCommentsReqDto, - bypassAnonymousData: boolean, + bypassRestrictedData: boolean, ): Promise> { - // Use a prisma transaction to execute two requests at once: // We fetch a page of comments matching our filters and retrieve the total count of comments matching our filters const comments = await this.prisma.normalize.ueComment.findMany({ args: { userId: userId, - includeLastValidatedBody: bypassAnonymousData, - includeDeletedReplied: bypassAnonymousData, + includeDeleted: bypassRestrictedData, + includeHiddenComments: bypassRestrictedData, + includeReports: bypassRestrictedData, + bypassAnonymousData: bypassRestrictedData, }, where: { ueof: { @@ -47,8 +54,13 @@ export class CommentsService { skip: ((dto.page ?? 1) - 1) * this.config.PAGINATION_PAGE_SIZE, }); const commentCount = await this.prisma.ueComment.count({ - where: { ueof: { ue: { code: dto.ueCode } } }, + where: { + ueof: { ue: { code: dto.ueCode } }, + deletedAt: bypassRestrictedData ? undefined : null, + reports: bypassRestrictedData ? undefined : { none: { mitigated: false } }, + }, }); + // Data pagination return { items: comments, @@ -60,15 +72,18 @@ export class CommentsService { /** * Retrieves a single {@link UeComment} from a comment UUID * @param commentId the UUID of the comment - * @param userId the user fetching the comments. Used to determine if an anonymous comment should include its author - * @returns a page of {@link UeComment} matching the user query + * @param userId the user fetching the comment. Used to determine if an anonymous comment should include its author + * @param isModerator if true the user is a moderator + * @returns a single {@link UeComment} matching the provided UUID */ async getCommentFromId(commentId: string, userId: string, isModerator: boolean): Promise { const comment = await this.prisma.normalize.ueComment.findUnique({ args: { - includeDeletedReplied: isModerator, - includeLastValidatedBody: isModerator, - userId, + userId: userId, + includeDeleted: isModerator, + includeHiddenComments: isModerator, + includeReports: isModerator, + bypassAnonymousData: isModerator, }, where: { id: commentId, @@ -77,6 +92,20 @@ export class CommentsService { return comment; } + /** + * Retrieves a single {@link UeCommentReply} from a reply UUID + * @param replyId the UUID of the comment reply + * @returns a single {@link UeCommentReply} matching the provided UUID + */ + async getReplyFromId(replyId: string): Promise { + const comment = await this.prisma.normalize.ueCommentReply.findUnique({ + where: { + id: replyId, + }, + }); + return comment; + } + /** * Checks whether a user is the author of a comment * @remarks The comment must exist and user must not be null @@ -125,29 +154,6 @@ export class CommentsService { ); } - /** - * Retrieves the last semester done by a user for a given ue - * @remarks The user must not be null - * @param userId the user to retrieve semesters of - * @param ueCode the code of the UE - * @returns the last semester done by the {@link user} for the {@link ueCode | ue} - */ - private async getLastUserSubscription(userId: string, ueCode: string): Promise { - return this.prisma.userUeSubscription.findFirst({ - where: { - ueof: { - ueId: ueCode, - }, - userId, - }, - orderBy: { - semester: { - end: 'desc', - }, - }, - }); - } - /** * Checks whether a user has already posted a comment for an ue * @remarks The user must not be null and UE must exist @@ -165,8 +171,6 @@ export class CommentsService { // Find a comment (in the UE) whose author is the user const comment = await this.prisma.normalize.ueComment.findMany({ args: { - includeDeletedReplied: false, - includeLastValidatedBody: false, userId, }, where: { @@ -188,11 +192,9 @@ export class CommentsService { */ async createComment(body: UeCommentPostReqDto, userId: string): Promise { // Use last semester done when creating the comment - const lastSemester = await this.getLastUserSubscription(userId, body.ueCode); + const lastSemester = await this.ueService.getLastUserSubscription(userId, body.ueCode); return this.prisma.normalize.ueComment.create({ args: { - includeDeletedReplied: true, - includeLastValidatedBody: true, userId, }, data: { @@ -232,27 +234,22 @@ export class CommentsService { userId: string, isModerator: boolean, ): Promise { - const previousComment = await this.prisma.normalize.ueComment.findUnique({ + await this.prisma.normalize.ueComment.findUnique({ args: { userId, - includeDeletedReplied: true, - includeLastValidatedBody: true, + includeHiddenComments: isModerator, + includeDeleted: isModerator, }, where: { id: commentId, }, }); - const needsValidationAgain = - body.body && - body.body !== previousComment.body && - previousComment.status & CommentStatus.VALIDATED && - !isModerator; return this.prisma.normalize.ueComment.update({ args: { userId, - includeDeletedReplied: true, - includeLastValidatedBody: true, + includeHiddenComments: isModerator, + includeDeleted: isModerator, }, where: { id: commentId, @@ -260,8 +257,6 @@ export class CommentsService { data: { body: body.body, isAnonymous: body.isAnonymous, - validatedAt: needsValidationAgain ? null : undefined, - lastValidatedBody: needsValidationAgain ? previousComment.body : undefined, updatedAt: new Date(), }, }); @@ -292,7 +287,7 @@ export class CommentsService { * @param reply the reply to post * @returns the created {@link UeCommentReply} */ - async replyComment(userId: string, commentId: string, reply: CommentReplyReqDto): Promise { + async replyComment(userId: string, commentId: string, reply: UeCommentReplyReqDto): Promise { return this.prisma.normalize.ueCommentReply.create({ data: { body: reply.body, @@ -309,7 +304,7 @@ export class CommentsService { * @param reply the modifications to apply to the reply * @returns the updated {@link UeCommentReply} */ - async editReply(replyId: string, reply: CommentReplyReqDto): Promise { + async editReply(replyId: string, reply: UeCommentReplyReqDto): Promise { return this.prisma.normalize.ueCommentReply.update({ data: { body: reply.body, @@ -378,8 +373,8 @@ export class CommentsService { return this.prisma.normalize.ueComment.update({ args: { userId, - includeDeletedReplied: true, - includeLastValidatedBody: false, + includeDeleted: true, + includeHiddenComments: true, }, where: { id: commentId, @@ -393,31 +388,223 @@ export class CommentsService { /** * Checks whether a comment exists * @param commentId the id of the comment to check + * @param isModerator if true the user is a moderator * @returns whether the {@link commentId | comment} exists */ - async doesCommentExist(commentId: string, userId: string, includeUnverified: boolean, includeDeleted = false) { - return ( - (await this.prisma.ueComment.count({ - where: { - id: commentId, - deletedAt: includeDeleted ? undefined : null, - OR: [ - { - validatedAt: { - not: null, - }, + async doesCommentExist(commentId: string, userId: string, isModerator: boolean = false) { + const where: Prisma.UeCommentWhereInput = { + id: commentId, + }; + if (!isModerator) { + where.deletedAt = null; + where.reports = { + none: { + mitigated: false, + }, + }; + } + return (await this.prisma.ueComment.count({ where })) != 0; + } + + /** + * Retrieves a page of {@link UeComment} having at least one non mitigated report + * @returns a page of {@link UeComment} matching the user query + */ + async getCommentsWithReports(userId: string, dto: GetReportedCommentsReqDto): Promise> { + // We fetch a page of comments matching our filters and retrieve the total count of comments matching our filters + const whereClause: Prisma.UeCommentWhereInput = { + OR: [ + { + reports: { + some: { + mitigated: false, + }, + }, + }, + { + answers: { + some: { reports: { - none: { + some: { mitigated: false, }, }, }, - { - authorId: includeUnverified ? undefined : userId, - }, - ], + }, }, - })) != 0 + ], + }; + const comments = await this.prisma.normalize.ueComment.findMany({ + args: { + userId: userId, + includeDeleted: false, + includeHiddenComments: true, + includeReports: true, + bypassAnonymousData: true, + }, + where: whereClause, + take: this.config.PAGINATION_PAGE_SIZE, + skip: ((dto.page ?? 1) - 1) * this.config.PAGINATION_PAGE_SIZE, + }); + const commentCount = await this.prisma.ueComment.count({ + where: { + ...whereClause, + deletedAt: null, + }, + }); + + // Data pagination + return { + items: comments, + itemCount: commentCount, + itemsPerPage: this.config.PAGINATION_PAGE_SIZE, + }; + } + + /** + * Check if a comment report exists + * @param reportId the id of the report + * @returns true if it exists + */ + async doesCommentReportExist(reportId: string): Promise { + return (await this.prisma.ueCommentReport.count({ where: { id: reportId } })) == 1; + } + + /** + * Check if a comment reply report exists + * @param reportId the id of the report + * @returns true if it exists + */ + async doesCommentReplyReportExist(reportId: string): Promise { + return (await this.prisma.ueCommentReplyReport.count({ where: { id: reportId } })) == 1; + } + + /** + * Check if a report reason exist + * @param reasonName the name of the report reason + * @returns true if it exists + */ + async doesReportReasonExist(reasonName: string): Promise { + return (await this.prisma.ueCommentReportReason.count({ where: { name: reasonName } })) == 1; + } + + /** + * Report a comment + * @param userId the user id of the reporter + * @param body the report data + */ + async reportComment( + userId: string, + body: UeCommentReportReqDto, + commentId: string, + isModerator: boolean, + ): Promise { + const comment = await this.getCommentFromId(commentId, userId, isModerator); + const report = await this.prisma.ueCommentReport.create({ + data: { + body: body.body, + reportedBody: comment.body, + reason: { + connect: { + name: body.reason, + }, + }, + comment: { + connect: { + id: commentId, + }, + }, + user: { + connect: { + id: userId, + }, + }, + }, + include: { + user: true, + reason: true, + }, + }); + return { + ...omit(report, 'reason', 'reasonId', 'userId', 'user', 'commentId'), + reason: report.reason.name, + user: pick(report.user, 'firstName', 'id', 'lastName'), + }; + } + + async reportCommentReply( + userId: string, + body: UeCommentReportReqDto, + replyId: string, + ): Promise { + const reply = await this.getReplyFromId(replyId); + const report = await this.prisma.ueCommentReplyReport.create({ + data: { + body: body.body, + mitigated: false, + reason: { + connect: { + name: body.reason, + }, + }, + reply: { + connect: { + id: replyId, + }, + }, + user: { + connect: { + id: userId, + }, + }, + reportedBody: reply.body, + }, + include: { + user: true, + reason: true, + }, + }); + return { + ...omit(report, 'user', 'replyId', 'reasonId', 'userId'), + user: pick(report.user, 'firstName', 'id', 'lastName'), + reason: report.reason.name, + }; + } + + async mitigateCommentReport(commentId: string, reportId: string) { + return await this.prisma.ueCommentReport.update({ + where: { + commentId, + id: reportId, + }, + data: { + mitigated: true, + }, + }); + } + + async mitigateCommentReplyReport(replyId: string, reportId: string) { + return await this.prisma.ueCommentReplyReport.update({ + where: { + reply: { + id: replyId, + }, + id: reportId, + }, + data: { + mitigated: true, + }, + }); + } + + async getCommentReportReason() { + return (await this.prisma.ueCommentReportReason.findMany({ include: { descriptionTranslation: true } })).map( + (rr) => { + return { + name: rr.name, + description: omit(rr.descriptionTranslation, 'id'), + }; + }, ); } } diff --git a/src/ue/comments/dto/req/ue-comment-reply-req.dto.ts b/src/ue/comments/dto/req/ue-comment-reply-req.dto.ts index d53fc1a4..cbe1b6d7 100644 --- a/src/ue/comments/dto/req/ue-comment-reply-req.dto.ts +++ b/src/ue/comments/dto/req/ue-comment-reply-req.dto.ts @@ -4,7 +4,7 @@ import { IsNotEmpty, IsString, MinLength } from 'class-validator'; * Body data required to create a new comment reply. * @property body The body of the reply. Must be at least 5 characters long. */ -export default class CommentReplyReqDto { +export default class UeCommentReplyReqDto { @IsString() @IsNotEmpty() @MinLength(5) diff --git a/src/ue/comments/dto/req/ue-comment-report-req.dto.ts b/src/ue/comments/dto/req/ue-comment-report-req.dto.ts new file mode 100644 index 00000000..505e91fb --- /dev/null +++ b/src/ue/comments/dto/req/ue-comment-report-req.dto.ts @@ -0,0 +1,17 @@ +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; + +/** + * Body data required to report a comment + * @property body The user message associated with the report. Must be at least 5 characters long + * @property reason The report reason + */ +export default class UeCommentReportReqDto { + @IsString() + @IsNotEmpty() + @MinLength(5) + body: string; + + @IsString() + @IsNotEmpty() + reason: string; +} diff --git a/src/ue/comments/dto/req/ue-get-reported-comments-req.dto.ts b/src/ue/comments/dto/req/ue-get-reported-comments-req.dto.ts new file mode 100644 index 00000000..5699a657 --- /dev/null +++ b/src/ue/comments/dto/req/ue-get-reported-comments-req.dto.ts @@ -0,0 +1,14 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsPositive } from 'class-validator'; + +/** + * Query parameters to get reported comments. + * @property page The page number to get. Defaults to 1 (Starting at 1). + */ +export default class GetReportedCommentsReqDto { + @Type(() => Number) + @IsInt() + @IsPositive() + @IsOptional() + page?: number; +} diff --git a/src/ue/comments/dto/res/ue-comment-report-reason-res.dto.ts b/src/ue/comments/dto/res/ue-comment-report-reason-res.dto.ts new file mode 100644 index 00000000..b74aff0b --- /dev/null +++ b/src/ue/comments/dto/res/ue-comment-report-reason-res.dto.ts @@ -0,0 +1,10 @@ +export default class UeCommentReportReasonResDto { + name: string; + description: { + fr: string; + en: string; + es: string; + de: string; + zh: string; + }; +} diff --git a/src/ue/comments/dto/res/ue-comment-report-res.dto.ts b/src/ue/comments/dto/res/ue-comment-report-res.dto.ts new file mode 100644 index 00000000..e1e02bac --- /dev/null +++ b/src/ue/comments/dto/res/ue-comment-report-res.dto.ts @@ -0,0 +1,11 @@ +import UeCommentAuthorResDto from './ue-comment-author-res.dto'; + +export default class UeCommentReportResDto { + id: string; + body: string; + createdAt: Date; + mitigated: boolean; + reportedBody: string; + user: UeCommentAuthorResDto; + reason: string; +} diff --git a/src/ue/comments/dto/res/ue-comment-res.dto.ts b/src/ue/comments/dto/res/ue-comment-res.dto.ts index 75981e80..11acf42f 100644 --- a/src/ue/comments/dto/res/ue-comment-res.dto.ts +++ b/src/ue/comments/dto/res/ue-comment-res.dto.ts @@ -1,4 +1,5 @@ import UeCommentAuthorResDto from './ue-comment-author-res.dto'; +import UeCommentReportResDto from './ue-comment-report-res.dto'; export default class UeCommentResDto { id: string; @@ -12,7 +13,7 @@ export default class UeCommentResDto { upvoted: boolean; status: number; answers: CommentResDto_Answer[]; - lastValidatedBody?: string; + reports: UeCommentReportResDto[]; } class CommentResDto_Answer { @@ -22,4 +23,5 @@ class CommentResDto_Answer { createdAt: Date; updatedAt: Date; status: number; + reports: UeCommentReportResDto[]; } diff --git a/src/ue/comments/interfaces/comment-reply.interface.ts b/src/ue/comments/interfaces/comment-reply.interface.ts index b2e53ce7..36a50879 100644 --- a/src/ue/comments/interfaces/comment-reply.interface.ts +++ b/src/ue/comments/interfaces/comment-reply.interface.ts @@ -2,8 +2,9 @@ import { CommentStatus } from './comment.interface'; import { Prisma, PrismaClient } from '@prisma/client'; import { omit } from '../../../utils'; import { generateCustomModel } from '../../../prisma/prisma.service'; +import { RawUeCommentReplyReport } from 'src/prisma/types'; -const REPLY_SELECT_FILTER = { +export const REPLY_SELECT_FILTER = { select: { id: true, author: { @@ -17,16 +18,46 @@ const REPLY_SELECT_FILTER = { createdAt: true, updatedAt: true, deletedAt: true, + reports: { + select: { + id: true, + body: true, + mitigated: true, + createdAt: true, + reportedBody: true, + reason: { + select: { + name: true, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + }, + }, }, } as const; +export type UeCommentReplyReport = Omit & { + reason: string; + user: { + id: string; + firstName: string; + lastName: string; + }; +}; type UnformattedUeCommentReply = Prisma.UeCommentGetPayload; export type UeCommentReply = Omit< - Prisma.UeCommentReplyGetPayload & { - status: CommentStatus; - }, - 'deletedAt' ->; + Prisma.UeCommentReplyGetPayload, + 'deletedAt' | 'reports' +> & { + status: CommentStatus; + reports: UeCommentReplyReport[]; +}; export function generateCustomUeCommentReplyModel(prisma: PrismaClient) { return generateCustomModel(prisma, 'ueCommentReply', REPLY_SELECT_FILTER, formatReply); @@ -34,7 +65,11 @@ export function generateCustomUeCommentReplyModel(prisma: PrismaClient) { export function formatReply(_: PrismaClient, reply: UnformattedUeCommentReply): UeCommentReply { return { - ...omit(reply, 'deletedAt'), - status: (reply.deletedAt && CommentStatus.DELETED) | CommentStatus.VALIDATED, + ...omit(reply, 'deletedAt', 'reports'), + reports: reply.reports.map((r) => { + return { ...r, reason: r.reason.name }; + }), + status: + (reply.reports.some((r) => !r.mitigated) && CommentStatus.HIDDEN) | (reply.deletedAt && CommentStatus.DELETED), }; } diff --git a/src/ue/comments/interfaces/comment.interface.ts b/src/ue/comments/interfaces/comment.interface.ts index 0b0ef655..da13ea49 100644 --- a/src/ue/comments/interfaces/comment.interface.ts +++ b/src/ue/comments/interfaces/comment.interface.ts @@ -1,7 +1,8 @@ import { Prisma, PrismaClient } from '@prisma/client'; import { RequestType, generateCustomModel } from '../../../prisma/prisma.service'; -import { UeCommentReply, formatReply } from './comment-reply.interface'; +import { REPLY_SELECT_FILTER, UeCommentReply, formatReply } from './comment-reply.interface'; import { omit } from '../../../utils'; +import { RawUeCommentReport } from 'src/prisma/types'; const COMMENT_SELECT_FILTER = { select: { @@ -17,7 +18,6 @@ const COMMENT_SELECT_FILTER = { createdAt: true, updatedAt: true, deletedAt: true, - validatedAt: true, semester: { select: { code: true, @@ -35,28 +35,31 @@ const COMMENT_SELECT_FILTER = { }, }, }, - answers: { + answers: REPLY_SELECT_FILTER, + upvotes: { + select: { + userId: true, + }, + }, + reports: { select: { id: true, - author: { + reportedBody: true, + body: true, + mitigated: true, + createdAt: true, + reason: { + select: { + name: true, + }, + }, + user: { select: { id: true, firstName: true, lastName: true, }, }, - body: true, - createdAt: true, - updatedAt: true, - deletedAt: true, - }, - where: { - deletedAt: null, - }, - }, - upvotes: { - select: { - userId: true, }, }, }, @@ -73,23 +76,45 @@ const COMMENT_SELECT_FILTER = { } satisfies Partial>; export type UEExtraArgs = { - includeDeletedReplied: boolean; - includeLastValidatedBody: boolean; userId: string; + /** + * If true this will include deleted comments and deleted replies + */ + includeDeleted?: boolean; + /** + * If true this will include comments reports + */ + includeReports?: boolean; + /** + * If true this will include comments which have been reported and are not yet mitigated by a moderator + */ + includeHiddenComments?: boolean; + /** + * If true the owner of anonymous comments will be included + */ + bypassAnonymousData?: boolean; }; -export type UnformattedUEComment = Prisma.UeCommentGetPayload; +export type UeCommentReport = Omit & { + reason: string; + user: { + id: string; + firstName: string; + lastName: string; + }; +}; +export type UnformattedUeComment = Prisma.UeCommentGetPayload; export type UeComment = Omit< - UnformattedUEComment, - 'upvotes' | 'deletedAt' | 'validatedAt' | 'answers' | 'semester' | 'author' + UnformattedUeComment, + 'upvotes' | 'deletedAt' | 'answers' | 'semester' | 'reports' | 'author' > & { upvotes: number; upvoted: boolean; status: CommentStatus; answers: UeCommentReply[]; - lastValidatedBody?: string | undefined; semester: string; - author?: UnformattedUEComment['author']; + reports: UeCommentReport[]; + author?: UnformattedUeComment['author']; }; export function generateCustomCommentModel(prisma: PrismaClient) { @@ -99,52 +124,57 @@ export function generateCustomCommentModel(prisma: PrismaClient) { COMMENT_SELECT_FILTER, formatComment, async (query, args: UEExtraArgs) => { - Object.assign(query.select.answers, { - where: { - deletedAt: args.includeDeletedReplied ? undefined : null, - OR: [ - { - reports: { - none: { - mitigated: false, - }, - }, - }, - { - authorId: args.includeDeletedReplied ? undefined : args.userId, - }, - ], - }, - }); - Object.assign(query.select, { lastValidatedBody: args.includeLastValidatedBody }); + if ('data' in query && !('where' in query)) { + // CREATE operation → skip where filters + return query; + } + const includeDeleted = !!args.includeDeleted; + const includeHiddenComments = !!args.includeHiddenComments; + if (query.where == null && !(includeDeleted && includeHiddenComments)) { + Object.assign(query, { ...query, where: {} }); + } + if (!includeDeleted) { + Object.assign(query.where, { ...query.where, deletedAt: null }); + } + if (!includeHiddenComments) { + Object.assign(query.where, { ...query.where, reports: { none: { mitigated: false } } }); + } return query; }, ); } -export function formatComment(prisma: PrismaClient, comment: UnformattedUEComment, args: UEExtraArgs): UeComment { +export function formatComment(prisma: PrismaClient, comment: UnformattedUeComment, args: UEExtraArgs): UeComment { + const bypassAnonymousData = !!args.bypassAnonymousData; + const includeReports = !!args.includeReports; return { ...omit( comment, 'deletedAt', - 'validatedAt', - comment.isAnonymous && - comment.author.id !== args.userId && - !(args.includeDeletedReplied && args.includeLastValidatedBody) - ? 'author' - : undefined, + !comment.isAnonymous || bypassAnonymousData || args.userId == comment.author.id ? undefined : 'author', ), - answers: comment.answers.map((answer) => formatReply(prisma, answer)), - status: (comment.deletedAt && CommentStatus.DELETED) | (comment.validatedAt && CommentStatus.VALIDATED), + answers: comment.answers + .filter((answer) => args.includeDeleted || answer.deletedAt === null) + .map((answer) => { + const anwser = formatReply(prisma, answer); + if (!includeReports) anwser.reports = []; + return anwser; + }), + status: + (comment.reports.some((r) => !r.mitigated) && CommentStatus.HIDDEN) | + (comment.deletedAt && CommentStatus.DELETED), upvotes: comment.upvotes.length, upvoted: comment.upvotes.some((upvote) => upvote.userId == args.userId), semester: comment.semester.code, + reports: includeReports ? comment.reports.map((r) => ({ ...r, reason: r.reason.name })) : [], }; } export const enum CommentStatus { - UNVERIFIED = 0b000, // For typing only - VALIDATED = 0b001, - PROCESSING = 0b010, - DELETED = 0b100, + ACTIVE = 0b00, + /** + * The comment has been reported and is temporarily hidden + */ + HIDDEN = 0b01, + DELETED = 0b10, } diff --git a/src/ue/ue.service.ts b/src/ue/ue.service.ts index 29e6ff4e..4477e796 100644 --- a/src/ue/ue.service.ts +++ b/src/ue/ue.service.ts @@ -8,6 +8,7 @@ import { UeRating } from './interfaces/rate.interface'; import { ConfigModule } from '../config/config.module'; import { Language, Prisma } from '@prisma/client'; import { SemesterService } from '../semester/semester.service'; +import { RawUserUeSubscription } from 'src/prisma/types'; @Injectable() export class UeService { @@ -224,6 +225,29 @@ export class UeService { }); } + /** + * Retrieves the last semester done by a user for a given ue + * @remarks The user must not be null + * @param userId the user to retrieve semesters of + * @param ueCode the code of the UE + * @returns the last semester done by the {@link user} for the {@link ueCode | ue} + */ + async getLastUserSubscription(userId: string, ueCode: string): Promise { + return this.prisma.userUeSubscription.findFirst({ + where: { + ueof: { + ueId: ueCode, + }, + userId, + }, + orderBy: { + semester: { + end: 'desc', + }, + }, + }); + } + /** * Checks whether a criterion exists * @param criterionId the id of the criterion to check diff --git a/test/declarations.d.ts b/test/declarations.d.ts index bf412f0a..6505128b 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -1,5 +1,5 @@ import { ERROR_CODE, ErrorData, ExtrasTypeBuilder } from '../src/exceptions'; -import { UeComment } from 'src/ue/comments/interfaces/comment.interface'; +import { UeComment, UeCommentReport } from 'src/ue/comments/interfaces/comment.interface'; import { UeCommentReply } from 'src/ue/comments/interfaces/comment-reply.interface'; import { UeRating } from 'src/ue/interfaces/rate.interface'; import { @@ -75,6 +75,8 @@ declare module './declarations' { * The HTTP Status code may be 200 or 204, depending on the {@link created} property. */ expectUeCommentReply(reply: JsonLikeVariant, created = false): this; + /** expects to return the given {@link UeCommentReport} */ + expectUeCommentReport(report: JsonLikeVariant, created = false): this; /** expects to return the given {@link criterion} list */ expectUeCriteria(criterion: JsonLikeVariant): this; /** expects to return the given {@link rate} */ diff --git a/test/declarations.ts b/test/declarations.ts index 1d8e1f5f..3da28400 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -2,7 +2,7 @@ import { HttpStatus } from '@nestjs/common'; import Spec from 'pactum/src/models/Spec'; import { FakeAssoMembers, FakeUeWithOfs, JsonLikeVariant } from './declarations.d'; import { ERROR_CODE, ErrorData, ExtrasTypeBuilder } from '../src/exceptions'; -import { UeComment } from '../src/ue/comments/interfaces/comment.interface'; +import { UeComment, UeCommentReport } from '../src/ue/comments/interfaces/comment.interface'; import { UeCommentReply } from '../src/ue/comments/interfaces/comment-reply.interface'; import { Criterion } from 'src/ue/interfaces/criterion.interface'; import { UeRating } from 'src/ue/interfaces/rate.interface'; @@ -164,42 +164,25 @@ Spec.prototype.expectUeComment = function (this: Spec, obj, created = false) { language: obj.ueof.info.language, }, }, - }); + } satisfies JsonLikeVariant); }; Spec.prototype.expectUeComments = function (this: Spec, obj) { return this.expectStatus(HttpStatus.OK).$expectRegexableJson({ itemCount: obj.itemCount, itemsPerPage: obj.itemsPerPage, items: obj.items.map((comment) => ({ - ...pick( - comment, - 'id', - 'author', - 'body', - 'isAnonymous', - 'lastValidatedBody', - 'semester', - 'status', - 'upvoted', - 'upvotes', - ), + ...omit(comment, 'ueof', 'ue'), ueof: { code: comment.ueof.code, info: { language: comment.ueof.info.language, }, }, - createdAt: comment.createdAt, - updatedAt: comment.updatedAt, - answers: comment.answers.map((answer) => ({ - ...pick(answer, 'author', 'body', 'id', 'status'), - createdAt: answer.createdAt, - updatedAt: answer.updatedAt, - })), })), } satisfies JsonLikeVariant>); }; Spec.prototype.expectUeCommentReply = expectOkOrCreate; +Spec.prototype.expectUeCommentReport = expectOkOrCreate; Spec.prototype.expectUeCriteria = expect; Spec.prototype.expectUeRate = expect; Spec.prototype.expectUeRates = expect<{ [criterion: string]: UeRating[] }>; diff --git a/test/e2e/profile/set-homepage-widgets.e2e-spec.ts b/test/e2e/profile/set-homepage-widgets.e2e-spec.ts index 9eeb926d..b88f6d66 100644 --- a/test/e2e/profile/set-homepage-widgets.e2e-spec.ts +++ b/test/e2e/profile/set-homepage-widgets.e2e-spec.ts @@ -29,13 +29,16 @@ const SetHomepageWidgetsE2ESpec = e2eSuite('PUT /profile/homepage', (app) => { it('should fail as user is not connected', () => pactum.spec().put('/profile/homepage').withJson(body).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); - it('should fail as body is not valid', () => - pactum + it('should fail as body is not valid', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + await pactum .spec() .put('/profile/homepage') .withBearerToken(user.token) .withJson({ widget: 'a_widget', height: 1, width: 1, x: 0, y: 0 }) - .expectAppError(ERROR_CODE.PARAM_MALFORMED, 'items')); + .expectAppError(ERROR_CODE.PARAM_MALFORMED, 'items'); + consoleErrorSpy.mockRestore(); + }); it('should fail for each value of the body as they are not allowed (too small, wrong type, ...)', async () => { await pactum @@ -88,14 +91,13 @@ const SetHomepageWidgetsE2ESpec = e2eSuite('PUT /profile/homepage', (app) => { .expectAppError(ERROR_CODE.PARAM_NOT_POSITIVE, 'height'); }); - it('should fail as the widgets are overlapping', () => { + it('should fail as the widgets are overlapping', () => pactum .spec() .put('/profile/homepage') .withBearerToken(user.token) .withJson([body[0], { ...body[1], x: 0, y: 0 }]) - .expectAppError(ERROR_CODE.WIDGET_OVERLAPPING, '0', '1'); - }); + .expectAppError(ERROR_CODE.WIDGET_OVERLAPPING, '0', '1')); it('should successfully set the homepage widgets', async () => { await pactum diff --git a/test/e2e/ue/annals/delete-annal.e2e-spec.ts b/test/e2e/ue/annals/delete-annal.e2e-spec.ts index a0741231..1af3c933 100644 --- a/test/e2e/ue/annals/delete-annal.e2e-spec.ts +++ b/test/e2e/ue/annals/delete-annal.e2e-spec.ts @@ -12,9 +12,9 @@ import { } from '../../../utils/fakedb'; import { Dummies, JsonLike, e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; -import { CommentStatus } from 'src/ue/comments/interfaces/comment.interface'; import { PermissionManager, pick } from '../../../../src/utils'; import { PrismaService } from '../../../../src/prisma/prisma.service'; +import { AnnalStatus } from 'src/ue/annals/interfaces/annal.interface'; const DeleteAnnal = e2eSuite('DELETE /ue/annals/{annalId}', (app) => { const senderUser = createUser(app, { permissions: new PermissionManager().with('API_UPLOAD_ANNALS') }); @@ -68,7 +68,7 @@ const DeleteAnnal = e2eSuite('DELETE /ue/annals/{annalId}', (app) => { .expectUeAnnal({ ...pick(annal_validated, 'id', 'semesterId'), type: annalType, - status: CommentStatus.DELETED | CommentStatus.VALIDATED, + status: AnnalStatus.DELETED | AnnalStatus.VALIDATED, sender: pick(senderUser, 'id', 'firstName', 'lastName'), createdAt: annal_validated.createdAt, updatedAt: JsonLike.DATE, diff --git a/test/e2e/ue/annals/get-annal-file.e2e-spec.ts b/test/e2e/ue/annals/get-annal-file.e2e-spec.ts index 21c7ae5c..3bbc6d82 100644 --- a/test/e2e/ue/annals/get-annal-file.e2e-spec.ts +++ b/test/e2e/ue/annals/get-annal-file.e2e-spec.ts @@ -12,7 +12,7 @@ import { } from '../../../utils/fakedb'; import { Dummies, e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; -import { CommentStatus } from '../../../../src/ue/comments/interfaces/comment.interface'; +import { AnnalStatus } from 'src/ue/annals/interfaces/annal.interface'; import { PermissionManager } from '../../../../src/utils'; const GetAnnalFile = e2eSuite('GET /ue/annals/{annalId}', (app) => { @@ -34,18 +34,18 @@ const GetAnnalFile = e2eSuite('GET /ue/annals/{annalId}', (app) => { const annal_not_validated = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.UNVERIFIED }, + { status: AnnalStatus.UNVERIFIED }, ); const annal_validated = createAnnal(app, { semester, sender: senderUser, type: annalType, ueof }); const annal_not_uploaded = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.UNVERIFIED | CommentStatus.PROCESSING }, + { status: AnnalStatus.UNVERIFIED | AnnalStatus.PROCESSING }, ); const annal_deleted = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.VALIDATED | CommentStatus.DELETED }, + { status: AnnalStatus.VALIDATED | AnnalStatus.DELETED }, ); it('should return a 401 as user is not authenticated', () => { diff --git a/test/e2e/ue/annals/get-annals.e2e-spec.ts b/test/e2e/ue/annals/get-annals.e2e-spec.ts index ba1817b9..e456d142 100644 --- a/test/e2e/ue/annals/get-annals.e2e-spec.ts +++ b/test/e2e/ue/annals/get-annals.e2e-spec.ts @@ -12,10 +12,9 @@ import { } from '../../../utils/fakedb'; import { e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; -import { UeAnnalFile } from '../../../../src/ue/annals/interfaces/annal.interface'; +import { AnnalStatus, UeAnnalFile } from '../../../../src/ue/annals/interfaces/annal.interface'; import { JsonLikeVariant } from 'test/declarations'; import { PermissionManager, pick } from '../../../../src/utils'; -import { CommentStatus } from '../../../../src/ue/comments/interfaces/comment.interface'; const GetAnnal = e2eSuite('GET /ue/annals', (app) => { const senderUser = createUser(app, { permissions: new PermissionManager().with('API_SEE_ANNALS') }); @@ -40,18 +39,18 @@ const GetAnnal = e2eSuite('GET /ue/annals', (app) => { const annal_not_validated = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.UNVERIFIED }, + { status: AnnalStatus.UNVERIFIED }, ); const annal_validated = createAnnal(app, { semester, sender: senderUser, type: annalType, ueof }); const annal_not_uploaded = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.UNVERIFIED | CommentStatus.PROCESSING }, + { status: AnnalStatus.UNVERIFIED | AnnalStatus.PROCESSING }, ); const annal_deleted = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.DELETED | CommentStatus.VALIDATED }, + { status: AnnalStatus.DELETED | AnnalStatus.VALIDATED }, ); it('should return a 401 as user is not authenticated', () => { @@ -87,7 +86,11 @@ const GetAnnal = e2eSuite('GET /ue/annals', (app) => { .withQueryParams({ ueCode: ue.code, }) - .expectUeAnnals([annal_not_validated, annal_validated, annal_not_uploaded].map(formatAnnalFile)); + .expectUeAnnals( + [annal_not_validated, annal_validated, annal_not_uploaded] + .mappedSort((annal) => [annal.createdAt.getTime(), annal.id]) + .map(formatAnnalFile), + ); await pactum .spec() .withBearerToken(nonUeUser.token) @@ -103,7 +106,11 @@ const GetAnnal = e2eSuite('GET /ue/annals', (app) => { .withQueryParams({ ueCode: ue.code, }) - .expectUeAnnals([annal_not_validated, annal_validated, annal_not_uploaded, annal_deleted].map(formatAnnalFile)); + .expectUeAnnals( + [annal_not_validated, annal_deleted, annal_not_uploaded, annal_validated] + .mappedSort((annal) => [annal.createdAt.getTime(), annal.id]) + .map(formatAnnalFile), + ); }); const formatAnnalFile = (from: Partial): JsonLikeVariant => { diff --git a/test/e2e/ue/annals/patch-annal.e2e-spec.ts b/test/e2e/ue/annals/patch-annal.e2e-spec.ts index ec754eb8..990f7b61 100644 --- a/test/e2e/ue/annals/patch-annal.e2e-spec.ts +++ b/test/e2e/ue/annals/patch-annal.e2e-spec.ts @@ -12,8 +12,8 @@ import { } from '../../../utils/fakedb'; import { Dummies, JsonLike, e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; -import { CommentStatus } from 'src/ue/comments/interfaces/comment.interface'; import { PermissionManager, pick } from '../../../../src/utils'; +import { AnnalStatus } from 'src/ue/annals/interfaces/annal.interface'; const EditAnnal = e2eSuite('PATCH /ue/annals/{annalId}', (app) => { const senderUser = createUser(app, { permissions: new PermissionManager().with('API_UPLOAD_ANNALS') }); @@ -34,12 +34,12 @@ const EditAnnal = e2eSuite('PATCH /ue/annals/{annalId}', (app) => { const annal_not_uploaded = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.PROCESSING | CommentStatus.UNVERIFIED }, + { status: AnnalStatus.PROCESSING | AnnalStatus.UNVERIFIED }, ); const annal_deleted = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.VALIDATED | CommentStatus.DELETED }, + { status: AnnalStatus.VALIDATED | AnnalStatus.DELETED }, ); const xx_analType_xx = createAnnalType(app, {}); @@ -106,7 +106,7 @@ const EditAnnal = e2eSuite('PATCH /ue/annals/{annalId}', (app) => { .expectUeAnnal({ semesterId: xx_semester_xx.code, type: xx_analType_xx, - status: CommentStatus.VALIDATED, + status: AnnalStatus.VALIDATED, sender: pick(senderUser, 'id', 'firstName', 'lastName'), id: annal_validated.id, createdAt: annal_validated.createdAt, diff --git a/test/e2e/ue/annals/upload-annal.e2e-spec.ts b/test/e2e/ue/annals/upload-annal.e2e-spec.ts index bc4bd120..d8bb775e 100644 --- a/test/e2e/ue/annals/upload-annal.e2e-spec.ts +++ b/test/e2e/ue/annals/upload-annal.e2e-spec.ts @@ -12,9 +12,9 @@ import { import { JsonLike, e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; import { ConfigModule } from '../../../../src/config/config.module'; -import { CommentStatus } from 'src/ue/comments/interfaces/comment.interface'; import { PermissionManager, pick } from '../../../../src/utils'; import { mkdirSync, rmSync } from 'fs'; +import { AnnalStatus } from 'src/ue/annals/interfaces/annal.interface'; const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { const senderUser = createUser(app, { permissions: new PermissionManager().with('API_UPLOAD_ANNALS') }); @@ -142,7 +142,7 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { updatedAt: JsonLike.DATE, semesterId: semester.code, type: annalType, - status: CommentStatus.PROCESSING, + status: AnnalStatus.PROCESSING, sender: pick(senderUser, 'id', 'firstName', 'lastName'), }, true, @@ -180,7 +180,7 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { updatedAt: JsonLike.DATE, semesterId: semester.code, type: annalType, - status: CommentStatus.PROCESSING, + status: AnnalStatus.PROCESSING, sender: pick(senderUser, 'id', 'firstName', 'lastName'), }, true, @@ -214,7 +214,7 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { updatedAt: JsonLike.DATE, semesterId: semester.code, type: annalType, - status: CommentStatus.PROCESSING, + status: AnnalStatus.PROCESSING, sender: pick(senderUser, 'id', 'firstName', 'lastName'), }, true, @@ -248,7 +248,7 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { updatedAt: JsonLike.DATE, semesterId: semester.code, type: annalType, - status: CommentStatus.PROCESSING, + status: AnnalStatus.PROCESSING, sender: pick(senderUser, 'id', 'firstName', 'lastName'), }, true, @@ -278,7 +278,7 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { updatedAt: JsonLike.DATE, semesterId: semester.code, type: annalType, - status: CommentStatus.PROCESSING, + status: AnnalStatus.PROCESSING, sender: pick(senderUser, 'id', 'firstName', 'lastName'), }, true, diff --git a/test/e2e/ue/comments/delete-comment.e2e-spec.ts b/test/e2e/ue/comments/delete-comment.e2e-spec.ts index 3b3d35e1..a7b1f32d 100644 --- a/test/e2e/ue/comments/delete-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/delete-comment.e2e-spec.ts @@ -85,16 +85,16 @@ const DeleteComment = e2eSuite('DELETE /ue/comments/:commentId', (app) => { isAnonymous: comment1.isAnonymous, body: comment1.body, answers: [], + reports: [], upvotes: 1, upvoted: true, - status: CommentStatus.DELETED | CommentStatus.VALIDATED, + status: CommentStatus.DELETED, }); await app() .get(PrismaService) .normalize.ueComment.delete({ args: { - includeDeletedReplied: false, - includeLastValidatedBody: false, + includeDeleted: true, userId: user.id, }, where: { id: comment1.id }, diff --git a/test/e2e/ue/comments/delete-reply.e2e-spec.ts b/test/e2e/ue/comments/delete-reply.e2e-spec.ts index 3eac04da..7969b86e 100644 --- a/test/e2e/ue/comments/delete-reply.e2e-spec.ts +++ b/test/e2e/ue/comments/delete-reply.e2e-spec.ts @@ -85,7 +85,8 @@ const DeleteCommentReply = e2eSuite('DELETE /ue/comments/reply/{replyId}', (app) createdAt: reply.createdAt, updatedAt: reply.updatedAt, body: reply.body, - status: CommentStatus.DELETED | CommentStatus.VALIDATED, + status: CommentStatus.DELETED, + reports: [], }); await app() .get(PrismaService) diff --git a/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts b/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts index e3971c90..ea2ecc2d 100644 --- a/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts @@ -61,14 +61,7 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => .get(`/ue/comments/${comment.id}`) .expectUeComment({ ueof, - ...(omit( - comment, - 'semesterId', - 'authorId', - 'deletedAt', - 'validatedAt', - 'lastValidatedBody', - ) as Required), + ...(omit(comment, 'semesterId', 'authorId', 'deletedAt') as Required), answers: [ { ...omit(reply, 'authorId', 'deletedAt', 'commentId'), @@ -79,6 +72,7 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => }, createdAt: reply.createdAt, updatedAt: reply.updatedAt, + reports: [], }, ], author: { @@ -91,6 +85,7 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => createdAt: comment.createdAt, semester: semester.code, upvotes: 1, + reports: [], upvoted: false, })); @@ -101,14 +96,7 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => .get(`/ue/comments/${comment.id}`) .expectUeComment({ ueof, - ...(omit( - comment, - 'semesterId', - 'authorId', - 'deletedAt', - 'validatedAt', - 'lastValidatedBody', - ) as Required), + ...(omit(comment, 'semesterId', 'authorId', 'deletedAt') as Required), answers: [ { ...omit(reply, 'authorId', 'deletedAt', 'commentId'), @@ -119,12 +107,14 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => firstName: user.firstName, lastName: user.lastName, }, + reports: [], }, ], updatedAt: comment.updatedAt, createdAt: comment.createdAt, semester: semester.code, upvotes: 1, + reports: [], upvoted: true, }); }); diff --git a/test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts b/test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts new file mode 100644 index 00000000..a79cfc6a --- /dev/null +++ b/test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts @@ -0,0 +1,40 @@ +import * as pactum from 'pactum'; +import { ERROR_CODE } from 'src/exceptions'; +import { createCommentReportReason, createUser } from '../../../../test/utils/fakedb'; +import { e2eSuite } from '../../../../test/utils/test_utils'; +import { PermissionManager } from '../../../../src/utils'; + +const GetCommentReportReason = e2eSuite('GET /ue/comments/reports/reasons', (app) => { + const user = createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE') }); + const userNoPermission = createUser(app); + createCommentReportReason(app, { name: 'meh' }); + createCommentReportReason(app, { name: 'bad' }); + + it('should return a 401 as user is not authenticated', () => + pactum.spec().get(`/ue/comments/reports/reasons`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should fail as the user does not have the required permissions', () => + pactum + .spec() + .withBearerToken(userNoPermission.token) + .get(`/ue/comments/reports/reasons`) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_SEE_OPINIONS_UE')); + + it('should return an array of report reasons', () => + pactum + .spec() + .withBearerToken(user.token) + .get(`/ue/comments/reports/reasons`) + .expectJsonLength(2) + .expectJsonLike([ + { + name: 'bad', + description: 'bonjour', + }, + { + name: 'meh', + description: 'bonjour', + }, + ])); +}); +export default GetCommentReportReason; diff --git a/test/e2e/ue/comments/get-comment.e2e-spec.ts b/test/e2e/ue/comments/get-comment.e2e-spec.ts index a5474a9c..fc473912 100644 --- a/test/e2e/ue/comments/get-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment.e2e-spec.ts @@ -95,20 +95,11 @@ const GetCommentsE2ESpec = e2eSuite('GET /ue/comments', (app) => { }); it('should return the first page of comments', async () => { - await app() - .get(PrismaService) - .ueComment.updateMany({ - data: { - lastValidatedBody: 'I like to spread fake news in my comments !', - }, - }); const extendedComments = await app() .get(PrismaService) .normalize.ueComment.findMany({ args: { userId: user.id, - includeDeletedReplied: false, - includeLastValidatedBody: false, }, }); const commentsFiltered = { @@ -119,7 +110,9 @@ const GetCommentsE2ESpec = e2eSuite('GET /ue/comments', (app) => { : b.upvotes - a.upvotes, ) .slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE) - .map((comment) => ({ ...comment, ue })), + .map((comment) => { + return { ...comment, ue }; + }), itemCount: comments.length, itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, }; @@ -139,8 +132,6 @@ const GetCommentsE2ESpec = e2eSuite('GET /ue/comments', (app) => { .normalize.ueComment.findMany({ args: { userId: user.id, - includeDeletedReplied: false, - includeLastValidatedBody: false, }, }); return pactum @@ -159,27 +150,24 @@ const GetCommentsE2ESpec = e2eSuite('GET /ue/comments', (app) => { : b.upvotes - a.upvotes, ) .slice(app().get(ConfigModule).PAGINATION_PAGE_SIZE, app().get(ConfigModule).PAGINATION_PAGE_SIZE * 2) - .map((comment) => ({ ...comment, ue })), + .map((comment) => { + return { ...comment, ue }; + }), itemCount: comments.length, itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, }); }); - it('should return comments with lastValidatedBodies', async () => { - await app() - .get(PrismaService) - .ueComment.updateMany({ - data: { - lastValidatedBody: 'I like to spread fake news in my comments !', - }, - }); + it('should return comments with moderator data', async () => { const extendedComments = await app() .get(PrismaService) .normalize.ueComment.findMany({ args: { userId: user.id, - includeDeletedReplied: true, - includeLastValidatedBody: true, + includeDeleted: true, + includeHiddenComments: true, + includeReports: true, + bypassAnonymousData: true, }, }); const commentsFiltered = { diff --git a/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts b/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts new file mode 100644 index 00000000..958429c9 --- /dev/null +++ b/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts @@ -0,0 +1,199 @@ +import { faker } from '@faker-js/faker'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from 'src/exceptions'; +import { ConfigModule } from '../../../../src/config/config.module'; +import { PrismaService } from '../../../../src/prisma/prisma.service'; +import { + createBranch, + createBranchOption, + createComment, + createCommentReply, + createCommentReplyReport, + createCommentReport, + createCommentReportReason, + createSemester, + createUe, + createUeof, + createUser, + FakeComment, +} from '../../../utils/fakedb'; +import { e2eSuite } from '../../../utils/test_utils'; +import { Prisma } from '@prisma/client'; +import { PermissionManager } from '../../../../src/utils'; + +const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { + const userModerator = createUser(app, { + login: 'user2', + permissions: new PermissionManager() + .with('API_SEE_OPINIONS_UE') + .with('API_GIVE_OPINIONS_UE') + .with('API_MODERATE_COMMENTS'), + }); + const semester = createSemester(app); + const branch = createBranch(app); + const branchOption = createBranchOption(app, { branch }); + const ue = createUe(app); + const ueof = createUeof(app, { branchOptions: [branchOption], semesters: [semester], ue }); + const reportReason = createCommentReportReason(app, { name: 'meh' }); + const comments: FakeComment[] = []; + for (let i = 1; i <= 22; i++) { + const commentAuthor = createUser(app, { + login: `user${i + 10}`, + studentId: i + 10, + }); + const comment = createComment(app, { ueof, user: commentAuthor, semester }); + comments.push(comment); + const commentReporter = createUser(app, { + login: `user${i + 100}`, + studentId: i + 100, + }); + createCommentReport( + app, + { user: commentReporter, comment, reason: reportReason }, + { + body: faker.word.words(), + reportedBody: comment.body, + mitigated: i % 2 === 0, + }, + ); + } + + const reportedCommentsWhereClause: Prisma.UeCommentWhereInput = { + OR: [ + { reports: { some: { mitigated: false } } }, // Le commentaire est signalé + { answers: { some: { reports: { some: { mitigated: false } } } } }, // Une de ses réponses est signalée + ], + }; + + it('should return a 401 as user is not authenticated', () => + pactum.spec().get('/ue/comments/reports').expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 403 as user does not have permission to moderate comments', async () => { + const userNoPermission = await createUser(app, {}, true); + const userNotModerator = await createUser( + app, + { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }, + true, + ); + await pactum + .spec() + .withBearerToken(userNoPermission.token) + .get('/ue/comments/reports') + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); + await pactum + .spec() + .withBearerToken(userNotModerator.token) + .get('/ue/comments/reports') + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); + }); + + it('should return a 403 as user uses a wrong page', () => + pactum + .spec() + .withBearerToken(userModerator.token) + .get('/ue/comments/reports') + .withQueryParams({ + page: -1, + }) + .expectAppError(ERROR_CODE.PARAM_NOT_POSITIVE, 'page')); + + it('should return the first page of reported comments', async () => { + const comments = await app() + .get(PrismaService) + .normalize.ueComment.findMany({ + args: { + userId: userModerator.id, + bypassAnonymousData: true, + includeDeleted: false, + includeHiddenComments: true, + includeReports: true, + }, + where: reportedCommentsWhereClause, + }); + + const commentsFiltered = { + items: comments.slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE), + itemCount: comments.length, + itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, + }; + return pactum + .spec() + .withBearerToken(userModerator.token) + .get('/ue/comments/reports') + .$expectRegexableJson(commentsFiltered); + }); + + it('should return the second page of reported comments', async () => { + const comments = await app() + .get(PrismaService) + .normalize.ueComment.findMany({ + args: { + userId: userModerator.id, + bypassAnonymousData: true, + includeDeleted: false, + includeHiddenComments: true, + includeReports: true, + }, + where: reportedCommentsWhereClause, + }); + const PAGINATION_PAGE_SIZE = app().get(ConfigModule).PAGINATION_PAGE_SIZE; + const commentsFiltered = { + items: comments.slice(PAGINATION_PAGE_SIZE, 2 * PAGINATION_PAGE_SIZE), + itemCount: comments.length, + itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, + }; + return pactum + .spec() + .withBearerToken(userModerator.token) + .get('/ue/comments/reports') + .withQueryParams({ page: 2 }) + .$expectRegexableJson(commentsFiltered); + }); + + it('should include comments with reported replies', async () => { + const cleanCommentAuthor = await createUser(app, { login: 'cleanAuthor' }, true); + const cleanComment = await createComment(app, { ueof, user: cleanCommentAuthor, semester }, {}, true); + const replyAuthor = await createUser(app, { login: 'replyAuthor' }, true); + const commentReply = await createCommentReply(app, { user: replyAuthor, comment: cleanComment }, {}, true); + const replyReporter = await createUser(app, { login: 'replyReporter' }, true); + await createCommentReplyReport( + app, + { user: replyReporter, reply: commentReply, reason: reportReason }, + { + body: 'This reply is problematic', + reportedBody: commentReply.body, + mitigated: false, + }, + true, + ); + + const comments = await app() + .get(PrismaService) + .normalize.ueComment.findMany({ + args: { + userId: userModerator.id, + bypassAnonymousData: true, + includeDeleted: false, + includeHiddenComments: true, + includeReports: true, + }, + where: reportedCommentsWhereClause, + }); + const PAGINATION_PAGE_SIZE = app().get(ConfigModule).PAGINATION_PAGE_SIZE; + const commentsFiltered = { + items: comments.slice(0, PAGINATION_PAGE_SIZE), + itemCount: comments.length, + itemsPerPage: PAGINATION_PAGE_SIZE, + }; + + expect(commentsFiltered.items).toContainEqual(expect.objectContaining({ id: cleanComment.id })); + + await pactum + .spec() + .withBearerToken(userModerator.token) + .get('/ue/comments/reports') + .$expectRegexableJson(commentsFiltered); + }); +}); + +export default GetReportedComments; diff --git a/test/e2e/ue/comments/index.ts b/test/e2e/ue/comments/index.ts index a32c565b..8006b8fc 100644 --- a/test/e2e/ue/comments/index.ts +++ b/test/e2e/ue/comments/index.ts @@ -1,26 +1,38 @@ import { INestApplication } from '@nestjs/common'; -import GetCommentsE2ESpec from './get-comment.e2e-spec'; import DeleteComment from './delete-comment.e2e-spec'; import DeleteCommentReply from './delete-reply.e2e-spec'; import DeleteUpvote from './delete-upvote.e2e-spec'; +import GetCommentFromIdE2ESpec from './get-comment-from-id.e2e-spec'; +import GetCommentReportReason from './get-comment-report-reasons.e2e-spec'; +import GetCommentsE2ESpec from './get-comment.e2e-spec'; +import GetReportedComments from './get-reported-comments.e2e-spec'; +import PostReportCommentReply from './post-comment-reply-report.e2e-spec'; +import PostReportComment from './post-comment-report.e2e-spec'; import PostCommment from './post-comment.e2e-spec'; import PostCommmentReply from './post-reply.e2e-spec'; import PostUpvote from './post-upvote.e2e-spec'; +import UpdateCommentReplyReport from './update-comment-reply-report.e2e-spec'; +import UpdateCommentReport from './update-comment-report.e2e-spec'; import UpdateComment from './update-comment.e2e-spec'; import UpdateCommentReply from './update-reply.e2e-spec'; -import GetCommentFromIdE2ESpec from './get-comment-from-id.e2e-spec'; export default function CommentsE2ESpec(app: () => INestApplication) { describe('Comments', () => { GetCommentsE2ESpec(app); + GetCommentFromIdE2ESpec(app); + GetReportedComments(app); + GetCommentReportReason(app); PostCommment(app); PostCommmentReply(app); + PostUpvote(app); + PostReportComment(app); + PostReportCommentReply(app); UpdateComment(app); - DeleteComment(app); UpdateCommentReply(app); + UpdateCommentReport(app); + UpdateCommentReplyReport(app); + DeleteComment(app); DeleteCommentReply(app); - PostUpvote(app); DeleteUpvote(app); - GetCommentFromIdE2ESpec(app); }); } diff --git a/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts new file mode 100644 index 00000000..fc14e280 --- /dev/null +++ b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts @@ -0,0 +1,120 @@ +import { PermissionManager } from '../../../../src/utils'; +import { + createBranch, + createBranchOption, + createComment, + createCommentReply, + createCommentReportReason, + createSemester, + createUe, + createUeof, + createUser, +} from '../../../utils/fakedb'; +import { Dummies, e2eSuite, JsonLike } from '../../../utils/test_utils'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from 'src/exceptions'; + +const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', (app) => { + const commentAuthor = createUser(app, { + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), + }); + const replyAuthor = createUser(app, { + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), + }); + const userNotAuthor = createUser(app, { + login: 'user2', + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), + }); + const userNoPermission = createUser(app); + const semester = createSemester(app); + const branch = createBranch(app); + const branchOption = createBranchOption(app, { branch }); + const ue = createUe(app); + const ueof = createUeof(app, { branchOptions: [branchOption], semesters: [semester], ue }); + const comment = createComment(app, { ueof, user: commentAuthor, semester }); + const reply = createCommentReply(app, { user: replyAuthor, comment }); + const reportReason = createCommentReportReason(app, { name: 'meh' }); + + it('should return a 401 as user is not authenticated', () => + pactum.spec().post(`/ue/comments/reply/${reply.id}/report`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should fail as the user does not have the required permissions', () => + pactum + .spec() + .withBearerToken(userNoPermission.token) + .post(`/ue/comments/reply/${reply.id}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_SEE_OPINIONS_UE')); + + it('should return 400 because reply id is not a valid UUID', async () => + pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/reply/notauuid/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'replyId')); + + it('should return 404 because reply does not exist', () => + pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/reply/${Dummies.UUID}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.NO_SUCH_REPLY)); + + it('should return 403 because user is reply author', () => + pactum + .spec() + .withBearerToken(replyAuthor.token) + .post(`/ue/comments/reply/${reply.id}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.IS_COMMENT_AUTHOR)); + + it('should return 404 because report reason does not exist', () => + pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/reply/${reply.id}/report`) + .withBody({ + body: "it's offensive", + reason: 'idontexist', + }) + .expectAppError(ERROR_CODE.NO_SUCH_REPORT_REASON)); + + it('should return a report', () => + pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/reply/${reply.id}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectUeCommentReport({ + id: JsonLike.UUID, + body: "it's offensive", + reason: reportReason.name, + createdAt: JsonLike.DATE, + reportedBody: reply.body, + mitigated: false, + user: { + id: userNotAuthor.id, + firstName: userNotAuthor.firstName, + lastName: userNotAuthor.lastName, + }, + }, true)); +}); + +export default ReportCommentReply; diff --git a/test/e2e/ue/comments/post-comment-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts new file mode 100644 index 00000000..62098a23 --- /dev/null +++ b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts @@ -0,0 +1,115 @@ +import { PermissionManager } from '../../../../src/utils'; +import { + createBranch, + createBranchOption, + createComment, + createCommentReportReason, + createSemester, + createUe, + createUeof, + createUser, +} from '../../../utils/fakedb'; +import { Dummies, e2eSuite, JsonLike } from '../../../utils/test_utils'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from 'src/exceptions'; + +const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => { + const user = createUser(app, { + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), + }); + const userNotAuthor = createUser(app, { + login: 'user2', + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), + }); + const userNoPermission = createUser(app); + const semester = createSemester(app); + const branch = createBranch(app); + const branchOption = createBranchOption(app, { branch }); + const ue = createUe(app); + const ueof = createUeof(app, { branchOptions: [branchOption], semesters: [semester], ue }); + const comment = createComment(app, { ueof, user, semester }); + const reportReason = createCommentReportReason(app, { name: 'meh' }); + + it('should return a 401 as user is not authenticated', () => + pactum.spec().post(`/ue/comments/${comment.id}/report`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should fail as the user does not have the required permissions', () => + pactum + .spec() + .withBearerToken(userNoPermission.token) + .post(`/ue/comments/${comment.id}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_SEE_OPINIONS_UE')); + + it('should return 400 because comment id is not a valid UUID', () => + pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/notauuid/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'commentId')); + + it('should return 404 because comment does not exist', () => + pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/${Dummies.UUID}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.NO_SUCH_COMMENT)); + + it('should return 403 because user is comment author', () => + pactum + .spec() + .withBearerToken(user.token) + .post(`/ue/comments/${comment.id}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.IS_COMMENT_AUTHOR)); + + it('should return 404 because report reason does not exist', () => + pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/${comment.id}/report`) + .withBody({ + body: "it's offensive", + reason: 'idontexist', + }) + .expectAppError(ERROR_CODE.NO_SUCH_REPORT_REASON)); + + it('should return a report', () => + pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/${comment.id}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectUeCommentReport({ + id: JsonLike.UUID, + body: "it's offensive", + reason: reportReason.name, + reportedBody: comment.body, + createdAt: JsonLike.DATE, + mitigated: false, + user: { + id: userNotAuthor.id, + firstName: userNotAuthor.firstName, + lastName: userNotAuthor.lastName, + }, + },true)); +}); + +export default ReportComment; diff --git a/test/e2e/ue/comments/post-comment.e2e-spec.ts b/test/e2e/ue/comments/post-comment.e2e-spec.ts index fc37672e..0c8598f3 100644 --- a/test/e2e/ue/comments/post-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment.e2e-spec.ts @@ -128,10 +128,10 @@ const PostCommment = e2eSuite('POST /ue/comments', (app) => { isAnonymous: true, body: 'Cette UE est troooop bien', answers: [], + reports: [], upvotes: 0, upvoted: false, - status: CommentStatus.UNVERIFIED, - lastValidatedBody: null, + status: CommentStatus.ACTIVE, }, true, ); @@ -177,10 +177,10 @@ const PostCommment = e2eSuite('POST /ue/comments', (app) => { isAnonymous: false, body: 'Cette UE est troooop bien', answers: [], + reports: [], upvotes: 0, upvoted: false, - status: CommentStatus.UNVERIFIED, - lastValidatedBody: null, + status: CommentStatus.ACTIVE, }, true, ); diff --git a/test/e2e/ue/comments/post-reply.e2e-spec.ts b/test/e2e/ue/comments/post-reply.e2e-spec.ts index 2ab7a9d8..0e998805 100644 --- a/test/e2e/ue/comments/post-reply.e2e-spec.ts +++ b/test/e2e/ue/comments/post-reply.e2e-spec.ts @@ -120,7 +120,8 @@ const PostCommmentReply = e2eSuite('POST /ue/comments/{commentId}/reply', (app) body: 'heyhey', createdAt: JsonLike.DATE, updatedAt: JsonLike.DATE, - status: CommentStatus.VALIDATED, + status: CommentStatus.ACTIVE, + reports: [], }, true, ); diff --git a/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts b/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts new file mode 100644 index 00000000..9d8a363d --- /dev/null +++ b/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts @@ -0,0 +1,96 @@ +import * as pactum from 'pactum'; +import { ERROR_CODE } from 'src/exceptions'; +import { PrismaService } from '../../../../src/prisma/prisma.service'; +import { + createBranch, + createBranchOption, + createComment, + createCommentReply, + createCommentReplyReport, + createCommentReportReason, + createSemester, + createUe, + createUeof, + createUser, +} from '../../../utils/fakedb'; +import { Dummies, e2eSuite } from '../../../utils/test_utils'; +import { PermissionManager } from '../../../../src/utils'; + +const UpdateCommentReplyReport = e2eSuite('PATCH /ue/comments/reply/{replyId}/{reportId}', (app) => { + const commentAuthor = createUser(app, { + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), + }); + const replyAuthor = createUser(app, { + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), + }); + const moderator = createUser(app, { permissions: new PermissionManager().with('API_MODERATE_COMMENTS') }); + const semester = createSemester(app); + const branch = createBranch(app); + const branchOption = createBranchOption(app, { branch }); + const ue = createUe(app); + const ueof = createUeof(app, { branchOptions: [branchOption], semesters: [semester], ue }); + const reason = createCommentReportReason(app, { name: 'meh' }); + const comment = createComment(app, { user: commentAuthor, ueof, semester }); + const reply = createCommentReply(app, { user: replyAuthor, comment }); + const report = createCommentReplyReport(app, { reply, reason, user: commentAuthor }); + + it('should return a 401 as user is not authenticated', () => + pactum.spec().patch(`/ue/comments/reply/${reply.id}/${report.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 403 as user does not have permission to moderate comments', async () => { + const userNoPermission = await createUser(app, {}, true); + const userNotModerator = await createUser( + app, + { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }, + true, + ); + await pactum + .spec() + .withBearerToken(userNoPermission.token) + .patch(`/ue/comments/reply/${reply.id}/${report.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); + await pactum + .spec() + .withBearerToken(userNotModerator.token) + .patch(`/ue/comments/reply/${reply.id}/${report.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); + }); + + it('should return 404 as replyId is invalid', () => + pactum + .spec() + .withBearerToken(moderator.token) + .patch(`/ue/comments/reply/${Dummies.UUID}/${report.id}`) + .expectAppError(ERROR_CODE.NO_SUCH_REPLY)); + + it('should return 404 as reportId is invalid', () => + pactum + .spec() + .withBearerToken(moderator.token) + .patch(`/ue/comments/reply/${reply.id}/${Dummies.UUID}`) + .expectAppError(ERROR_CODE.NO_SUCH_REPORT)); + + it('should return the updated report', async () => { + await pactum + .spec() + .withBearerToken(moderator.token) + .patch(`/ue/comments/reply/${reply.id}/${report.id}`) + .expectUeCommentReport({ + ...report, + mitigated: true, + createdAt: report.createdAt, + }); + await app() + .get(PrismaService) + .ueCommentReplyReport.update({ + where: { + id: report.id, + }, + data: { + mitigated: false, + }, + }); + }); +}); + +export default UpdateCommentReplyReport; diff --git a/test/e2e/ue/comments/update-comment-report.e2e-spec.ts b/test/e2e/ue/comments/update-comment-report.e2e-spec.ts new file mode 100644 index 00000000..202dabdb --- /dev/null +++ b/test/e2e/ue/comments/update-comment-report.e2e-spec.ts @@ -0,0 +1,87 @@ +import * as pactum from 'pactum'; +import { ERROR_CODE } from 'src/exceptions'; +import { PrismaService } from '../../../../src/prisma/prisma.service'; +import { + createBranch, + createBranchOption, + createComment, + createCommentReport, + createCommentReportReason, + createSemester, + createUe, + createUeof, + createUser, +} from '../../../utils/fakedb'; +import { Dummies, e2eSuite } from '../../../utils/test_utils'; +import { PermissionManager } from '../../../../src/utils'; + +const UpdateCommentReport = e2eSuite('PATCH /ue/comments/:commentId/:reportId', (app) => { + const user = createUser(app, { permissions: new PermissionManager().with('API_MODERATE_COMMENTS') }); + const semester = createSemester(app); + const branch = createBranch(app); + const branchOption = createBranchOption(app, { branch }); + const ue = createUe(app); + const ueof = createUeof(app, { branchOptions: [branchOption], semesters: [semester], ue }); + const reason = createCommentReportReason(app, { name: 'meh' }); + const comment = createComment(app, { user, ueof, semester }); + const report = createCommentReport(app, { comment, reason, user }); + + it('should return a 401 as user is not authenticated', () => + pactum.spec().patch(`/ue/comments/${comment.id}/${report.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 403 as user does not have permission to moderate comments', async () => { + const userNoPermission = await createUser(app, {}, true); + const userNotModerator = await createUser( + app, + { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }, + true, + ); + await pactum + .spec() + .withBearerToken(userNoPermission.token) + .patch(`/ue/comments/${comment.id}/${report.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); + await pactum + .spec() + .withBearerToken(userNotModerator.token) + .patch(`/ue/comments/${comment.id}/${report.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); + }); + + it('should return 404 as commentId is invalid', () => + pactum + .spec() + .withBearerToken(user.token) + .patch(`/ue/comments/${Dummies.UUID}/${report.id}`) + .expectAppError(ERROR_CODE.NO_SUCH_COMMENT)); + + it('should return 404 as reportId is invalid', () => + pactum + .spec() + .withBearerToken(user.token) + .patch(`/ue/comments/${comment.id}/${Dummies.UUID}`) + .expectAppError(ERROR_CODE.NO_SUCH_REPORT)); + + it('should return the updated report', async () => { + await pactum + .spec() + .withBearerToken(user.token) + .patch(`/ue/comments/${comment.id}/${report.id}`) + .expectUeCommentReport({ + ...report, + mitigated: true, + }); + await app() + .get(PrismaService) + .ueCommentReport.update({ + where: { + id: report.id, + }, + data: { + mitigated: false, + }, + }); + }); +}); + +export default UpdateCommentReport; diff --git a/test/e2e/ue/comments/update-comment.e2e-spec.ts b/test/e2e/ue/comments/update-comment.e2e-spec.ts index 214f8dc6..fdc77ebb 100644 --- a/test/e2e/ue/comments/update-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/update-comment.e2e-spec.ts @@ -131,10 +131,10 @@ const UpdateComment = e2eSuite('PATCH /ue/comments/:commentId', (app) => { isAnonymous: true, body: 'Cette UE est troooop bien', answers: [], + reports: [], upvotes: 1, upvoted: false, - status: CommentStatus.UNVERIFIED, - lastValidatedBody: comment.body, + status: CommentStatus.ACTIVE, }); await app().get(PrismaService).ueComment.deleteMany(); await createComment(app, { ueof, user, semester }, comment, true); @@ -164,10 +164,10 @@ const UpdateComment = e2eSuite('PATCH /ue/comments/:commentId', (app) => { isAnonymous: false, body: comment.body, answers: [], + reports: [], upvotes: 1, upvoted: false, - status: CommentStatus.VALIDATED, - lastValidatedBody: null, + status: CommentStatus.ACTIVE, }); await app().get(PrismaService).ueComment.deleteMany(); await createComment(app, { ueof, user, semester }, comment, true); diff --git a/test/e2e/ue/comments/update-reply.e2e-spec.ts b/test/e2e/ue/comments/update-reply.e2e-spec.ts index e505e9e0..5715fc14 100644 --- a/test/e2e/ue/comments/update-reply.e2e-spec.ts +++ b/test/e2e/ue/comments/update-reply.e2e-spec.ts @@ -125,7 +125,8 @@ const UpdateCommentReply = e2eSuite('PATCH /ue/comments/reply/{replyId}', (app) createdAt: JsonLike.DATE, updatedAt: JsonLike.DATE, body: "Je m'appelle Alban Ichou et j'approuve ce commentaire", - status: CommentStatus.VALIDATED, + status: CommentStatus.ACTIVE, + reports: [], }); return app().get(PrismaService).ueCommentReply.deleteMany(); }); diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index c368ac9b..22432bb8 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -34,14 +34,16 @@ import { RawUserPrivacy, RawApiKey, RawApiApplication, + RawUeCommentReport, + RawUeCommentReplyReport, } from '../../src/prisma/types'; import { faker } from '@faker-js/faker'; import { AuthService } from '../../src/auth/auth.service'; import { PrismaService } from '../../src/prisma/prisma.service'; import { AppProvider } from './test_utils'; -import { Permission, Sex, TimetableEntryType, UserType } from '@prisma/client'; +import { Permission, Sex, TimetableEntryType, UeCommentReportReason, UserType } from '@prisma/client'; import { CommentStatus } from '../../src/ue/comments/interfaces/comment.interface'; -import { UeAnnalFile } from '../../src/ue/annals/interfaces/annal.interface'; +import { AnnalStatus, UeAnnalFile } from '../../src/ue/annals/interfaces/annal.interface'; import { omit, PermissionManager, pick, translationSelect } from '../../src/utils'; import { DEFAULT_APPLICATION } from '../../prisma/seed/utils'; @@ -106,11 +108,17 @@ export type FakeUeof = Partial; export type FakeUeStarCriterion = Partial; export type FakeUeStarVote = Partial; -export type FakeComment = Partial & { status: Exclude }; +export type FakeComment = Partial & { + status: Exclude; + reports: FakeCommentReport[]; +}; export type FakeCommentUpvote = Partial; export type FakeCommentReply = Partial & { - status: Exclude; + status: Exclude; }; +export type FakeCommentReport = Partial; +export type FakeCommentReplyReport = Partial; +export type FakeCommentReportReason = Partial; export type FakeUeCreditCategory = Partial; export type FakeUeAnnalType = Partial; export type FakeUeAnnal = Partial; @@ -199,7 +207,7 @@ export interface FakeEntityMap { comment: { entity: FakeComment; params: CreateCommentParameters & { - status: Exclude; + status: Exclude; }; deps: { user: FakeUser; ueof: FakeUeof; semester: FakeSemester }; }; @@ -211,10 +219,24 @@ export interface FakeEntityMap { commentReply: { entity: FakeCommentReply; params: CreateCommentReplyParameters & { - status: Exclude; + status: Exclude; }; deps: { user: FakeUser; comment: FakeComment }; }; + commentReport: { + entity: FakeCommentReport; + params: CreateCommentReport; + deps: { comment: FakeComment; user: FakeUser; reason: FakeCommentReportReason }; + }; + commentReplyReport: { + entity: FakeCommentReplyReport; + params: CreateCommentReplyReport; + deps: { reply: FakeCommentReply; user: FakeUser; reason: FakeCommentReportReason }; + }; + commentReportReason: { + entity: FakeCommentReportReason; + params: CreateCommentReportReason; + }; ueCreditCategory: { entity: FakeUeCreditCategory; params: CreateUeCreditCategoryParameters; @@ -226,7 +248,7 @@ export interface FakeEntityMap { annal: { entity: FakeUeAnnal; params: { - status: CommentStatus; + status: AnnalStatus; }; deps: { type: FakeUeAnnalType; @@ -703,15 +725,15 @@ export const createAnnalType = entityFaker( export const createAnnal = entityFaker( 'annal', - { status: CommentStatus.VALIDATED }, + { status: AnnalStatus.VALIDATED }, async (app, { semester, sender, type, ueof }, { status }) => app() .get(PrismaService) .normalize.ueAnnal.create({ data: { - uploadComplete: !(status & CommentStatus.PROCESSING), - deletedAt: status & CommentStatus.DELETED ? faker.date.recent() : null, - validatedAt: status & CommentStatus.VALIDATED ? faker.date.past() : null, + uploadComplete: !(status & AnnalStatus.PROCESSING), + deletedAt: status & AnnalStatus.DELETED ? faker.date.recent() : null, + validatedAt: status & AnnalStatus.VALIDATED ? faker.date.past() : null, semesterId: semester.code, senderId: sender.id, typeId: type.id, @@ -951,21 +973,21 @@ export const createUeRating = entityFaker( }, ); -export type CreateCommentParameters = Omit; +export type CreateCommentParameters = Omit; export const createComment = entityFaker( 'comment', { body: faker.word.words, isAnonymous: faker.datatype.boolean, - status: CommentStatus.VALIDATED, + status: CommentStatus.ACTIVE, }, async (app, dependencies, params) => { + delete (params as any).reports; const rawFakeData = await app() .get(PrismaService) .ueComment.create({ data: { ...omit(params, 'status'), - validatedAt: params.status & CommentStatus.VALIDATED ? new Date() : undefined, deletedAt: params.status & CommentStatus.DELETED ? new Date() : undefined, ueof: { connect: { @@ -984,7 +1006,7 @@ export const createComment = entityFaker( }, }, }); - return { ...omit(rawFakeData, 'ueofCode', 'authorId', 'semesterId'), status: params.status }; + return { ...omit(rawFakeData, 'ueofCode', 'authorId', 'semesterId'), status: params.status, reports: [] }; }, ); @@ -1014,7 +1036,7 @@ export const createCommentReply = entityFaker( 'commentReply', { body: faker.word.words, - status: CommentStatus.VALIDATED, + status: CommentStatus.ACTIVE, }, async (app, dependencies, params) => { const rawFakeReply = await app() @@ -1038,6 +1060,100 @@ export const createCommentReply = entityFaker( return { ...rawFakeReply, status: params.status }; }, ); +export type CreateCommentReport = FakeCommentReport; +export const createCommentReport = entityFaker( + 'commentReport', + { + body: faker.word.words(), + mitigated: faker.datatype.boolean(), + createdAt: faker.date.recent(), + }, + async (app, deps, params) => { + return app() + .get(PrismaService) + .ueCommentReport.create({ + data: { + ...omit(params, 'userId', 'commentId', 'reasonId'), + reportedBody: deps.comment.body, + comment: { + connect: { + id: deps.comment.id, + }, + }, + user: { + connect: { + id: deps.user.id, + }, + }, + reason: { + connect: { + name: deps.reason.name, + }, + }, + }, + }); + }, +); +export type CreateCommentReplyReport = FakeCommentReplyReport; +export const createCommentReplyReport = entityFaker( + 'commentReplyReport', + { + body: faker.word.words(), + mitigated: faker.datatype.boolean(), + createdAt: faker.date.recent(), + }, + async (app, deps, params) => { + return app() + .get(PrismaService) + .ueCommentReplyReport.create({ + data: { + ...omit(params, 'userId', 'replyId', 'reasonId'), + reportedBody: deps.reply.body, + reply: { + connect: { + id: deps.reply.id, + }, + }, + user: { + connect: { + id: deps.user.id, + }, + }, + reason: { + connect: { + name: deps.reason.name, + }, + }, + }, + }); + }, +); + +export type CreateCommentReportReason = FakeCommentReportReason; +export const createCommentReportReason = entityFaker( + 'commentReportReason', + { + name: faker.word.adjective(), + }, + async (app, params) => + app() + .get(PrismaService) + .ueCommentReportReason.create({ + data: { + ...omit(params, 'descriptionTranslationId'), + descriptionTranslation: { + create: { + id: params.descriptionTranslationId, + fr: 'bonjour', + en: null, + de: null, + es: null, + zh: null, + }, + }, + }, + }), +); export type CreateUeCreditCategoryParameters = FakeUeCreditCategory; export const createUeCreditCategory = entityFaker(