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
pull/4/head
trivernis 5 years ago
parent 26ecb3b0cf
commit 4014f77a4c

@ -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) - routine to cleanup orphaned media entries (not referenced by post, user, group)
- delete handler for media to delete the corresponding file - 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) - 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 ### 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 - only group admins can create group events
- config behaviour to use all files that reside in the ./config directory with the .toml format - 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 - 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 ### Fixed

@ -5,6 +5,16 @@ import App from "./app";
const numCPUs = require("os").cpus().length; 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) { if (cluster.isMaster) {
console.log(`[CLUSTER-M] Master ${process.pid} is running`); console.log(`[CLUSTER-M] Master ${process.pid} is running`);
@ -21,9 +31,12 @@ if (cluster.isMaster) {
}); });
}); });
(async () => {
for (let i = 0; i < numCPUs; i++) { for (let i = 0; i < numCPUs; i++) {
cluster.fork(); cluster.fork();
await delay(1000);
} }
})();
} else { } else {
/** /**

@ -65,6 +65,8 @@ namespace dataaccess {
models.Activity, models.Activity,
models.BlacklistedPhrase, models.BlacklistedPhrase,
models.Media, models.Media,
models.Report,
models.ReportReason,
]); ]);
} catch (err) { } catch (err) {
globals.logger.error(err.message); globals.logger.error(err.message);

@ -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.");
}
}

@ -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!`);
}
}

@ -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`);
}
}

@ -1,9 +1,20 @@
import * as sqz from "sequelize"; 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 markdown from "../markdown";
import {Activity} from "./Activity"; import {Activity} from "./Activity";
import {Media} from "./Media"; import {Media} from "./Media";
import {PostVote, VoteType} from "./PostVote"; import {PostVote, VoteType} from "./PostVote";
import {Report} from "./Report";
import {User} from "./User"; import {User} from "./User";
/** /**
@ -73,6 +84,12 @@ export class Post extends Model<Post> {
// tslint:disable-next-line:completed-docs // tslint:disable-next-line:completed-docs
public rVotes: Array<User & { PostVote: PostVote }>; public rVotes: Array<User & { PostVote: PostVote }>;
/**
* The reports on the post
*/
@HasMany(() => Report, "postId")
public rReports: Report[];
/** /**
* The date the post was created at * The date the post was created at
*/ */
@ -100,6 +117,13 @@ export class Post extends Model<Post> {
return await this.$get("rVotes") as Array<User & { PostVote: PostVote }>; return await this.$get("rVotes") as Array<User & { PostVote: PostVote }>;
} }
/**
* Returns the reports on the post
*/
public async reports({first, offset}: {first: number, offset: number}): Promise<Report[]> {
return await this.$get("rReports", {limit: first, offset}) as Report[];
}
/** /**
* Returns the markdown-rendered html content of the post * Returns the markdown-rendered html content of the post
*/ */

@ -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<Report> {
/**
* 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<User> {
return await this.$get("rUser") as User;
}
/**
* Returns the post that was reported
*/
public async post(): Promise<Post> {
return await this.$get("rPost") as Post;
}
/**
* Returns the reason why the post was reported
*/
public async reason(): Promise<ReportReason> {
return await this.$get("rReason") as ReportReason;
}
}

@ -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<ReportReason> {
/**
* 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;
}

@ -14,3 +14,5 @@ export {EventParticipant} from "./EventParticipant";
export {Activity} from "./Activity"; export {Activity} from "./Activity";
export {BlacklistedPhrase} from "./BlacklistedPhrase"; export {BlacklistedPhrase} from "./BlacklistedPhrase";
export {Media} from "./Media"; export {Media} from "./Media";
export {Report} from "./Report";
export {ReportReason} from "./ReportReason";

@ -46,8 +46,8 @@ export class GraphqlRoute extends Route {
} else { } else {
response.status(400); response.status(400);
} }
logger.debug(err.message); logger.verbose(err.message);
logger.silly(err.stack); logger.debug(err.stack);
return err.graphqlError ?? err; return err.graphqlError ?? err;
}, },
graphiql: config.get<boolean>("api.graphiql"), graphiql: config.get<boolean>("api.graphiql"),

@ -33,22 +33,11 @@ interface IUploadConfirmation {
success: boolean; success: boolean;
} }
type ImageFit = "cover" | "contain" | "fill" | "inside" | "outside";
/** /**
* Represents an upload handler. * Represents an upload handler.
*/ */
export class UploadRoute extends Route { 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 * The directory where the uploaded data will be saved in
*/ */

@ -10,9 +10,14 @@ import {NotAGroupAdminError} from "../../lib/errors/NotAGroupAdminError";
import {NotAnAdminError} from "../../lib/errors/NotAnAdminError"; import {NotAnAdminError} from "../../lib/errors/NotAnAdminError";
import {NotTheGroupCreatorError} from "../../lib/errors/NotTheGroupCreatorError"; import {NotTheGroupCreatorError} from "../../lib/errors/NotTheGroupCreatorError";
import {PostNotFoundError} from "../../lib/errors/PostNotFoundError"; 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 globals from "../../lib/globals";
import {InternalEvents} from "../../lib/InternalEvents"; import {InternalEvents} from "../../lib/InternalEvents";
import {Activity, BlacklistedPhrase, ChatMessage, ChatRoom, Event, Group, Post, Request, User} from "../../lib/models"; 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 {UploadManager} from "../../lib/UploadManager";
import {BaseResolver} from "./BaseResolver"; 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<Report> {
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 * Creates a chat with several members
* @param members * @param members
@ -509,4 +537,24 @@ export class MutationResolver extends BaseResolver {
return false; 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<ReportReason> {
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});
}
} }

