diff --git a/package-lock.json b/package-lock.json index a0cd81a95..adb0cad4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "express": "^4.18.2", "express-fileupload": "^1.4.0", "express-jsdoc-swagger": "^1.8.0", - "express-swaggerize-ui": "^1.1.0", "jsonwebtoken": "^9.0.0", "ldap-escape": "^2.0.6", "ldapts": "^4.2.4", @@ -3502,15 +3501,6 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, - "node_modules/express-swaggerize-ui": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/express-swaggerize-ui/-/express-swaggerize-ui-1.1.0.tgz", - "integrity": "sha512-dDJuWV/GlISNYyKvFMa3EDr6sYzMgMrVRCt9o1kQxaIIKnmK1NJvaTzGbRIokIlGGHriIT6E2ztorRyRxLuOzA==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dependencies": { - "express": "^4.13.3" - } - }, "node_modules/express/node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", diff --git a/src/controller/balance-controller.ts b/src/controller/balance-controller.ts index 269bfc99d..3e4f60774 100644 --- a/src/controller/balance-controller.ts +++ b/src/controller/balance-controller.ts @@ -109,7 +109,6 @@ export default class BalanceController extends BaseController { */ private async getAllBalances(req: RequestWithToken, res: Response): Promise { this.logger.trace('Get all balances by', req.token.user); - let params: GetBalanceParameters; let take; let skip; diff --git a/src/controller/flagged-transaction-controller.ts b/src/controller/flagged-transaction-controller.ts new file mode 100644 index 000000000..5e344063e --- /dev/null +++ b/src/controller/flagged-transaction-controller.ts @@ -0,0 +1,457 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2020 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { Response } from 'express'; +import log4js, { Logger } from 'log4js'; +import BaseController, { BaseControllerOptions } from './base-controller'; +import Policy from './policy'; +import { RequestWithToken } from '../middleware/token-middleware'; +import FileService from '../service/file-service'; +import { parseRequestPagination } from '../helpers/pagination'; +import FlaggedTransactionRequest from "./request/flagged-transaction-request"; +import FlaggedTransactionService, {CreateFlaggedTransactionParams} from "../service/flagged-transaction-service"; +import FlaggedTransaction, {FlagStatus} from "../entity/transactions/flagged-transaction"; +import {EventShiftRequest} from "./request/event-request"; +import UpdateFlaggedTransactionRequest from "./request/UpdateFlaggedTransactionRequest"; + +export default class FlaggedTransactionController extends BaseController { + private logger: Logger = log4js.getLogger('FlaggedTransactionController'); + + private fileService: FileService; + + /** + * Creates a new flagged transaction controller instance. + * @param options - The options passed to the base controller. + */ + public constructor(options: BaseControllerOptions) { + super(options); + this.logger.level = process.env.LOG_LEVEL; + } + + public getPolicy(): Policy { + return { + '/': { + GET: { + policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'FlaggedTransactions', ['*']), + handler: this.returnPendingFlaggedTransactions.bind(this), + }, + POST: { + body: { modelName: 'FlaggedTransactionRequest' }, + policy: async (req) => this.roleManager.can(req.token.roles, 'create', 'all', 'FlaggedTransactions', ['*']), + handler: this.createFlaggedTransaction.bind(this), + }, + }, + '/:id(\\d+)': { + GET: { + policy: async (req: RequestWithToken) => this.roleManager.can(req.token.roles, 'get', 'all', 'FlaggedTransactions', ['*']), + handler: this.getSingleFlaggedTransaction.bind(this), + }, + PATCH: { + policy: async (req) => this.roleManager.can(req.token.roles, 'update', 'all', 'FlaggedTransactions', ['*']), + handler: this.updateFlaggedTransaction.bind(this), + }, + DELETE: { + policy: async (req) => this.roleManager.can(req.token.roles, 'delete', 'all', 'FlaggedTransactions', ['*']), + handler: this.deleteFlaggedTransaction.bind(this), + }, + }, + // '/all': { + // GET: { + // policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'FlaggedTransactions', ['*']), + // handler: this.returnAllFlaggedTransactions.bind(this), + // }, + // }, + // '/:id(\\d+)': { + // GET: { + // policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'FlaggedTransactions', ['*']), + // handler: this.returnSingleBanner.bind(this), + // }, + // PATCH: { + // body: { modelName: 'BannerRequest' }, + // policy: async (req) => this.roleManager.can(req.token.roles, 'update', 'all', 'FlaggedTransactions', ['*']), + // handler: this.updateBanner.bind(this), + // }, + // }, + }; + } + + /** + * GET /flaggedtransactions + * @summary Returns all existing flagged transactions + * @operationId getPendingFlaggedTransactions + * @tags flagged - Operations of the flagged transactions controller + * @security JWT + * @param {integer} take.query - How many flagged transactions the endpoint should return + * @param {integer} skip.query - How many flagged transactions should be skipped (for pagination) + * @return {PaginatedFlaggedTransactionResponse} 200 - All existing flagged transactions + * @return {string} 400 - Validation error + * @return {string} 500 - Internal server error + */ + public async returnPendingFlaggedTransactions(req: RequestWithToken, res: Response): Promise { + this.logger.trace('Get all pending flagged transactions by', req.token.user); + + let take; + let skip; + try { + const pagination = parseRequestPagination(req); + take = pagination.take; + skip = pagination.skip; + } catch (e) { + res.status(400).send(e.message); + return; + } + + try { + res.json(await FlaggedTransactionService.getFlaggedTransactions({}, { take, skip })); + } catch (error) { + this.logger.error('Could not return all flagged transactions:', error); + res.status(500).json('Internal server error.'); + } + } + + /** + * POST /flaggedtransactions + * @summary Creates a flagged transaction + * @operationId createFlaggedTransaction + * @tags flagged - Operations of the flagged transactions controller + * @security JWT + * @param {FlaggedTransactionRequest} request.body.required - The flagged transaction which should be created + * @return {FlaggedTransactionResponse} 200 - The created flagged transaction entity. + * @return {string} 400 - Validation error + * @return {string} 500 - Internal server error + */ + public async createFlaggedTransaction(request: RequestWithToken, res: Response): Promise{ + const body = request.body as FlaggedTransactionRequest; + this.logger.trace('Create flagged transaction', body, 'by user', request.token.user); + + const params: CreateFlaggedTransactionParams = { + status: FlagStatus.TODO, + reason: body.reason, + flaggedById: request.token.user.id, + transactionId: body.transactionId, + }; + try { + + res.json(await FlaggedTransactionService.createFlaggedTransaction(params)); + } catch (error) { + this.logger.error('Could not create flagged transaction:', error); + this.logger.error(params); + res.status(500).json('Internal server error.'); + } + } + + /** + * PATCH /flaggedtransactions/{id} + * @summary Update a flagged transaction + * @tags flagged - Operations of the flagged transactions controller + * @operationId updateFlaggedTransaction + * @security JWT + * @param {integer} id.path.required - The id of the flagged transaction to be updated + * @param {UpdateFlaggedTransactionRequest} request.body.required + * @return {FlaggedTransactionResponse} 200 - Created Flagged Transaction + * @return {string} 400 - Validation Error + * @return {string} 500 - Internal Server error + */ + public async updateFlaggedTransaction(req: RequestWithToken, res: Response) { + const { id: rawId } = req.params; + const body = req.body as UpdateFlaggedTransactionRequest; + this.logger.trace('Update flaggedTransaction', rawId, 'with body', body, 'by user', req.token.user); + + let id = Number.parseInt(rawId, 10); + try { + const flaggedTransaction = await FlaggedTransaction.findOne({where: { id }}); + if (flaggedTransaction == null) { + res.status(404).send(); + return; + } + } catch (error) { + this.logger.error('Could not update flagged transaction:', error); + res.status(500).json('Internal server error.'); + } + + try { + res.json(await FlaggedTransactionService.updateFlaggedTransaction(id, body)); + } catch (error) { + this.logger.error('Could not update flagged transaction:', error); + res.status(500).json('Internal server error.'); + } + } + + /** + * GET /flaggedtransactions/{id} + * @summary - Returns a single flagged transaction + * @operationId getSingleFlaggedTransactions + * @tags flagged - Operations of the flagged transactions controller + * @param {integer} id.path.required - The id of the flagged transaction which should be returned + * @security JWT + * @return {FlaggedTransactionResponse} 200 - The requested flagged transaction + * @return {string} 404 - Not found error + * @return {string} 500 - Internal server error + */ + public async getSingleFlaggedTransaction(req: RequestWithToken, res: Response): Promise { + const { id } = req.params; + this.logger.trace('Get single flagged transaction', id, 'by user', req.token.user); + + const flaggedTransactionId = parseInt(id, 10); + + // Handle request + try { + const flaggedTransaction = await FlaggedTransactionService.getSingleFlaggedTransaction(flaggedTransactionId); + if (!flaggedTransaction) { + res.status(404).json('Flagged transaction not found.'); + return; + } else { + res.json(flaggedTransaction); + } + } catch (error) { + this.logger.error('Could not return single flagged transaction:', error); + res.status(500).json('Internal server error.'); + } + } + + /** + * DELETE /flaggedtransactions/{id} + * @summary Deletes a flagged transaction. + * @operationId deleteFlaggedTransaction + * @tags flagged - Operations of the flagged transactions controller + * @security JWT + * @param {integer} id.path.required - The id of the flagged transaction which should be deleted + * @return {string} 404 - Invoice not found + * @return {string} 204 - Update success + * @return {string} 500 - Internal server error + */ + public async deleteFlaggedTransaction(req: RequestWithToken, res: Response): Promise { + const { id } = req.params; + const flaggedTransactionId = parseInt(id, 10); + this.logger.trace('Delete Flagged Transaction', id, 'by user', req.token.user); + + try { + const flaggedTransaction = await FlaggedTransactionService.deleteFlaggedTransaction(flaggedTransactionId); + if (flaggedTransaction) { + res.status(204).json(); + } else { + res.status(404).json('Flagged transaction not found'); + } + } catch (error) { + this.logger.error('Could not remove flagged transaction:', error); + res.status(500).json('Internal server error.'); + } + } + // /** + // * POST /banners + // * @summary Saves a banner to the database + // * @operationId create + // * @tags banners - Operations of banner controller + // * @param {BannerRequest} request.body.required - The banner which should be created + // * @security JWT + // * @return {BannerResponse} 200 - The created banner entity + // * @return {string} 400 - Validation error + // * @return {string} 500 - Internal server error + // */ + // public async createBanner(req: RequestWithToken, res: Response): Promise { + // const body = req.body as BannerRequest; + // this.logger.trace('Create banner', body, 'by user', req.token.user); + // + // // handle request + // try { + // if (BannerService.verifyBanner(body)) { + // res.json(await BannerService.createBanner(body)); + // } else { + // res.status(400).json('Invalid banner.'); + // } + // } catch (error) { + // this.logger.error('Could not create banner:', error); + // res.status(500).json('Internal server error.'); + // } + // } + // + // /** + // * POST /banners/{id}/image + // * @summary Uploads a banner image to the given banner + // * @operationId updateImage + // * @tags banners - Operations of banner controller + // * @param {integer} id.path.required - The id of the banner + // * @param {FileRequest} request.body.required - banner image - multipart/form-data + // * @security JWT + // * @return 204 - Success + // * @return {string} 400 - Validation error + // * @return {string} 500 - Internal server error + // */ + // public async uploadBannerImage(req: RequestWithToken, res: Response): Promise { + // const { id } = req.params; + // const { files } = req; + // this.logger.trace('Upload banner image for banner', id, 'by user', req.token.user); + // + // if (!req.files || Object.keys(files).length !== 1) { + // res.status(400).send('No file or too many files were uploaded'); + // return; + // } + // if (files.file === undefined) { + // res.status(400).send("No file is uploaded in the 'file' field"); + // return; + // } + // const file = files.file as UploadedFile; + // if (file.data === undefined) { + // res.status(400).send('File body data is missing from request'); + // return; + // } + // if (file.name === undefined) { + // res.status(400).send('File name is missing from request'); + // return; + // } + // + // const bannerId = parseInt(id, 10); + // + // try { + // const banner = await Banner.findOne({ where: { id: bannerId }, relations: ['image'] }); + // if (banner) { + // await this.fileService.uploadEntityImage( + // banner, file, req.token.user, + // ); + // res.status(204).send(); + // return; + // } + // res.status(404).json('Banner not found'); + // return; + // } catch (error) { + // this.logger.error('Could not upload image:', error); + // res.status(500).json('Internal server error'); + // } + // } + // + // /** + // * GET /banners/{id} + // * @summary Returns the requested banner + // * @operationId getBanner + // * @tags banners - Operations of banner controller + // * @param {integer} id.path.required - The id of the banner which should be returned + // * @security JWT + // * @return {BannerResponse} 200 - The requested banner entity + // * @return {string} 404 - Not found error + // * @return {string} 500 - Internal server error + // */ + // public async returnSingleBanner(req: RequestWithToken, res: Response): Promise { + // const { id } = req.params; + // this.logger.trace('Get single banner', id, 'by user', req.token.user); + // + // // handle request + // try { + // // check if banner in database + // const { records } = await BannerService.getBanners({ bannerId: Number.parseInt(id, 10) }); + // if (records.length > 0) { + // res.json(records[0]); + // } else { + // res.status(404).json('Banner not found.'); + // } + // } catch (error) { + // this.logger.error('Could not return banner:', error); + // res.status(500).json('Internal server error.'); + // } + // } + // + // /** + // * PATCH /banners/{id} + // * @summary Updates the requested banner + // * @operationId update + // * @tags banners - Operations of banner controller + // * @param {integer} id.path.required - The id of the banner which should be updated + // * @param {BannerRequest} request.body.required - The updated banner + // * @security JWT + // * @return {BannerResponse} 200 - The requested banner entity + // * @return {string} 400 - Validation error + // * @return {string} 404 - Not found error + // * @return {string} 500 - Internal server error + // */ + // public async updateBanner(req: RequestWithToken, res: Response): Promise { + // const body = req.body as BannerRequest; + // const { id } = req.params; + // this.logger.trace('Update banner', id, 'by user', req.token.user); + // + // // handle request + // try { + // if (BannerService.verifyBanner(body)) { + // // try patching the banner + // const banner = await BannerService.updateBanner(Number.parseInt(id, 10), body); + // if (banner) { + // res.json(banner); + // } else { + // res.status(404).json('Banner not found.'); + // } + // } else { + // res.status(400).json('Invalid banner.'); + // } + // } catch (error) { + // this.logger.error('Could not update banner:', error); + // res.status(500).json('Internal server error.'); + // } + // } + // + // /** + // * DELETE /banners/{id} + // * @summary Deletes the requested banner + // * @operationId delete + // * @tags banners - Operations of banner controller + // * @param {integer} id.path.required - The id of the banner which should be deleted + // * @security JWT + // * @return {BannerResponse} 200 - The deleted banner entity + // * @return {string} 404 - Not found error + // */ + // public async removeBanner(req: RequestWithToken, res: Response): Promise { + // const { id } = req.params; + // this.logger.trace('Remove banner', id, 'by user', req.token.user); + // + // // handle request + // try { + // // check if banner in database + // const banner = await BannerService.deleteBanner(Number.parseInt(id, 10), this.fileService); + // if (banner) { + // res.json(banner); + // } else { + // res.status(404).json('Banner not found.'); + // } + // } catch (error) { + // this.logger.error('Could not remove banner:', error); + // res.status(500).json('Internal server error.'); + // } + // } + // + // /** + // * GET /banners/active + // * @summary Returns all active banners + // * @operationId getActive + // * @tags banners - Operations of banner controller + // * @security JWT + // * @param {integer} take.query - How many banners the endpoint should return + // * @param {integer} skip.query - How many banners should be skipped (for pagination) + // * @return {PaginatedBannerResponse} 200 - All active banners + // * @return {string} 400 - Validation error + // */ + // public async returnActiveBanners(req: RequestWithToken, res: Response): Promise { + // const { body } = req; + // this.logger.trace('Get active banners', body, 'by user', req.token.user); + // + // const { take, skip } = parseRequestPagination(req); + // + // // handle request + // try { + // res.json(await BannerService.getBanners({ active: true }, { take, skip })); + // } catch (error) { + // this.logger.error('Could not return active banners:', error); + // res.status(500).json('Internal server error.'); + // } + // } +} diff --git a/src/controller/request/UpdateFlaggedTransactionRequest.ts b/src/controller/request/UpdateFlaggedTransactionRequest.ts new file mode 100644 index 000000000..c00385590 --- /dev/null +++ b/src/controller/request/UpdateFlaggedTransactionRequest.ts @@ -0,0 +1,26 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2020 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +/** + * @typedef {object} UpdateFlaggedTransactionRequest + * @property {integer} status - What the status is of the flagged transaction + */ +export default interface UpdateFlaggedTransactionRequest { + status: number, +} diff --git a/src/controller/request/flagged-transaction-request.ts b/src/controller/request/flagged-transaction-request.ts new file mode 100644 index 000000000..7fe7c67e1 --- /dev/null +++ b/src/controller/request/flagged-transaction-request.ts @@ -0,0 +1,28 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2020 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +/** + * @typedef {object} FlaggedTransactionRequest + * @property {number} transactionId - ID of the transaction being flagged. + * @property {string} reason - How long the banner should be shown (in seconds) + */ +export default interface FlaggedTransactionRequest { + transactionId: number, + reason: string, +} diff --git a/src/controller/request/transaction-request.ts b/src/controller/request/transaction-request.ts index 9fa386667..554a81719 100644 --- a/src/controller/request/transaction-request.ts +++ b/src/controller/request/transaction-request.ts @@ -62,3 +62,13 @@ export interface SubTransactionRowRequest { amount: number, totalPriceInclVat: DineroObjectRequest, } + +/** + * @typedef {object} UpdateTransactionRequest + * @property {integer} from - From user + * @property {integer} createdBy - Created by user + */ +export interface UpdateTransactionRequest { + from: number, + createdBy: number, +} diff --git a/src/controller/response/flagged-transaction-response.ts b/src/controller/response/flagged-transaction-response.ts new file mode 100644 index 000000000..a1d7e2f6e --- /dev/null +++ b/src/controller/response/flagged-transaction-response.ts @@ -0,0 +1,45 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2020 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import BaseResponse from './base-response'; +import { BaseUserResponse } from './user-response'; +import { TransactionResponse } from './transaction-response'; +import { PaginationResult } from '../../helpers/pagination'; + +/** + * @typedef {allOf|BaseResponse} FlaggedTransactionResponse + * @property {string} status.required - enum:TODO,ACCEPTED,REJECTED - The status of this flag. + * @property {BaseUserResponse} flaggedBy.required - The user created this flag. + * @property {string} reason.required - The reason why this transaction should be changed. + * @property {TransactionResponse} transaction.required - The transaction that has been flagged. + */ +export interface FlaggedTransactionResponse extends BaseResponse { + status: string, + flaggedBy: BaseUserResponse, + reason: string, + transaction: TransactionResponse, +} + +/** + * @typedef {object} PaginatedFlaggedTransactionResponse + * @property {PaginationResult} _pagination.required - Pagination metadata + * @property {Array} records.required - Returned flagged transactions + */ +export interface PaginatedFlaggedTransactionResponse { + _pagination: PaginationResult, + records: FlaggedTransactionResponse[], +} diff --git a/src/controller/transaction-controller.ts b/src/controller/transaction-controller.ts index 58bad13c5..82c06e219 100644 --- a/src/controller/transaction-controller.ts +++ b/src/controller/transaction-controller.ts @@ -25,7 +25,7 @@ import TransactionService, { } from '../service/transaction-service'; import { TransactionResponse } from './response/transaction-response'; import { parseRequestPagination } from '../helpers/pagination'; -import { TransactionRequest } from './request/transaction-request'; +import { SubTransactionRequest, TransactionRequest, UpdateTransactionRequest } from './request/transaction-request'; import Transaction from '../entity/transactions/transaction'; import User from '../entity/user/user'; import { asNumber } from '../helpers/validators'; @@ -207,7 +207,8 @@ export default class TransactionController extends BaseController { * @operationId updateTransaction * @tags transactions - Operations of transaction controller * @param {integer} id.path.required - The id of the transaction which should be updated - * @param {TransactionRequest} request.body.required - + * @param {integer} flaggedId.path - The id of a possible flagged transaction associated with the change. + * @param {UpdateTransactionRequest} request.body.required - * The updated transaction * @security JWT * @return {TransactionResponse} 200 - The requested transaction entity @@ -222,8 +223,21 @@ export default class TransactionController extends BaseController { // handle request try { - if (await Transaction.findOne({ where: { id: parseInt(id, 10) } })) { + const oldTransaction = await Transaction.findOne({ where: { id: parseInt(id, 10) } }); + if (oldTransaction) { if (await TransactionService.verifyTransaction(body, true)) { + // Minimize updates + const minimizedBody: UpdateTransactionRequest = { ...body }; + const oldTransactionRequest: UpdateTransactionRequest = { + createdBy: oldTransaction.createdById, + from: oldTransaction.fromId, + }; + Object.keys(body).forEach((key: keyof UpdateTransactionRequest) => { + if (oldTransactionRequest[key] === body[key]) { + delete minimizedBody[key]; + } + }); + res.status(200).json(await TransactionService.updateTransaction( parseInt(id, 10), body, )); diff --git a/src/database/database.ts b/src/database/database.ts index 75802ef12..f2411f2ea 100644 --- a/src/database/database.ts +++ b/src/database/database.ts @@ -67,6 +67,10 @@ import Event from '../entity/event/event'; import EventShiftAnswer from '../entity/event/event-shift-answer'; import EventShift from '../entity/event/event-shift'; import { TransactionSubscriber, TransferSubscriber } from '../subscriber'; +import TransactionChange from '../entity/transactions/logs/transaction-change'; +import TransactionLog from '../entity/transactions/logs/transaction-log'; +import SubTransactionChange from '../entity/transactions/logs/sub-transaction-change'; +import SubTransactionRowChange from '../entity/transactions/logs/sub-transaction-row-change'; export default class Database { public static async initialize(): Promise { @@ -133,6 +137,10 @@ export default class Database { Event, EventShift, EventShiftAnswer, + TransactionChange, + TransactionLog, + SubTransactionChange, + SubTransactionRowChange, ], subscribers: [ TransactionSubscriber, diff --git a/src/entity/transactions/logs/base-transaction-change.ts b/src/entity/transactions/logs/base-transaction-change.ts new file mode 100644 index 000000000..5befbf97d --- /dev/null +++ b/src/entity/transactions/logs/base-transaction-change.ts @@ -0,0 +1,48 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2020 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import BaseEntity from '../../base-entity'; +import { Column } from 'typeorm'; + +export default class BaseTransactionChange extends BaseEntity { + @Column({ + type: 'varchar', + transformer: { + to: (value: number | string) => { + return JSON.stringify(value); + }, + from: (value: string) => { + return JSON.parse(value); + }, + }, + }) + public old: number | string; + + @Column({ + type: 'varchar', + transformer: { + to: (value: number | string) => { + return JSON.stringify(value); + }, + from: (value: string) => { + return JSON.parse(value); + }, + }, + }) + public new: number | string; + +} diff --git a/src/entity/transactions/logs/sub-transaction-change.ts b/src/entity/transactions/logs/sub-transaction-change.ts new file mode 100644 index 000000000..24e137334 --- /dev/null +++ b/src/entity/transactions/logs/sub-transaction-change.ts @@ -0,0 +1,30 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2020 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { Column, Entity, ManyToOne } from 'typeorm'; +import BaseTransactionChange from './base-transaction-change'; +import SubTransaction from '../sub-transaction'; +import TransactionLog from './transaction-log'; + +@Entity() +export default class SubTransactionChange extends BaseTransactionChange { + @Column({ type: 'varchar' }) + public attribute: typeof SubTransaction; + + @ManyToOne(() => TransactionLog, { onDelete: 'CASCADE' }) + public log: TransactionLog; +} diff --git a/src/entity/transactions/logs/sub-transaction-row-change.ts b/src/entity/transactions/logs/sub-transaction-row-change.ts new file mode 100644 index 000000000..c255a26b9 --- /dev/null +++ b/src/entity/transactions/logs/sub-transaction-row-change.ts @@ -0,0 +1,30 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2020 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import BaseTransactionChange from './base-transaction-change'; +import { Column, Entity, ManyToOne } from 'typeorm'; +import SubTransactionRow from '../sub-transaction-row'; +import TransactionLog from './transaction-log'; + +@Entity() +export default class SubTransactionRowChange extends BaseTransactionChange { + @Column({ type: 'varchar' }) + public attribute: typeof SubTransactionRow; + + @ManyToOne(() => TransactionLog, { onDelete: 'CASCADE' }) + public log: TransactionLog; +} diff --git a/src/entity/transactions/logs/transaction-change.ts b/src/entity/transactions/logs/transaction-change.ts new file mode 100644 index 000000000..bf114d22e --- /dev/null +++ b/src/entity/transactions/logs/transaction-change.ts @@ -0,0 +1,30 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2020 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import BaseTransactionChange from './base-transaction-change'; +import { Column, Entity, ManyToOne } from 'typeorm'; +import Transaction from '../transaction'; +import TransactionLog from './transaction-log'; + +@Entity() +export default class TransactionChange extends BaseTransactionChange { + @Column({ type: 'varchar' }) + public attribute: typeof Transaction; + + @ManyToOne(() => TransactionLog, { onDelete: 'CASCADE' }) + public log: TransactionLog; +} diff --git a/src/entity/transactions/logs/transaction-log.ts b/src/entity/transactions/logs/transaction-log.ts new file mode 100644 index 000000000..9e4b4b7d4 --- /dev/null +++ b/src/entity/transactions/logs/transaction-log.ts @@ -0,0 +1,30 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2020 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import BaseEntity from '../../base-entity'; +import { Entity, ManyToOne, OneToMany } from 'typeorm'; +import User from '../../user/user'; +import TransactionChange from './transaction-change'; + +@Entity() +export default class TransactionLog extends BaseEntity { + @ManyToOne(() => User, { nullable: false }) + public createdBy: User; + + @OneToMany(() => TransactionChange, (change) => change.log) + public transactionChanges: TransactionChange[]; +} diff --git a/src/entity/transactions/transaction.ts b/src/entity/transactions/transaction.ts index 7ac32be0d..9702166da 100644 --- a/src/entity/transactions/transaction.ts +++ b/src/entity/transactions/transaction.ts @@ -16,7 +16,8 @@ * along with this program. If not, see . */ import { - Entity, ManyToOne, OneToMany, + Column, + Entity, JoinColumn, ManyToOne, OneToMany, } from 'typeorm'; // eslint-disable-next-line import/no-cycle import SubTransaction from './sub-transaction'; @@ -35,10 +36,18 @@ import PointOfSaleRevision from '../point-of-sale/point-of-sale-revision'; */ @Entity() export default class Transaction extends BaseEntity { + @Column({ nullable: false }) + public fromId: number; + @ManyToOne(() => User, { nullable: false }) + @JoinColumn({ name: 'fromId' }) public from: User; + @Column({ nullable: false }) + public createdById: number; + @ManyToOne(() => User, { nullable: false }) + @JoinColumn({ name: 'createdById' }) public createdBy: User; @OneToMany(() => SubTransaction, diff --git a/src/index.ts b/src/index.ts index 6ef84270b..eaf6c8b23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,7 @@ import DebtorController from './controller/debtor-controller'; import EventController from './controller/event-controller'; import EventShiftController from './controller/event-shift-controller'; import EventService from './service/event-service'; +import FlaggedTransactionController from "./controller/flagged-transaction-controller"; export class Application { app: express.Express; @@ -285,6 +286,7 @@ export default async function createApp(): Promise { application.app.use('/v1/files', new SimpleFileController(options).getRouter()); application.app.use('/v1/test', new TestController(options).getRouter()); } + application.app.use('/v1/flaggedtransactions', new FlaggedTransactionController(options).getRouter()); // Start express application. logger.info(`Server listening on port ${process.env.HTTP_PORT}.`); application.server = application.app.listen(process.env.HTTP_PORT); diff --git a/src/service/flagged-transaction-service.ts b/src/service/flagged-transaction-service.ts new file mode 100644 index 000000000..a2162b1fd --- /dev/null +++ b/src/service/flagged-transaction-service.ts @@ -0,0 +1,160 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2020 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { PaginationParameters } from '../helpers/pagination'; +import { + FlaggedTransactionResponse, + PaginatedFlaggedTransactionResponse, +} from '../controller/response/flagged-transaction-response'; +import FlaggedTransaction, { FlagStatus } from '../entity/transactions/flagged-transaction'; +import { UserFilterParameters } from './user-service'; +import QueryFilter, { FilterMapping } from '../helpers/query-filter'; +import { FindManyOptions } from 'typeorm'; +import { parseUserToBaseResponse } from '../helpers/revision-to-response'; +import TransactionService from './transaction-service'; +import User from '../entity/user/user'; +import Transaction from '../entity/transactions/transaction'; +import UpdateFlaggedTransactionRequest from '../controller/request/UpdateFlaggedTransactionRequest'; +import EventShift from '../entity/event/event-shift'; + +/** + * Parameters used to filter on Get Flagged Transactions functions. + */ +interface FlaggedTransactionsFilterParameters { + flaggedTransactionId?: number, + status?: FlagStatus, + flaggedBy?: UserFilterParameters, + reason?: string, +} + +export interface CreateFlaggedTransactionParams { + status: number; + reason: string; + flaggedById: number; + transactionId: number; +} + +export default class FlaggedTransactionService { + public static async asFlaggedTransactionResponse(transaction: FlaggedTransaction): Promise { + return { + flaggedBy: parseUserToBaseResponse(transaction.flaggedBy, false), + id: transaction.id, + reason: transaction.reason, + status: FlagStatus[transaction.status], + transaction: await TransactionService.asTransactionResponse(transaction.transaction), + }; + } + + /** + * Function for getting all flagged transactions + * @param filters - Query filters to apply + * @param pagination - Pagination to adhere to + */ + public static async getFlaggedTransactions( + filters: FlaggedTransactionsFilterParameters = {}, pagination: PaginationParameters = {}, + ): Promise { + const { take, skip } = pagination; + + const mapping: FilterMapping = { + flaggedTransactionId: 'id', + status: 'status', + }; + + const options: FindManyOptions = { + where: QueryFilter.createFilterWhereClause(mapping, filters), + order: { id: 'DESC' }, + }; + + const flaggedTransactions = await FlaggedTransaction.find({ + ...options, + take, + skip, + }); + + const records: FlaggedTransactionResponse[] = []; + const promises = flaggedTransactions.map(async (flaggedTransaction) => { + return records.push(await this.asFlaggedTransactionResponse(flaggedTransaction)); + }); + + void Promise.all(promises); + + return { + _pagination: { + take, + skip, + count: await FlaggedTransaction.count(options), + }, + records, + }; + } + + /** + * Creates a flagged transaction from a FlaggedTransactionRequest + * @param {CreateFlaggedTransactionParams} params - Flagged transaction request + * @returns {FlaggedTransaction.model} - Flagged transaction created + */ + public static async createFlaggedTransaction(params: CreateFlaggedTransactionParams) : Promise { + + const flaggedBy = await User.findOne({ where: { id: params.flaggedById } }); + const transaction = await Transaction.findOne({ + where: { id: params.transactionId }, + relations: [ + 'from', 'createdBy', 'subTransactions', 'subTransactions.to', 'subTransactions.subTransactionRows', + // We query a lot here, but we will parse this later to a very simple BaseResponse + 'pointOfSale', 'pointOfSale.pointOfSale', + 'subTransactions.container', 'subTransactions.container.container', + 'subTransactions.subTransactionRows.product', 'subTransactions.subTransactionRows.product.product', + 'subTransactions.subTransactionRows.product.vat', + ], + }); + const flaggedTransaction: FlaggedTransaction = Object.assign(new FlaggedTransaction(), { + status: params.status, + flaggedBy, + reason: params.reason, + transaction, + version: 1, // TODO: Handle logic for updating flagged transactions + }); + await FlaggedTransaction.save(flaggedTransaction); + return this.asFlaggedTransactionResponse(flaggedTransaction); + } + + public static async updateFlaggedTransaction(id: number, param: UpdateFlaggedTransactionRequest) : Promise { + const transaction = await FlaggedTransaction.findOne( { where: { id } } ); + if (!transaction) return undefined; + transaction.status = param.status; + await FlaggedTransaction.save(transaction); + return this.asFlaggedTransactionResponse(transaction); + } + + public static async getSingleFlaggedTransaction(id: number) : Promise { + const transaction: FlaggedTransaction = await FlaggedTransaction.findOne( { where: { id } } ); + if (!transaction) return null; + return this.asFlaggedTransactionResponse(transaction); + } + + public static async deleteFlaggedTransaction(id: number) : Promise { + const transaction: FlaggedTransaction = await FlaggedTransaction.findOne({ where: { id } } ); + + if (!transaction) { + return undefined; + } + + await FlaggedTransaction.delete(id); + + return this.asFlaggedTransactionResponse(transaction); + } +} diff --git a/src/service/transaction-service.ts b/src/service/transaction-service.ts index 5fc0acbb4..7a29bb970 100644 --- a/src/service/transaction-service.ts +++ b/src/service/transaction-service.ts @@ -71,6 +71,7 @@ import { reduceMapToVatEntries, } from '../helpers/transaction-mapper'; import ProductCategoryService from './product-category-service'; +import FlaggedTransaction from '../entity/transactions/flagged-transaction'; export interface TransactionFilterParameters { transactionId?: number | number[], @@ -689,7 +690,6 @@ export default class TransactionService { public static async updateTransaction(id: number, req: TransactionRequest): Promise { const transaction = await this.asTransaction(req, await Transaction.findOne({ where: { id } })); - // delete old transaction await this.deleteTransaction(id); @@ -713,6 +713,7 @@ export default class TransactionService { Promise { // get the transaction we should delete const transaction = await this.getSingleTransaction(id); + await FlaggedTransaction.delete(id); await Transaction.delete(id); // invalidate user balance cache diff --git a/test/seed.ts b/test/seed.ts index 95d199afc..8ca386cd8 100644 --- a/test/seed.ts +++ b/test/seed.ts @@ -61,6 +61,7 @@ import { calculateBalance } from './helpers/balance'; import GewisUser from '../src/gewis/entity/gewis-user'; import AssignedRole from '../src/entity/roles/assigned-role'; import MemberAuthenticator from '../src/entity/authenticator/member-authenticator'; +import FlaggedTransaction, { FlagStatus } from '../src/entity/transactions/flagged-transaction'; function getDate(startDate: Date, endDate: Date, i: number): Date { const diff = endDate.getTime() - startDate.getTime(); @@ -1357,6 +1358,26 @@ export async function seedBanners(users: User[]): Promise<{ return { banners, bannerImages }; } +export async function seedFlaggedTransactions(transactions: Transaction[]) { + const flaggedTransactions: FlaggedTransaction[] = []; + for (let i = transactions.length - 1; i >= 0; i--) { + if (i % 3 !== 0) continue; + const flaggedTransaction = Object.assign(new FlaggedTransaction(), { + id: i + 1, + status: FlagStatus[i % 3], + reason: `Reason number ${i}`, + flaggedBy: transactions[i].createdBy, + transaction: transactions[i], + }); + + flaggedTransactions.push(flaggedTransaction); + } + + await Promise.all(flaggedTransactions.map((flaggedTransaction) => FlaggedTransaction.save(flaggedTransaction))); + + return { flaggedTransactions }; +} + export interface DatabaseContent { users: User[], roles: AssignedRole[], @@ -1382,6 +1403,7 @@ export interface DatabaseContent { gewisUsers: GewisUser[], pinUsers: PinAuthenticator[], localUsers: LocalAuthenticator[], + flaggedTransactions: FlaggedTransaction[], } export default async function seedDatabase(): Promise { @@ -1413,6 +1435,7 @@ export default async function seedDatabase(): Promise { const { invoices, invoiceTransfers } = await seedInvoices(users, transactions); const { stripeDeposits, stripeDepositTransfers } = await seedStripeDeposits(users); const { banners } = await seedBanners(users); + const { flaggedTransactions } = await seedFlaggedTransactions(transactions); return { users, @@ -1439,5 +1462,6 @@ export default async function seedDatabase(): Promise { events, eventShifts, eventShiftAnswers, + flaggedTransactions, }; }