diff --git a/CHANGELOG.md b/CHANGELOG.md index 6689bc1..267adef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - rate limits with defaults of 10/min for `/upload` and 30/min for `/graphql` - complexity limits for graphql queries that can be configured with the `api.maxQueryComplexity` option - complexity headers `X-Query-Complexity` and `X-Max-Query-Complexity` +- Media model to store information about media (videos and images) +- Media association to users, groups and posts +- Upload handling for media entries (via /upload) +- 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) ### Removed diff --git a/Dockerfile b/Dockerfile index 6a47f66..bee1f64 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,12 @@ -FROM node:current-alpine +FROM node:13.7.0 COPY . /home/node/green WORKDIR /home/node/green +RUN apt update +RUN apt install redis-server -y RUN npm install -g gulp -RUN npm install --save-dev -RUN npm rebuild node-sass +RUN yarn install RUN gulp COPY . . EXPOSE 8080 -CMD ["npm" , "run"] +CMD ["redis-server", "&", "node" , "./dist"] diff --git a/config/default.toml b/config/default.toml index b804130..1b186aa 100644 --- a/config/default.toml +++ b/config/default.toml @@ -4,6 +4,9 @@ # A connection uri string to the database connectionUri = "sqlite://greenvironment.db" +# The cleanup interval of orphaned entries in seconds +cleanupInterval = 6000 + # Configuration for the redis connection [redis] @@ -54,6 +57,7 @@ publicPath = "./public" # Configuration for the api [api] + # if graphiql should be enabled graphiql = true diff --git a/docker-compose.yml b/docker-compose.yml index 75de1cd..af0d472 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,4 +8,4 @@ services: - NODE_ENV=production ports: - "8080:8080" - command: "npm start" + command: "yarn start" diff --git a/src/lib/UploadManager.ts b/src/lib/UploadManager.ts index fd1b7cd..223ea89 100644 --- a/src/lib/UploadManager.ts +++ b/src/lib/UploadManager.ts @@ -5,26 +5,18 @@ import * as path from "path"; import * as sharp from "sharp"; import {Readable} from "stream"; import globals from "./globals"; +import {Media} from "./models"; const toArray = require("stream-to-array"); const dataDirName = "data"; -interface IUploadConfirmation { - /** - * Indicates the error that might have occured during the upload - */ - error?: string; - - /** - * The file that has been uploaded - */ - fileName?: string; - - /** - * If the upload was successful - */ - success: boolean; +/** + * An enum representing a type of media + */ +export enum MediaType { + IMAGE = "IMAGE", + VIDEO = "VIDEO", } type ImageFit = "cover" | "contain" | "fill" | "inside" | "outside"; @@ -71,7 +63,7 @@ export class UploadManager { * @param fit */ public async processAndStoreImage(data: Buffer, width = 512, height = 512, - fit: ImageFit = "cover"): Promise { + fit: ImageFit = "cover"): Promise { const fileBasename = UploadManager.getCrypticFileName() + "." + config.get("api.imageFormat"); await fsx.ensureDir(this.dataDir); const filePath = path.join(this.dataDir, fileBasename); @@ -93,7 +85,11 @@ export class UploadManager { }); } await image.toFile(filePath); - return `/${dataDirName}/${fileBasename}`; + return Media.create({ + path: filePath, + type: MediaType.IMAGE, + url: `/${dataDirName}/${fileBasename}`, + }); } /** @@ -101,12 +97,16 @@ export class UploadManager { * @param data * @param extension */ - public async processAndStoreVideo(data: Buffer, extension: string): Promise { + public async processAndStoreVideo(data: Buffer, extension: string): Promise { const fileBasename = UploadManager.getCrypticFileName() + extension; await fsx.ensureDir(this.dataDir); const filePath = path.join(this.dataDir, fileBasename); await fsx.writeFile(filePath, data); - return `/${dataDirName}/${fileBasename}`; + return Media.create({ + path: filePath, + type: MediaType.VIDEO, + url: `/${dataDirName}/${fileBasename}`, + }); } /** diff --git a/src/lib/dataAccess.ts b/src/lib/dataAccess.ts index 3b1ad3b..668c8b1 100644 --- a/src/lib/dataAccess.ts +++ b/src/lib/dataAccess.ts @@ -1,3 +1,4 @@ +import * as config from "config"; import * as crypto from "crypto"; import * as sqz from "sequelize"; import {Sequelize} from "sequelize-typescript"; @@ -63,11 +64,28 @@ namespace dataaccess { models.Event, models.Activity, models.BlacklistedPhrase, + models.Media, ]); } catch (err) { globals.logger.error(err.message); globals.logger.debug(err.stack); } + await databaseCleanup(); + setInterval(databaseCleanup, config.get("database.cleanupInterval")); + } + + /** + * Cleans the database. + * - deletes all media entries without associations + */ + async function databaseCleanup() { + const allMedia = await models.Media + .findAll({include: [models.Post, models.User, models.Group]}) as models.Media[]; + for (const media of allMedia) { + if (!media.user && !media.post && !media.group) { + await media.destroy(); + } + } } /** @@ -161,6 +179,9 @@ namespace dataaccess { limit: first, offset, order: [["createdAt", "DESC"]], + where: { + visible: true, + }, }); } else { // more performant way to get the votes with plain sql @@ -170,10 +191,12 @@ namespace dataaccess { SELECT *, (SELECT count(*) FROM post_votes - WHERE vote_type = 'UPVOTE' AND post_id = posts.id) AS upvotes, + WHERE vote_type = 'UPVOTE' + AND post_id = posts.id) AS upvotes, (SELECT count(*) FROM post_votes - WHERE vote_type = 'DOWNVOTE' AND post_id = posts.id) AS downvotes + WHERE vote_type = 'DOWNVOTE' + AND post_id = posts.id) AS downvotes FROM posts) AS a ORDER BY (a.upvotes - a.downvotes) DESC, a.upvotes DESC, a.id LIMIT ? @@ -188,15 +211,17 @@ namespace dataaccess { * @param content * @param authorId * @param activityId + * @param type */ - export async function createPost(content: string, authorId: number, activityId?: number): Promise { + export async function createPost(content: string, authorId: number, activityId?: number, + type: PostType = PostType.TEXT): Promise { const blacklisted = await checkBlacklisted(content); if (blacklisted.length > 0) { throw new BlacklistedError(blacklisted.map((p) => p.phrase), "content"); } const activity = await models.Activity.findByPk(activityId); if (!activityId || activity) { - const post = await models.Post.create({content, authorId, activityId}); + const post = await models.Post.create({content, authorId, activityId, visible: type !== PostType.MEDIA}); globals.internalEmitter.emit(InternalEvents.POSTCREATE, post); if (activity) { const user = await models.User.findByPk(authorId); @@ -218,11 +243,15 @@ namespace dataaccess { const post = await models.Post.findByPk(postId, {include: [{model: Activity}, {association: "rAuthor"}]}); const activity = await post.activity(); const author = await post.author(); + const media = await post.$get("rMedia") as models.Media; if (activity && author) { author.rankpoints -= activity.points; await author.save(); } await post.destroy(); + if (media) { + await media.destroy(); + } } catch (err) { globals.logger.error(err.message); globals.logger.debug(err.stack); @@ -399,6 +428,17 @@ namespace dataaccess { NEW = "NEW", } + /** + * The type of the post + */ + export enum PostType { + TEXT = "TEXT", + MEDIA = "MEDIA", + } + + /** + * Enum representing the type of membership change for the membership change function + */ export enum MembershipChangeAction { ADD, REMOVE, diff --git a/src/lib/models/Group.ts b/src/lib/models/Group.ts index 85c88ce..245003f 100644 --- a/src/lib/models/Group.ts +++ b/src/lib/models/Group.ts @@ -1,4 +1,3 @@ -import * as sqz from "sequelize"; import { BelongsTo, BelongsToMany, @@ -14,6 +13,7 @@ import {ChatRoom} from "./ChatRoom"; import {Event} from "./Event"; import {GroupAdmin} from "./GroupAdmin"; import {GroupMember} from "./GroupMember"; +import {Media} from "./Media"; import {User} from "./User"; /** @@ -30,12 +30,12 @@ export class Group extends Model { @Column({allowNull: false, unique: true}) public name: string; - /** - * The url of the groups avatar picture + * The id of the media that represents the groups profile picture */ - @Column({type: sqz.STRING(512)}) - public picture: string; + @ForeignKey(() => Media) + @Column({allowNull: true}) + public mediaId: number; /** * The id of the user who created the group @@ -53,6 +53,12 @@ export class Group extends Model { @Column({allowNull: false}) public chatId: number; + /** + * The media of the group + */ + @BelongsTo(() => Media, "mediaId") + public rMedia: Media; + /** * The creator of the group */ @@ -83,6 +89,14 @@ export class Group extends Model { @HasMany(() => Event, "groupId") public rEvents: Event[]; + /** + * Returns the media url of the group which is the profile picture + */ + public async picture(): Promise { + const media = await this.$get("rMedia") as Media; + return media ? media.url : undefined; + } + /** * Returns the creator of the group */ diff --git a/src/lib/models/Media.ts b/src/lib/models/Media.ts new file mode 100644 index 0000000..1d3a58d --- /dev/null +++ b/src/lib/models/Media.ts @@ -0,0 +1,61 @@ +import * as fsx from "fs-extra"; +import * as sqz from "sequelize"; +import {BeforeDestroy, Column, HasOne, Model, NotNull, Table} from "sequelize-typescript"; +import {MediaType} from "../UploadManager"; +import {Group} from "./Group"; +import {Post} from "./Post"; +import {User} from "./User"; + +/** + * Represents a single media file that can be used as a profile picture for groups and users or a post picture + */ +@Table({underscored: true}) +export class Media extends Model { + + /** + * Deletes the media file before the media is destroyed + * @param instance + */ + @BeforeDestroy + public static async deleteMediaFile(instance: Media) { + await fsx.unlink(instance.path); + } + + /** + * The api url for the media + */ + @NotNull + @Column({type: sqz.STRING(512), allowNull: false}) + public url: string; + + /** + * The local path of the file + */ + @NotNull + @Column({allowNull: false}) + public path: string; + + /** + * The type of media + */ + @Column({type: sqz.ENUM, values: ["IMAGE", "VIDEO"]}) + public type: MediaType; + + /** + * The user that uses the media + */ + @HasOne(() => User) + public user: User; + + /** + * The group that uses the media + */ + @HasOne(() => Group) + public group: Group; + + /** + * The post that uses the media + */ + @HasOne(() => Post) + public post: Post; +} diff --git a/src/lib/models/Post.ts b/src/lib/models/Post.ts index dc0574e..cda62d3 100644 --- a/src/lib/models/Post.ts +++ b/src/lib/models/Post.ts @@ -1,8 +1,8 @@ -import * as config from "config"; import * as sqz from "sequelize"; import {BelongsTo, BelongsToMany, Column, CreatedAt, ForeignKey, Model, NotNull, Table} from "sequelize-typescript"; import markdown from "../markdown"; import {Activity} from "./Activity"; +import {Media} from "./Media"; import {PostVote, VoteType} from "./PostVote"; import {User} from "./User"; @@ -19,6 +19,13 @@ export class Post extends Model { @Column({type: sqz.STRING(2048), allowNull: false}) public content: string; + /** + * If the post is publically visible + */ + @NotNull + @Column({defaultValue: true, allowNull: false}) + public visible: boolean; + /** * The id of the post author */ @@ -35,10 +42,11 @@ export class Post extends Model { public activityId: number; /** - * An url pointing to any media that belongs to the post + * An id pointing to a media entry */ - @Column({allowNull: true, type: sqz.STRING(512)}) - public mediaUrl: string; + @ForeignKey(() => Media) + @Column({allowNull: true}) + public mediaId: number; /** * The author of the post @@ -52,6 +60,12 @@ export class Post extends Model { @BelongsTo(() => Activity, "activityId") public rActivity?: Activity; + /** + * The media of the post + */ + @BelongsTo(() => Media, "mediaId") + public rMedia?: Media; + /** * The votes that were performed on the post */ @@ -110,17 +124,8 @@ export class Post extends Model { /** * Returns the media description object of the post */ - public get media() { - const url = this.getDataValue("mediaUrl"); - if (url) { - const type = url.endsWith(config.get("api.imageFormat")) ? "IMAGE" : "VIDEO"; - return { - type, - url, - }; - } else { - return null; - } + public async media() { + return await this.$get("rMedia") as Media; } /** diff --git a/src/lib/models/User.ts b/src/lib/models/User.ts index ad15e01..7fbe1f5 100644 --- a/src/lib/models/User.ts +++ b/src/lib/models/User.ts @@ -1,8 +1,10 @@ import * as sqz from "sequelize"; import { + BelongsTo, BelongsToMany, Column, CreatedAt, + ForeignKey, HasMany, Model, NotNull, @@ -22,6 +24,7 @@ import {Friendship} from "./Friendship"; import {Group} from "./Group"; import {GroupAdmin} from "./GroupAdmin"; import {GroupMember} from "./GroupMember"; +import {Media} from "./Media"; import {Post} from "./Post"; import {PostVote} from "./PostVote"; import {Request, RequestType} from "./Request"; @@ -97,10 +100,18 @@ export class User extends Model { public isAdmin: boolean; /** - * The url of the users profile picture + * The id of the media that is the users profile picture */ - @Column({type: sqz.STRING(512)}) - public profilePicture: string; + @ForeignKey(() => Media) + @Column({allowNull: false}) + public mediaId: number; + + + /** + * The media of the user + */ + @BelongsTo(() => Media) + public rMedia: Media; /** * The friends of the user @@ -221,6 +232,14 @@ export class User extends Model { return JSON.stringify(this.getDataValue("frontendSettings")); } + /** + * Returns the media url which is the profile picture + */ + public async profilePicture(): Promise { + const media = await this.$get("rMedia") as Media; + return media ? media.url : undefined; + } + /** * Returns the token for the user that can be used as a bearer in requests */ @@ -287,11 +306,15 @@ export class User extends Model { * a list of posts the user has created * @param first * @param offset + * @param request */ - public async posts({first, offset}: { first: number, offset: number }): Promise { + public async posts({first, offset}: { first: number, offset: number }, request: any): Promise { const limit = first ?? 10; offset = offset ?? 0; - return await this.$get("rPosts", {limit, offset}) as Post[]; + if (request.session.userId === this.getDataValue("id")) { + return await this.$get("rPosts", {limit, offset, order: [["id", "desc"]]}) as Post[]; + } + return await this.$get("rPosts", {limit, offset, where: {visible: true}, order: [["id", "desc"]]}) as Post[]; } /** diff --git a/src/lib/models/index.ts b/src/lib/models/index.ts index 7aba87a..5fafbce 100644 --- a/src/lib/models/index.ts +++ b/src/lib/models/index.ts @@ -13,3 +13,4 @@ export {Event} from "./Event"; export {EventParticipant} from "./EventParticipant"; export {Activity} from "./Activity"; export {BlacklistedPhrase} from "./BlacklistedPhrase"; +export {Media} from "./Media"; diff --git a/src/routes/UploadRoute.ts b/src/routes/UploadRoute.ts index f2bddd3..76b7a76 100644 --- a/src/routes/UploadRoute.ts +++ b/src/routes/UploadRoute.ts @@ -2,13 +2,14 @@ import * as bodyParser from "body-parser"; import * as config from "config"; import * as crypto from "crypto"; import {Router} from "express"; -import {UploadedFile} from "express-fileupload"; import * as fileUpload from "express-fileupload"; +import {UploadedFile} from "express-fileupload"; import * as fsx from "fs-extra"; import * as status from "http-status"; import * as path from "path"; import globals from "../lib/globals"; import {Group, Post, User} from "../lib/models"; +import {Media} from "../lib/models"; import {is} from "../lib/regex"; import Route from "../lib/Route"; import {UploadManager} from "../lib/UploadManager"; @@ -117,14 +118,15 @@ export class UploadRoute extends Route { let fileName: string; const profilePic = request.files.profilePicture as UploadedFile; try { - const user = await User.findByPk(request.session.userId); + const user = await User.findByPk(request.session.userId, {include: [Media]}); if (user) { - fileName = await this.uploadManager.processAndStoreImage(profilePic.data); - if (user.profilePicture) { - await this.uploadManager.deleteWebFile(user.profilePicture); + const media = await this.uploadManager.processAndStoreImage(profilePic.data); + if (user.mediaId) { + const previousMedia = await user.$get("rMedia") as Media; + await previousMedia.destroy(); } - user.profilePicture = fileName; - await user.save(); + await user.$set("rMedia", media); + fileName = media.url; success = true; } else { error = "User not found"; @@ -153,7 +155,7 @@ export class UploadRoute extends Route { if (request.body.groupId) { try { const user = await User.findByPk(request.session.userId); - const group = await Group.findByPk(request.body.groupId); + const group = await Group.findByPk(request.body.groupId, {include: [Media]}); if (!group) { error = `No group with the id '${request.body.groupId}' found.`; return { @@ -163,12 +165,13 @@ export class UploadRoute extends Route { } const isAdmin = await group.$has("rAdmins", user); if (isAdmin) { - fileName = await this.uploadManager.processAndStoreImage(groupPicture.data); - if (group.picture) { - await this.uploadManager.deleteWebFile(group.picture); + const media = await this.uploadManager.processAndStoreImage(groupPicture.data); + if (group.mediaId) { + const previousMedia = await group.$get("rMedia") as Media; + await previousMedia.destroy(); } - group.picture = fileName; - await group.save(); + await group.$set("rMedia", media); + fileName = media.url; success = true; } else { error = "You are not a group admin."; @@ -200,19 +203,22 @@ export class UploadRoute extends Route { const postMedia = request.files.postMedia as UploadedFile; if (postId) { try { + let media: Media; const post = await Post.findByPk(postId); if (post.authorId === request.session.userId) { if (is.image(postMedia.mimetype)) { - fileName = await this.uploadManager.processAndStoreImage(postMedia.data, 1080, 720, "inside"); + media = await this.uploadManager.processAndStoreImage(postMedia.data, 1080, 720, "inside"); } else if (is.video(postMedia.mimetype)) { - fileName = await this.uploadManager.processAndStoreVideo(postMedia.data, postMedia.mimetype + media = await this.uploadManager.processAndStoreVideo(postMedia.data, postMedia.mimetype .replace("video/", "")); } else { error = "Wrong type of file provided"; } - if (fileName) { - post.mediaUrl = fileName; + if (media) { + await post.$set("rMedia", media); + post.visible = true; await post.save(); + fileName = media.url; success = true; } } else { diff --git a/src/routes/graphql/MutationResolver.ts b/src/routes/graphql/MutationResolver.ts index ac30772..aea1be2 100644 --- a/src/routes/graphql/MutationResolver.ts +++ b/src/routes/graphql/MutationResolver.ts @@ -1,12 +1,10 @@ import {GraphQLError} from "graphql"; -import {FileUpload} from "graphql-upload"; import * as yaml from "js-yaml"; import isEmail from "validator/lib/isEmail"; -import dataaccess from "../../lib/dataAccess"; +import dataAccess from "../../lib/dataAccess"; import {BlacklistedError} from "../../lib/errors/BlacklistedError"; import {GroupNotFoundError} from "../../lib/errors/GroupNotFoundError"; import {InvalidEmailError} from "../../lib/errors/InvalidEmailError"; -import {InvalidFileError} from "../../lib/errors/InvalidFileError"; import {NotAGroupAdminError} from "../../lib/errors/NotAGroupAdminError"; import {NotAnAdminError} from "../../lib/errors/NotAnAdminError"; import {NotTheGroupCreatorError} from "../../lib/errors/NotTheGroupCreatorError"; @@ -14,7 +12,6 @@ import {PostNotFoundError} from "../../lib/errors/PostNotFoundError"; import globals from "../../lib/globals"; import {InternalEvents} from "../../lib/InternalEvents"; import {Activity, BlacklistedPhrase, ChatMessage, ChatRoom, Event, Group, Post, Request, User} from "../../lib/models"; -import {is} from "../../lib/regex"; import {UploadManager} from "../../lib/UploadManager"; import {BaseResolver} from "./BaseResolver"; @@ -53,7 +50,7 @@ export class MutationResolver extends BaseResolver { * @param request */ public async login({email, passwordHash}: { email: string, passwordHash: string }, request: any): Promise { - const user = await dataaccess.getUserByLogin(email, passwordHash); + const user = await dataAccess.getUserByLogin(email, passwordHash); request.session.userId = user.id; return user; } @@ -96,7 +93,7 @@ export class MutationResolver extends BaseResolver { if (!mailValid) { throw new InvalidEmailError(email); } - const user = await dataaccess.registerUser(username, email, passwordHash); + const user = await dataAccess.registerUser(username, email, passwordHash); request.session.userId = user.id; return user; } @@ -124,8 +121,8 @@ export class MutationResolver extends BaseResolver { * @param type * @param request */ - public async vote({postId, type}: { postId: number, type: dataaccess.VoteType }, request: any): - Promise<{ post: Post, voteType: dataaccess.VoteType }> { + public async vote({postId, type}: { postId: number, type: dataAccess.VoteType }, request: any): + Promise<{ post: Post, voteType: dataAccess.VoteType }> { this.ensureLoggedIn(request); const post = await Post.findByPk(postId); if (post) { @@ -143,15 +140,17 @@ export class MutationResolver extends BaseResolver { * Creates a new post * @param content * @param activityId + * @param type * @param request */ - public async createPost({content, activityId}: { content: string, activityId?: number}, - request: any): Promise { + public async createPost( + {content, activityId, type}: { content: string, activityId?: number, type: dataAccess.PostType }, + request: any): Promise { this.ensureLoggedIn(request); if (content.length > 2048) { throw new GraphQLError("Content too long."); } - const post = await dataaccess.createPost(content, request.session.userId, activityId); + const post = await dataAccess.createPost(content, request.session.userId, activityId, type); globals.internalEmitter.emit(InternalEvents.GQLPOSTCREATE, post); return post; } @@ -171,15 +170,7 @@ export class MutationResolver extends BaseResolver { }); const isAdmin = (await User.findOne({where: {id: request.session.userId}})).isAdmin; if (post.rAuthor.id === request.session.userId || isAdmin) { - if (post.mediaUrl) { - try { - await this.uploadManager.deleteWebFile(post.mediaUrl); - } catch (err) { - globals.logger.error(err.message); - globals.logger.debug(err.stack); - } - } - return await dataaccess.deletePost(post.id); + return await dataAccess.deletePost(post.id); } else { throw new GraphQLError("User is not author of the post."); } @@ -196,7 +187,7 @@ export class MutationResolver extends BaseResolver { if (members) { chatMembers.push(...members); } - return await dataaccess.createChat(...chatMembers); + return await dataAccess.createChat(...chatMembers); } /** @@ -208,7 +199,7 @@ export class MutationResolver extends BaseResolver { public async sendMessage({chatId, content}: { chatId: number, content: string }, request: any): Promise { this.ensureLoggedIn(request); - const message = await dataaccess.sendChatMessage(request.session.userId, chatId, content); + const message = await dataAccess.sendChatMessage(request.session.userId, chatId, content); globals.internalEmitter.emit(InternalEvents.GQLCHATMESSAGE, message); return message; } @@ -219,10 +210,10 @@ export class MutationResolver extends BaseResolver { * @param type * @param request */ - public async sendRequest({receiver, type}: { receiver: number, type: dataaccess.RequestType }, request: any): + public async sendRequest({receiver, type}: { receiver: number, type: dataAccess.RequestType }, request: any): Promise { this.ensureLoggedIn(request); - return dataaccess.createRequest(request.session.userId, receiver, type); + return dataAccess.createRequest(request.session.userId, receiver, type); } /** @@ -231,7 +222,7 @@ export class MutationResolver extends BaseResolver { * @param type * @param request */ - public async denyRequest({sender, type}: { sender: number, type: dataaccess.RequestType }, request: any) { + public async denyRequest({sender, type}: { sender: number, type: dataAccess.RequestType }, request: any) { this.ensureLoggedIn(request); const user = await User.findByPk(request.session.userId); await user.acceptRequest(sender, type); @@ -244,7 +235,7 @@ export class MutationResolver extends BaseResolver { * @param type * @param request */ - public async acceptRequest({sender, type}: { sender: number, type: dataaccess.RequestType }, request: any) { + public async acceptRequest({sender, type}: { sender: number, type: dataAccess.RequestType }, request: any) { this.ensureLoggedIn(request); const user = await User.findByPk(request.session.userId); await user.acceptRequest(sender, type); @@ -270,7 +261,7 @@ export class MutationResolver extends BaseResolver { */ public async createGroup({name, members}: { name: string, members: number[] }, request: any): Promise { this.ensureLoggedIn(request); - return await dataaccess.createGroup(name, request.session.userId, members); + return await dataAccess.createGroup(name, request.session.userId, members); } /** @@ -299,8 +290,8 @@ export class MutationResolver extends BaseResolver { */ public async joinGroup({groupId}: { groupId: number }, request: any): Promise { this.ensureLoggedIn(request); - return dataaccess.changeGroupMembership(groupId, request.session.userId, - dataaccess.MembershipChangeAction.ADD); + return dataAccess.changeGroupMembership(groupId, request.session.userId, + dataAccess.MembershipChangeAction.ADD); } /** @@ -310,8 +301,8 @@ export class MutationResolver extends BaseResolver { */ public async leaveGroup({groupId}: { groupId: number }, request: any): Promise { this.ensureLoggedIn(request); - return dataaccess.changeGroupMembership(groupId, request.session.userId, - dataaccess.MembershipChangeAction.REMOVE); + return dataAccess.changeGroupMembership(groupId, request.session.userId, + dataAccess.MembershipChangeAction.REMOVE); } /** @@ -327,8 +318,8 @@ export class MutationResolver extends BaseResolver { if (group && !(await group.$has("rAdmins", user)) && (await group.creator()) !== user.id) { throw new NotAGroupAdminError(groupId); } - return dataaccess.changeGroupMembership(groupId, userId, - dataaccess.MembershipChangeAction.OP); + return dataAccess.changeGroupMembership(groupId, userId, + dataAccess.MembershipChangeAction.OP); } /** @@ -349,8 +340,8 @@ export class MutationResolver extends BaseResolver { throw new GraphQLError( "You are not allowed to remove a creator as an admin."); } - return await dataaccess.changeGroupMembership(groupId, userId, - dataaccess.MembershipChangeAction.DEOP); + return await dataAccess.changeGroupMembership(groupId, userId, + dataAccess.MembershipChangeAction.DEOP); } /** @@ -369,7 +360,7 @@ export class MutationResolver extends BaseResolver { if (!(await group.$has("rAdmins", user))) { throw new NotAGroupAdminError(groupId); } - const blacklisted = await dataaccess.checkBlacklisted(name); + const blacklisted = await dataAccess.checkBlacklisted(name); if (blacklisted.length > 0) { throw new BlacklistedError(blacklisted.map((p) => p.phrase), "event name"); } diff --git a/src/routes/graphql/schema.graphql b/src/routes/graphql/schema.graphql index 41b009a..5ed3592 100644 --- a/src/routes/graphql/schema.graphql +++ b/src/routes/graphql/schema.graphql @@ -85,7 +85,7 @@ type Mutation { sendMessage(chatId: ID!, content: String!): ChatMessage "create a post that can belong to an activity" - createPost(content: String!, activityId: ID): Post! + createPost(content: String!, activityId: ID, type: PostType = TEXT): Post! "delete the post for a given post id" deletePost(postId: ID!): Boolean! @@ -317,6 +317,9 @@ type Post { "the text of the post" content: String + "If the post is publically visible" + visible: Boolean! + "the content of the post rendered by markdown-it" htmlContent: String @@ -543,7 +546,16 @@ enum RequestType { EVENTINVITE } +"the type of sorting for getPosts" enum SortType { TOP NEW } +""" +The type of the post. If the post was created with the type MEDIA, +It stays invisible until a media file has been uploaded for the post +""" +enum PostType { + MEDIA + TEXT +} diff --git a/tslint.json b/tslint.json index 28549b8..4265452 100644 --- a/tslint.json +++ b/tslint.json @@ -29,7 +29,8 @@ "privacies": "all", "locations": "instance" } - }] + }], + "array-type": false }, "jsRules": { "max-line-length": {