@ -4,9 +4,10 @@ import dataaccess from "../../lib/dataAccess";
import {ChatNotFoundError} from "../../lib/errors/ChatNotFoundError"; import {ChatNotFoundError} from "../../lib/errors/ChatNotFoundError";
import {PostNotFoundGqlError} from "../../lib/errors/graphqlErrors"; import {PostNotFoundGqlError} from "../../lib/errors/graphqlErrors";
import {GroupNotFoundError} from "../../lib/errors/GroupNotFoundError"; import {GroupNotFoundError} from "../../lib/errors/GroupNotFoundError";
import {NotAnAdminError} from "../../lib/errors/NotAnAdminError";
import {RequestNotFoundError} from "../../lib/errors/RequestNotFoundError"; import {RequestNotFoundError} from "../../lib/errors/RequestNotFoundError";
import {UserNotFoundError} from "../../lib/errors/UserNotFoundError"; 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 {BlacklistedResult} from "./BlacklistedResult";
import {MutationResolver} from "./MutationResolver"; import {MutationResolver} from "./MutationResolver";
import {SearchResult} from "./SearchResult"; import {SearchResult} from "./SearchResult";
@ -184,4 +185,19 @@ export class QueryResolver extends MutationResolver {
return (await BlacklistedPhrase.findAll({limit: first, offset})) return (await BlacklistedPhrase.findAll({limit: first, offset}))
.map((p) => p.phrase); .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<Report[]> {
this.ensureLoggedIn(request);
const user = await User.findByPk(request.session.userId);
if (!user?.isAdmin) {
throw new NotAnAdminError();
}
return Report.findAll({limit: first, offset});
}
} }

@ -45,6 +45,9 @@ type Query {
"Returns the blacklist with pagination." "Returns the blacklist with pagination."
getBlacklistedPhrases(first: Int = 20, offset: Int = 0): [String!]! @complexity(value: 1, multipliers: ["first"]) 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 { type Mutation {
@ -72,9 +75,6 @@ type Mutation {
"Upvote/downvote a Post" "Upvote/downvote a Post"
vote(postId: ID!, type: VoteType!): VoteResult vote(postId: ID!, type: VoteType!): VoteResult
"Report the post"
report(postId: ID!): Boolean!
"send a request" "send a request"
sendRequest(receiver: ID!, type: RequestType): Request sendRequest(receiver: ID!, type: RequestType): Request
@ -96,6 +96,9 @@ type Mutation {
"delete the post for a given post id" "delete the post for a given post id"
deletePost(postId: ID!): Boolean! 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)" "Creates a chat between the user (and optional an other user)"
createChat(members: [ID!]): ChatRoom! 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." "Removes a phrase from the blacklist. Returns true if the phrase could be found and deleted."
removeFromBlacklist(phrase: String!, languageCode: String = "en"): Boolean! removeFromBlacklist(phrase: String!, languageCode: String = "en"): Boolean!
"Creates a new report reason"
createReportReason(name: String!, description: String!): ReportReason
} }
interface UserData { interface UserData {
@ -352,6 +358,9 @@ type Post {
"the uploaded file or video for the post" "the uploaded file or video for the post"
media: Media media: Media
"returns all reports issued on the post"
reports(first: Int = 20, offset: Int = 0): [Report!]!
} }
"represents a request of any type" "represents a request of any type"
@ -523,6 +532,9 @@ type BlacklistedResult {
"a type of uploaded media" "a type of uploaded media"
type Media { type Media {
"the id of the media"
id: ID!
"the url pointing to the media in the data folder" "the url pointing to the media in the data folder"
url: String! url: String!
@ -530,6 +542,35 @@ type Media {
type: MediaType 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" "represents the type of media"
enum MediaType { enum MediaType {
VIDEO VIDEO

Loading…
Cancel
Save