From 4014f77a4c0d73e3af23a6a90224fa8b6cd8b8c0 Mon Sep 17 00:00:00 2001 From: trivernis Date: Fri, 24 Jan 2020 18:39:45 +0100 Subject: [PATCH] Add reports for post - Add reports field on posts - add reportPost mutation - add getReports query - add createReportReason mutation - Add Report type and model - Add ReportReason type and model Closes #62 --- CHANGELOG.md | 2 + src/index.ts | 19 ++++- src/lib/dataAccess.ts | 2 + src/lib/errors/ReportAlreadyExistsError.ts | 10 +++ .../ReportReasonNameAlreadyExistsError.ts | 10 +++ src/lib/errors/ReportReasonNotFoundError.ts | 14 ++++ src/lib/models/Post.ts | 26 ++++++- src/lib/models/Report.ts | 73 +++++++++++++++++++ src/lib/models/ReportReason.ts | 24 ++++++ src/lib/models/index.ts | 2 + src/routes/GraphqlRoute.ts | 4 +- src/routes/UploadRoute.ts | 11 --- src/routes/graphql/MutationResolver.ts | 48 ++++++++++++ src/routes/graphql/QueryResolver.ts | 18 ++++- src/routes/graphql/schema.graphql | 47 +++++++++++- 15 files changed, 289 insertions(+), 21 deletions(-) create mode 100644 src/lib/errors/ReportAlreadyExistsError.ts create mode 100644 src/lib/errors/ReportReasonNameAlreadyExistsError.ts create mode 100644 src/lib/errors/ReportReasonNotFoundError.ts create mode 100644 src/lib/models/Report.ts create mode 100644 src/lib/models/ReportReason.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 267adef..add2222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - routine to cleanup orphaned media entries (not referenced by post, user, group) - delete handler for media to delete the corresponding file - type for create post to know if it is a media or text post (media posts are invisible until a media file is uploaded) +- reports and mutations to report posts and create reasons to report ### Removed @@ -49,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - only group admins can create group events - config behaviour to use all files that reside in the ./config directory with the .toml format - default response timeout from 2 minutes to 30 seconds +- cluster api to start workers with a 2 second delay each to avoid race conditions ### Fixed diff --git a/src/index.ts b/src/index.ts index ddd928e..b8ae6b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,16 @@ import App from "./app"; const numCPUs = require("os").cpus().length; +/** + * An asynchronous delay + * @param millis + */ +async function delay(millis: number) { + return new Promise(((resolve) => { + setTimeout(resolve, millis); + })); +} + if (cluster.isMaster) { console.log(`[CLUSTER-M] Master ${process.pid} is running`); @@ -21,9 +31,12 @@ if (cluster.isMaster) { }); }); - for (let i = 0; i < numCPUs; i++) { - cluster.fork(); - } + (async () => { + for (let i = 0; i < numCPUs; i++) { + cluster.fork(); + await delay(1000); + } + })(); } else { /** diff --git a/src/lib/dataAccess.ts b/src/lib/dataAccess.ts index ee880b7..471e38e 100644 --- a/src/lib/dataAccess.ts +++ b/src/lib/dataAccess.ts @@ -65,6 +65,8 @@ namespace dataaccess { models.Activity, models.BlacklistedPhrase, models.Media, + models.Report, + models.ReportReason, ]); } catch (err) { globals.logger.error(err.message); diff --git a/src/lib/errors/ReportAlreadyExistsError.ts b/src/lib/errors/ReportAlreadyExistsError.ts new file mode 100644 index 0000000..880028f --- /dev/null +++ b/src/lib/errors/ReportAlreadyExistsError.ts @@ -0,0 +1,10 @@ +import {BaseError} from "./BaseError"; + +/** + * An error that is thrown when a user tries to report the same post twice + */ +export class ReportAlreadyExistsError extends BaseError { + constructor() { + super("You've already reported this post for that reason."); + } +} diff --git a/src/lib/errors/ReportReasonNameAlreadyExistsError.ts b/src/lib/errors/ReportReasonNameAlreadyExistsError.ts new file mode 100644 index 0000000..f0ac4af --- /dev/null +++ b/src/lib/errors/ReportReasonNameAlreadyExistsError.ts @@ -0,0 +1,10 @@ +import {BaseError} from "./BaseError"; + +/** + * An error that is thrown when one tries to create a request with a name that already exists. + */ +export class ReportReasonNameAlreadyExistsError extends BaseError { + constructor(name: string) { + super(`A report reason with the name '${name}' already exists!`); + } +} diff --git a/src/lib/errors/ReportReasonNotFoundError.ts b/src/lib/errors/ReportReasonNotFoundError.ts new file mode 100644 index 0000000..5c859bb --- /dev/null +++ b/src/lib/errors/ReportReasonNotFoundError.ts @@ -0,0 +1,14 @@ +import * as httpStatus from "http-status"; +import {BaseError} from "./BaseError"; + +/** + * An error that is thrown when a report reason could not be found + */ +export class ReportReasonNotFoundError extends BaseError { + + public readonly statusCode = httpStatus.NOT_FOUND; + + constructor(reasonId: number) { + super(`A reason with the id '${reasonId}' could not be found`); + } +} diff --git a/src/lib/models/Post.ts b/src/lib/models/Post.ts index cda62d3..d1acda0 100644 --- a/src/lib/models/Post.ts +++ b/src/lib/models/Post.ts @@ -1,9 +1,20 @@ import * as sqz from "sequelize"; -import {BelongsTo, BelongsToMany, Column, CreatedAt, ForeignKey, Model, NotNull, Table} from "sequelize-typescript"; +import { + BelongsTo, + BelongsToMany, + Column, + CreatedAt, + ForeignKey, + HasMany, + Model, + NotNull, + Table, +} from "sequelize-typescript"; import markdown from "../markdown"; import {Activity} from "./Activity"; import {Media} from "./Media"; import {PostVote, VoteType} from "./PostVote"; +import {Report} from "./Report"; import {User} from "./User"; /** @@ -73,6 +84,12 @@ export class Post extends Model { // tslint:disable-next-line:completed-docs public rVotes: Array; + /** + * The reports on the post + */ + @HasMany(() => Report, "postId") + public rReports: Report[]; + /** * The date the post was created at */ @@ -100,6 +117,13 @@ export class Post extends Model { return await this.$get("rVotes") as Array; } + /** + * Returns the reports on the post + */ + public async reports({first, offset}: {first: number, offset: number}): Promise { + return await this.$get("rReports", {limit: first, offset}) as Report[]; + } + /** * Returns the markdown-rendered html content of the post */ diff --git a/src/lib/models/Report.ts b/src/lib/models/Report.ts new file mode 100644 index 0000000..9751b17 --- /dev/null +++ b/src/lib/models/Report.ts @@ -0,0 +1,73 @@ +import {BelongsTo, Column, ForeignKey, Model, NotNull, Table} from "sequelize-typescript"; +import {Post, User} from "./index"; +import {ReportReason} from "./ReportReason"; + +/** + * A report on a post + */ +@Table({underscored: true}) +export class Report extends Model { + + /** + * The id of the post that was reported + */ + @ForeignKey(() => Post) + @NotNull + @Column({allowNull: false, onDelete: "cascade", unique: "compositeIndex"}) + public postId: number; + + /** + * The id of the user who issued the report + */ + @ForeignKey(() => User) + @NotNull + @Column({allowNull: false, onDelete: "cascade", unique: "compositeIndex"}) + public userId: number; + + /** + * The reason for which the post was reported + */ + @ForeignKey(() => ReportReason) + @NotNull + @Column({allowNull: false, onDelete: "cascade", unique: "compositeIndex"}) + public reasonId: number; + + /** + * The user that reported the post + */ + @BelongsTo(() => User, "userId") + public rUser: User; + + /** + * The post that was reported + */ + @BelongsTo(() => Post, "postId") + public rPost: Post; + + /** + * The reason why the post was reported + */ + @BelongsTo(() => ReportReason, "reasonId") + public rReason: ReportReason; + + /** + * Returns the user that reported the post + */ + public async user(): Promise { + return await this.$get("rUser") as User; + } + + /** + * Returns the post that was reported + */ + public async post(): Promise { + return await this.$get("rPost") as Post; + } + + /** + * Returns the reason why the post was reported + */ + public async reason(): Promise { + return await this.$get("rReason") as ReportReason; + } +} diff --git a/src/lib/models/ReportReason.ts b/src/lib/models/ReportReason.ts new file mode 100644 index 0000000..775c806 --- /dev/null +++ b/src/lib/models/ReportReason.ts @@ -0,0 +1,24 @@ +import * as sqz from "sequelize"; +import {Column, Model, NotNull, Table, Unique} from "sequelize-typescript"; + +/** + * A reason for why a post was reported + */ +@Table({underscored: true}) +export class ReportReason extends Model { + + /** + * The name of the reason (short and precise) + */ + @NotNull + @Unique + @Column({unique: true, allowNull: false, type: sqz.STRING(64)}) + public name: string; + + /** + * A longer descripion of the reason + */ + @NotNull + @Column({allowNull: false, type: sqz.STRING(512)}) + public description: string; +} diff --git a/src/lib/models/index.ts b/src/lib/models/index.ts index 5fafbce..55fff59 100644 --- a/src/lib/models/index.ts +++ b/src/lib/models/index.ts @@ -14,3 +14,5 @@ export {EventParticipant} from "./EventParticipant"; export {Activity} from "./Activity"; export {BlacklistedPhrase} from "./BlacklistedPhrase"; export {Media} from "./Media"; +export {Report} from "./Report"; +export {ReportReason} from "./ReportReason"; diff --git a/src/routes/GraphqlRoute.ts b/src/routes/GraphqlRoute.ts index ea68e64..1c526f4 100644 --- a/src/routes/GraphqlRoute.ts +++ b/src/routes/GraphqlRoute.ts @@ -46,8 +46,8 @@ export class GraphqlRoute extends Route { } else { response.status(400); } - logger.debug(err.message); - logger.silly(err.stack); + logger.verbose(err.message); + logger.debug(err.stack); return err.graphqlError ?? err; }, graphiql: config.get("api.graphiql"), diff --git a/src/routes/UploadRoute.ts b/src/routes/UploadRoute.ts index 76b7a76..ebcf5fb 100644 --- a/src/routes/UploadRoute.ts +++ b/src/routes/UploadRoute.ts @@ -33,22 +33,11 @@ interface IUploadConfirmation { success: boolean; } -type ImageFit = "cover" | "contain" | "fill" | "inside" | "outside"; - /** * Represents an upload handler. */ export class UploadRoute extends Route { - /** - * Returns the hash of the current time to be used as a filename. - */ - private static getFileName() { - const hash = crypto.createHash("md5"); - hash.update(Number(Date.now()).toString()); - return hash.digest("hex"); - } - /** * The directory where the uploaded data will be saved in */ diff --git a/src/routes/graphql/MutationResolver.ts b/src/routes/graphql/MutationResolver.ts index 85c3823..9933479 100644 --- a/src/routes/graphql/MutationResolver.ts +++ b/src/routes/graphql/MutationResolver.ts @@ -10,9 +10,14 @@ import {NotAGroupAdminError} from "../../lib/errors/NotAGroupAdminError"; import {NotAnAdminError} from "../../lib/errors/NotAnAdminError"; import {NotTheGroupCreatorError} from "../../lib/errors/NotTheGroupCreatorError"; import {PostNotFoundError} from "../../lib/errors/PostNotFoundError"; +import {ReportAlreadyExistsError} from "../../lib/errors/ReportAlreadyExistsError"; +import {ReportReasonNameAlreadyExistsError} from "../../lib/errors/ReportReasonNameAlreadyExistsError"; +import {ReportReasonNotFoundError} from "../../lib/errors/ReportReasonNotFoundError"; import globals from "../../lib/globals"; import {InternalEvents} from "../../lib/InternalEvents"; import {Activity, BlacklistedPhrase, ChatMessage, ChatRoom, Event, Group, Post, Request, User} from "../../lib/models"; +import {Report} from "../../lib/models"; +import {ReportReason} from "../../lib/models"; import {UploadManager} from "../../lib/UploadManager"; import {BaseResolver} from "./BaseResolver"; @@ -207,6 +212,29 @@ export class MutationResolver extends BaseResolver { } } + /** + * Reports a post + * @param postId + * @param reasonId + * @param request + */ + public async reportPost({postId, reasonId}: {postId: number, reasonId: number}, request: any): Promise { + this.ensureLoggedIn(request); + const post = await Post.findByPk(postId); + if (!post) { + throw new PostNotFoundError(postId); + } + const reason = await ReportReason.findByPk(reasonId); + if (!reason) { + throw new ReportReasonNotFoundError(reasonId); + } + const report = await Report.findOne({where: {postId, reasonId, userId: request.session.userId}}); + if (report) { + throw new ReportAlreadyExistsError(); + } + return Report.create({postId, reasonId, userId: request.session.userId}, {include: [ReportReason]}); + } + /** * Creates a chat with several members * @param members @@ -509,4 +537,24 @@ export class MutationResolver extends BaseResolver { return false; } } + + /** + * Creates a new reason for reporting posts + * @param name + * @param description + * @param request + */ + public async createReportReason({name, description}: {name: string, description: string}, request: any): + Promise { + this.ensureLoggedIn(request); + const user = await User.findByPk(request.session.userId); + if (!user.isAdmin) { + throw new NotAnAdminError(); + } + const reasonExists = await ReportReason.findOne({where: {name}}); + if (reasonExists) { + throw new ReportReasonNameAlreadyExistsError(name); + } + return ReportReason.create({name, description}); + } } diff --git a/src/routes/graphql/QueryResolver.ts b/src/routes/graphql/QueryResolver.ts index 263e360..7f0c1ed 100644 --- a/src/routes/graphql/QueryResolver.ts +++ b/src/routes/graphql/QueryResolver.ts @@ -4,9 +4,10 @@ import dataaccess from "../../lib/dataAccess"; import {ChatNotFoundError} from "../../lib/errors/ChatNotFoundError"; import {PostNotFoundGqlError} from "../../lib/errors/graphqlErrors"; import {GroupNotFoundError} from "../../lib/errors/GroupNotFoundError"; +import {NotAnAdminError} from "../../lib/errors/NotAnAdminError"; import {RequestNotFoundError} from "../../lib/errors/RequestNotFoundError"; import {UserNotFoundError} from "../../lib/errors/UserNotFoundError"; -import {Activity, BlacklistedPhrase, ChatRoom, Event, Group, Post, Request, User} from "../../lib/models"; +import {Activity, BlacklistedPhrase, ChatRoom, Event, Group, Post, Report, Request, User} from "../../lib/models"; import {BlacklistedResult} from "./BlacklistedResult"; import {MutationResolver} from "./MutationResolver"; import {SearchResult} from "./SearchResult"; @@ -184,4 +185,19 @@ export class QueryResolver extends MutationResolver { return (await BlacklistedPhrase.findAll({limit: first, offset})) .map((p) => p.phrase); } + + /** + * Returns all reports with pagination if the user is an admin + * @param first + * @param offset + * @param request + */ + public async getReports({first, offset}: {first: number, offset: number}, request: any): Promise { + this.ensureLoggedIn(request); + const user = await User.findByPk(request.session.userId); + if (!user?.isAdmin) { + throw new NotAnAdminError(); + } + return Report.findAll({limit: first, offset}); + } } diff --git a/src/routes/graphql/schema.graphql b/src/routes/graphql/schema.graphql index abc7fa1..044c166 100644 --- a/src/routes/graphql/schema.graphql +++ b/src/routes/graphql/schema.graphql @@ -45,6 +45,9 @@ type Query { "Returns the blacklist with pagination." getBlacklistedPhrases(first: Int = 20, offset: Int = 0): [String!]! @complexity(value: 1, multipliers: ["first"]) + + "Returns all issued reports with pagination" + getReports(first: Int = 20, offset: Int = 0): [Report!]! @complexity(value: 1, multipliers: ["first"]) } type Mutation { @@ -72,9 +75,6 @@ type Mutation { "Upvote/downvote a Post" vote(postId: ID!, type: VoteType!): VoteResult - "Report the post" - report(postId: ID!): Boolean! - "send a request" sendRequest(receiver: ID!, type: RequestType): Request @@ -96,6 +96,9 @@ type Mutation { "delete the post for a given post id" deletePost(postId: ID!): Boolean! + "reports the post for a specific report reason" + reportPost(postId: ID!, reasonId: ID!): Report + "Creates a chat between the user (and optional an other user)" createChat(members: [ID!]): ChatRoom! @@ -137,6 +140,9 @@ type Mutation { "Removes a phrase from the blacklist. Returns true if the phrase could be found and deleted." removeFromBlacklist(phrase: String!, languageCode: String = "en"): Boolean! + + "Creates a new report reason" + createReportReason(name: String!, description: String!): ReportReason } interface UserData { @@ -352,6 +358,9 @@ type Post { "the uploaded file or video for the post" media: Media + + "returns all reports issued on the post" + reports(first: Int = 20, offset: Int = 0): [Report!]! } "represents a request of any type" @@ -523,6 +532,9 @@ type BlacklistedResult { "a type of uploaded media" type Media { + "the id of the media" + id: ID! + "the url pointing to the media in the data folder" url: String! @@ -530,6 +542,35 @@ type Media { type: MediaType } +"a report on a post" +type Report { + + "the id of the report" + id: ID! + + "the post that was reported" + post: Post! + + "the reason why the post was reported" + reason: ReportReason! + + "the user who reported the post" + user: User! +} + +"the reason for a report" +type ReportReason { + + "the id of the reason" + id: ID! + + "the name of the report reason" + name: String! + + "the description of the reason" + description: String! +} + "represents the type of media" enum MediaType { VIDEO