diff --git a/package-lock.json b/package-lock.json index 7572876..f56c08f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "dotenv": "^16.0.3", "express": "^4.18.2", "express-rate-limit": "^8.2.1", + "express-validator": "^7.3.1", "jimp": "^0.22.8", "jsonwebtoken": "^9.0.0", "nanoid": "^5.1.5", @@ -4280,6 +4281,19 @@ "express": ">= 4.11" } }, + "node_modules/express-validator": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", + "integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", diff --git a/package.json b/package.json index f5d7a74..8cec8ad 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dotenv": "^16.0.3", "express": "^4.18.2", "express-rate-limit": "^8.2.1", + "express-validator": "^7.3.1", "jimp": "^0.22.8", "jsonwebtoken": "^9.0.0", "nanoid": "^5.1.5", diff --git a/shared/constants.ts b/shared/constants.ts new file mode 100644 index 0000000..ae8726f --- /dev/null +++ b/shared/constants.ts @@ -0,0 +1,3 @@ +export const NON_NEGATIVE_REAL_NUMBER_REGEX = /^\d+(\.\d+)?$/; + +export const AUTH_MESSAGE_EXPIRATION_TIME_MS = 5 * 60 * 1000; // 5 minutes diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 20dacc9..dfec3ee 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -1,13 +1,39 @@ import jwt from 'jsonwebtoken'; import dotenv from 'dotenv'; import { Request, Response } from 'express'; +import crypto from 'crypto'; + import AuthData from '@/interfaces/bodies/user/AuthData.js'; +import RequestAuthBody from '@/interfaces/bodies/auth/RequestAuthBody.js'; +import authMessagesModel from '@/models/AuthMessages.js'; +import { AUTH_MESSAGE_EXPIRATION_TIME_MS } from 'shared/constants.js'; +import RequestAuthRes from '@/interfaces/responses/auth/RequestAuthRes.js'; +import sequelize from '@/sequelize.js'; import validateWallet from '../methods/validateWallet.js'; import userModel from '../models/User.js'; dotenv.config(); class AuthController { + requestAuth = async (req: Request, res: Response) => { + const { address, alias } = req.body as RequestAuthBody; + + const message = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + AUTH_MESSAGE_EXPIRATION_TIME_MS); + + const authMessageRow = await authMessagesModel.create({ + address, + alias, + message, + expiresAt, + }); + + return res.status(200).send({ + success: true, + data: authMessageRow.message, + }); + }; + async auth(req: Request, res: Response) { try { const userData: AuthData = req.body.data; @@ -18,6 +44,19 @@ class AuthController { return res.status(400).send({ success: false, data: 'Invalid auth data' }); } + const authMessageRow = await authMessagesModel.findOne({ + address, + alias, + message, + }); + + const isAuthMessageValid = + !!authMessageRow && authMessageRow.expires_at.getTime() > Date.now(); + + if (!isAuthMessageValid) { + return res.status(400).send({ success: false, data: 'Invalid auth message' }); + } + const dataValid = !!( userData && userData.address && @@ -28,17 +67,33 @@ class AuthController { return res.status(400).send({ success: false, data: 'Invalid auth data' }); } - const success = await userModel.add(userData); + let token: string | undefined; - if (success) { - const token = jwt.sign( - { ...userData }, - process.env.JWT_SECRET || '', - neverExpires ? undefined : { expiresIn: '24h' }, - ); - res.status(200).send({ success, data: token }); + await sequelize.transaction(async (transaction) => { + const success = await userModel.add(userData, { transaction }); + + if (success) { + await authMessagesModel.deleteOne( + { + address, + alias, + message, + }, + { transaction }, + ); + + token = jwt.sign( + { ...userData }, + process.env.JWT_SECRET || '', + neverExpires ? undefined : { expiresIn: '24h' }, + ); + } + }); + + if (token !== undefined) { + res.status(200).send({ success: true, data: token }); } else { - res.status(500).send({ success, data: 'Internal error' }); + res.status(500).send({ success: false, data: 'Internal error' }); } } catch (err) { console.log(err); diff --git a/src/controllers/dex.controller.ts b/src/controllers/dex.controller.ts index 690401b..264128c 100644 --- a/src/controllers/dex.controller.ts +++ b/src/controllers/dex.controller.ts @@ -3,6 +3,10 @@ import UserData from '@/interfaces/common/UserData.js'; import Currency from '@/schemes/Currency.js'; import Pair from '@/schemes/Pair.js'; import { Op } from 'sequelize'; +import GetAssetsPriceRatesBody from '@/interfaces/bodies/dex/GetAssetsPriceRatesBody.js'; +import GetAssetsPriceRatesRes, { + GetAssetsPriceRatesResPriceRate, +} from '@/interfaces/responses/dex/GetAssetsPriceRatesRes.js'; import User from '../schemes/User.js'; import ordersModel from '../models/Orders.js'; import dexModel from '../models/Dex.js'; @@ -104,10 +108,10 @@ class DexController { return res.status(200).send(result); } - async getAssetsPriceRates(req: Request, res: Response) { - const { assetsIds } = req.body; + getAssetsPriceRates = async (req: Request, res: Response) => { + const { assetsIds } = req.body as GetAssetsPriceRatesBody; - const currencysRows = await Currency.findAll({ + const currenciesRows = await Currency.findAll({ where: { asset_id: { [Op.in]: assetsIds, @@ -115,61 +119,42 @@ class DexController { }, }); - if (!currencysRows) { - return res.status(200).send({ - success: false, - data: 'Assets with this id doesn`t exists', - }); - } + const currencyIds = currenciesRows.map((currency) => currency.id); - const currencyIds = currencysRows.map((currency) => currency.id); - - const pairsRows = ( - (await Pair.findAll({ - where: { - first_currency_id: { - [Op.in]: currencyIds, - }, + const pairsRows = (await Pair.findAll({ + where: { + first_currency_id: { + [Op.in]: currencyIds, }, - include: [ - { - model: Currency, - as: 'first_currency', - required: true, - attributes: ['asset_id'], - }, - ], - })) || [] - ).map((pair) => ({ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - asset_id: pair?.first_currency?.asset_id, - rate: pair.rate, - })); + }, + include: [ + { + model: Currency, + as: 'first_currency', + required: true, + attributes: ['asset_id'], + }, + ], + })) as (Pair & { first_currency: Currency })[]; - if (!pairsRows || pairsRows.length === 0) { - return res.status(200).send({ - success: false, - data: 'Assets with this id doesn`t exists', - }); - } + const priceRates: GetAssetsPriceRatesResPriceRate[] = pairsRows.map((pairRow) => { + const assetId = pairRow.first_currency.asset_id; - // const priceRates = await Promise.all(pairsRows.map(async (pair) => { - // const currency = await Currency.findOne({ where: { - // id: pair.first_currency_id - // }}) - - // return { - // asset_id: currency?.asset_id, - // rate: pair.rate - // } - // })) + return { + asset_id: assetId, + rate: pairRow?.rate ?? null, + day_change: pairRow?.coefficient ?? null, + day_volume: pairRow?.volume ?? null, + day_high: pairRow?.high ?? null, + day_low: pairRow?.low ?? null, + }; + }); return res.status(200).send({ success: true, - priceRates: pairsRows, + priceRates, }); - } + }; async findPairID(req: Request, res: Response) { const { first, second } = req.body; diff --git a/src/controllers/orders.controller.ts b/src/controllers/orders.controller.ts index 61bc154..518318d 100644 --- a/src/controllers/orders.controller.ts +++ b/src/controllers/orders.controller.ts @@ -1,10 +1,28 @@ import { Request, Response } from 'express'; import Decimal from 'decimal.js'; + +import CreateOrderRes, { CreateOrderErrorCode } from '@/interfaces/responses/orders/CreateOrderRes'; +import GetUserOrdersRes, { + GetUserOrdersErrorCode, + GetUserOrdersResCurrency, + GetUserOrdersResOrderData, +} from '@/interfaces/responses/orders/GetUserOrdersRes'; +import GetUserOrdersAllPairsBody from '@/interfaces/bodies/orders/GetUserOrdersAllPairsBody'; +import GetUserOrdersAllPairsRes, { + GetUserOrdersAllPairsErrorCode, + GetUserOrdersAllPairsResPair, +} from '@/interfaces/responses/orders/GetUserOrdersAllPairsRes'; +import CancelAllBody, { CancelAllBodyOrderType } from '@/interfaces/bodies/orders/CancelAllBody'; +import sequelize from '@/sequelize'; +import CancelAllRes, { CancelAllErrorCode } from '@/interfaces/responses/orders/CancelAllRes'; import candlesModel from '../models/Candles'; import ordersModel from '../models/Orders'; import CreateOrderBody from '../interfaces/bodies/orders/CreateOrderBody'; import GetUserOrdersPageBody from '../interfaces/bodies/orders/GetUserOrdersPageBody'; -import GetUserOrdersBody from '../interfaces/bodies/orders/GetUserOrdersBody'; +import GetUserOrdersBody, { + GetUserOrdersBodyStatus, + GetUserOrdersBodyType, +} from '../interfaces/bodies/orders/GetUserOrdersBody'; import CancelOrderBody from '../interfaces/bodies/orders/CancelOrderBody'; import GetCandlesBody from '../interfaces/bodies/orders/GetCandlesBody'; import GetChartOrdersBody from '../interfaces/bodies/orders/GetChartOrdersBody'; @@ -16,70 +34,97 @@ import Currency from '../schemes/Currency'; import { validateTokensInput } from '../../shared/utils'; class OrdersController { - async createOrder(req: Request, res: Response) { + static CURRENCY_DECIMAL_POINT_NOT_FOUND_ERROR_MSG = 'CURRENCY_DECIMAL_POINT_MISSING'; + async createOrder(req: Request, res: Response) { try { - const { orderData } = req.body as CreateOrderBody; + const body = req.body as CreateOrderBody; + const { orderData } = body; + const { price, amount, pairId } = orderData; - const isFull = - orderData && - orderData?.type && - orderData?.side && - orderData?.price && - orderData?.amount && - orderData?.pairId; + const priceDecimal = new Decimal(price); + const amountDecimal = new Decimal(amount); - const priceDecimal = new Decimal(orderData?.price || 0); - const amountDecimal = new Decimal(orderData?.amount || 0); - - const pair = await Pair.findByPk(orderData?.pairId); + const pair = await Pair.findByPk(pairId); const firstCurrency = await Currency.findByPk(pair?.first_currency_id); const secondCurrency = await Currency.findByPk(pair?.second_currency_id); if (!pair || !firstCurrency || !secondCurrency) { - return res.status(400).send({ success: false, data: 'Invalid pair data' }); + return res.status(400).send({ + success: false, + data: CreateOrderErrorCode.INVALID_ORDER_DATA, + }); } - const firstCurrencyDecimalPoint = firstCurrency?.asset_info?.decimal_point || 12; - const secondCurrencyDecimalPoint = secondCurrency?.asset_info?.decimal_point || 12; + const firstCurrencyDP = firstCurrency.asset_info?.decimal_point; + const secondCurrencyDP = secondCurrency.asset_info?.decimal_point; - const rangeCorrect = (() => { - const priceCorrect = validateTokensInput( - orderData?.price, - secondCurrencyDecimalPoint, - ).valid; - const amountCorrect = validateTokensInput( - orderData?.amount, - firstCurrencyDecimalPoint, - ).valid; - - return priceCorrect && amountCorrect; - })(); - - const priceDecimalPointCorrect = priceDecimal.toString().replace('.', '').length <= 20; - const amountDecimalPointCorrect = - amountDecimal.toString().replace('.', '').length <= 18; - - if (!priceDecimalPointCorrect || !amountDecimalPointCorrect) { - return res.status(400).send({ success: false, data: 'Invalid pair data' }); + if (firstCurrencyDP === undefined || secondCurrencyDP === undefined) { + throw new Error(OrdersController.CURRENCY_DECIMAL_POINT_NOT_FOUND_ERROR_MSG); } - if (!isFull || !rangeCorrect) - return res.status(400).send({ success: false, data: 'Invalid order data' }); + const totalDecimal = priceDecimal.mul(amountDecimal); + const total = totalDecimal.toFixed(); + + const isPriceValid = validateTokensInput(price, secondCurrencyDP).valid; + const isAmountValid = validateTokensInput(amount, firstCurrencyDP).valid; + const isTotalValid = validateTokensInput(total, secondCurrencyDP).valid; + + const areAmountsValid = isPriceValid && isAmountValid && isTotalValid; + + if (!areAmountsValid) { + return res.status(400).send({ + success: false, + data: CreateOrderErrorCode.INVALID_ORDER_DATA, + }); + } const result = await ordersModel.createOrder(req.body); - if (result.data === 'Invalid order data') return res.status(400).send(result); + if (result.data === 'Invalid order data') + return res.status(400).send({ + success: false, + data: CreateOrderErrorCode.INVALID_ORDER_DATA, + }); - if (result.data === 'Same order') return res.status(400).send(result); + if (result.data === 'Same order') + return res.status(400).send({ + success: false, + data: CreateOrderErrorCode.SAME_ORDER, + }); - if (result.data === 'Internal error') return res.status(500).send(result); + if (result.data === 'Internal error') { + throw new Error('orderModel.createOrder returned Internal error'); + } - res.status(200).send(result); + if (typeof result.data === 'string') { + throw new Error('Invalid orderModel.createOrder result'); + } + + const createdOrder = result.data; + + res.status(200).send({ + success: true, + data: { + id: createdOrder.id, + type: createdOrder.type, + timestamp: createdOrder.timestamp, + side: createdOrder.side, + price: createdOrder.price, + amount: createdOrder.amount, + total: createdOrder.total, + pair_id: createdOrder.pairId, + user_id: createdOrder.userId, + status: createdOrder.status, + left: createdOrder.left, + hasNotification: createdOrder.hasNotification, + immediateMatch: createdOrder.immediateMatch, + }, + }); } catch (err) { console.log(err); - res.status(500).send({ success: false, data: 'Unhandled error' }); + res.status(500).send({ success: false, data: CreateOrderErrorCode.UNHANDLED_ERROR }); } } @@ -128,21 +173,171 @@ class OrdersController { } } - async getUserOrders(req: Request, res: Response) { + private fromGetUserOrdersServiceToResCurrencyMapper( + currency: Currency, + ): GetUserOrdersResCurrency { + return { + id: currency.id, + name: currency.name, + code: currency.code, + type: currency.type, + asset_id: currency.asset_id, + auto_parsed: currency.auto_parsed, + asset_info: currency.asset_info + ? { + asset_id: currency.asset_info.asset_id, + logo: currency.asset_info.logo, + price_url: currency.asset_info.price_url, + ticker: currency.asset_info.ticker, + full_name: currency.asset_info.full_name, + total_max_supply: currency.asset_info.total_max_supply, + current_supply: currency.asset_info.current_supply, + decimal_point: currency.asset_info.decimal_point, + meta_info: currency.asset_info.meta_info, + } + : undefined, + whitelisted: currency.whitelisted, + }; + } + getUserOrders = async (req: Request, res: Response) => { try { - await userModel.resetExchangeNotificationsAmount( - (req.body.userData as UserData).address, - ); - const result = await ordersModel.getUserOrders(req.body as GetUserOrdersBody); + const body = req.body as GetUserOrdersBody; + const { userData, offset, limit, filterInfo } = body; - if (result.data === 'Internal error') return res.status(500).send(result); + await userModel.resetExchangeNotificationsAmount(userData.address); - res.status(200).send(result); + const serviceOrderType: 'buy' | 'sell' | undefined = (() => { + if (filterInfo?.type === undefined) { + return undefined; + } + + return filterInfo.type === GetUserOrdersBodyType.BUY ? 'buy' : 'sell'; + })(); + + const serviceOrderStatus: 'active' | 'finished' | undefined = (() => { + if (filterInfo?.status === undefined) { + return undefined; + } + + return filterInfo.status === GetUserOrdersBodyStatus.ACTIVE ? 'active' : 'finished'; + })(); + + const result = await ordersModel.getUserOrders({ + address: userData.address, + offset, + limit, + filterInfo: { + pairId: filterInfo.pairId, + type: serviceOrderType, + status: serviceOrderStatus, + date: + filterInfo.date !== undefined + ? { + from: filterInfo.date.from, + to: filterInfo.date.to, + } + : undefined, + }, + }); + + if (result.data === 'Internal error') { + throw new Error('ordersModel.getUserOrders returned Internal error'); + } + + const { totalItemsCount } = result; + + const userOrders = result.data.map((order) => { + const mappedOrder: GetUserOrdersResOrderData = { + id: order.id, + type: order.type, + timestamp: order.timestamp, + side: order.side, + price: order.price, + amount: order.amount, + total: order.total, + pair_id: order.pair_id, + user_id: order.user_id, + status: order.status, + left: order.left, + hasNotification: order.hasNotification, + pair: { + id: order.pair.id, + first_currency_id: order.pair.first_currency_id, + second_currency_id: order.pair.second_currency_id, + rate: order.pair.rate, + coefficient: order.pair.coefficient, + high: order.pair.high, + low: order.pair.low, + volume: order.pair.volume, + featured: order.pair.featured, + first_currency: this.fromGetUserOrdersServiceToResCurrencyMapper( + order.pair.first_currency, + ), + second_currency: this.fromGetUserOrdersServiceToResCurrencyMapper( + order.pair.second_currency, + ), + }, + first_currency: this.fromGetUserOrdersServiceToResCurrencyMapper( + order.first_currency, + ), + second_currency: this.fromGetUserOrdersServiceToResCurrencyMapper( + order.second_currency, + ), + isInstant: order.isInstant, + }; + + return mappedOrder; + }); + + res.status(200).send({ + success: true, + totalItemsCount, + data: userOrders, + }); } catch (err) { console.log(err); - res.status(500).send({ success: false, data: 'Unhandled error' }); + res.status(500).send({ + success: false, + data: GetUserOrdersErrorCode.UNHANDLED_ERROR, + }); } - } + }; + + getUserOrdersAllPairs = async (req: Request, res: Response) => { + try { + const body = req.body as GetUserOrdersAllPairsBody; + const { userData } = body; + + const getUserOrdersAllPairsResult = await ordersModel.getUserOrdersAllPairs( + userData.address, + ); + + const pairs = getUserOrdersAllPairsResult.data; + + const responsePairs: GetUserOrdersAllPairsResPair[] = pairs.map((pair) => ({ + id: pair.id, + firstCurrency: { + id: pair.firstCurrency.id, + ticker: pair.firstCurrency.ticker, + }, + secondCurrency: { + id: pair.secondCurrency.id, + ticker: pair.secondCurrency.ticker, + }, + })); + + res.status(200).send({ + success: true, + data: responsePairs, + }); + } catch (err) { + console.log(err); + res.status(500).send({ + success: false, + data: GetUserOrdersAllPairsErrorCode.UNHANDLED_ERROR, + }); + } + }; async cancelOrder(req: Request, res: Response) { try { @@ -260,6 +455,51 @@ class OrdersController { res.status(500).send({ success: false, data: 'Unhandled error' }); } } + + async cancelAll(req: Request, res: Response) { + try { + const body = req.body as CancelAllBody; + const { userData, filterInfo } = body; + + const filterType = (() => { + if (filterInfo.type === undefined) { + return undefined; + } + + return filterInfo.type === CancelAllBodyOrderType.BUY + ? GetUserOrdersBodyType.BUY + : GetUserOrdersBodyType.SELL; + })(); + + await sequelize.transaction(async (transaction) => { + await ordersModel.cancelAll( + { + address: userData.address, + filterInfo: { + pairId: filterInfo.pairId, + type: filterType, + date: + filterInfo.date !== undefined + ? { + from: filterInfo.date.from, + to: filterInfo.date.to, + } + : undefined, + }, + }, + { transaction }, + ); + }); + + res.status(200).send({ success: true }); + } catch (err) { + console.log(err); + res.status(500).send({ + success: false, + data: CancelAllErrorCode.UNHANDLED_ERROR, + }); + } + } } const ordersController = new OrdersController(); diff --git a/src/interfaces/bodies/auth/RequestAuthBody.ts b/src/interfaces/bodies/auth/RequestAuthBody.ts new file mode 100644 index 0000000..6f1d368 --- /dev/null +++ b/src/interfaces/bodies/auth/RequestAuthBody.ts @@ -0,0 +1,13 @@ +import { body } from 'express-validator'; + +interface RequestAuthBody { + address: string; + alias: string; +} + +export const requestAuthBodyValidator = [ + body('address').isString().notEmpty(), + body('alias').isString().notEmpty(), +]; + +export default RequestAuthBody; diff --git a/src/interfaces/bodies/dex/GetAssetsPriceRatesBody.ts b/src/interfaces/bodies/dex/GetAssetsPriceRatesBody.ts new file mode 100644 index 0000000..055bb0a --- /dev/null +++ b/src/interfaces/bodies/dex/GetAssetsPriceRatesBody.ts @@ -0,0 +1,14 @@ +import { body } from 'express-validator'; + +interface GetAssetsPriceRatesBody { + assetsIds: string[]; +} + +export const getAssetsPriceRatesValidator = [ + body('assetsIds') + .isArray({ min: 1 }) + .withMessage('assetsIds must be a non-empty array of strings'), + body('assetsIds.*').isString().withMessage('Each assetId must be a string'), +]; + +export default GetAssetsPriceRatesBody; diff --git a/src/interfaces/bodies/orders/CancelAllBody.ts b/src/interfaces/bodies/orders/CancelAllBody.ts new file mode 100644 index 0000000..31438fa --- /dev/null +++ b/src/interfaces/bodies/orders/CancelAllBody.ts @@ -0,0 +1,50 @@ +import UserData from '@/interfaces/common/UserData'; +import { body } from 'express-validator'; + +export enum CancelAllBodyOrderType { + // eslint-disable-next-line no-unused-vars + BUY = 'buy', + // eslint-disable-next-line no-unused-vars + SELL = 'sell', +} + +interface CancelAllBody { + userData: UserData; + + filterInfo: { + pairId?: number; + type?: CancelAllBodyOrderType; + date?: { + // UNIX timestamps in milliseconds + from: number; + to: number; + }; + }; +} + +export const cancelAllValidator = [ + body('filterInfo').isObject().withMessage('filterInfo must be an object'), + body('filterInfo.pairId') + .optional() + .isInt({ min: 0 }) + .withMessage('filterInfo.pairId must be a non-negative integer'), + body('filterInfo.type') + .optional() + .isIn(Object.values(CancelAllBodyOrderType)) + .withMessage(`Invalid filterInfo.type value`), + body('filterInfo.date').optional().isObject().withMessage('filterInfo.date must be an object'), + body('filterInfo.date.from') + .if(body('filterInfo.date').isObject()) + .isInt({ min: 0 }) + .withMessage( + 'filterInfo.date.from must be a non-negative integer representing a UNIX timestamp in milliseconds', + ), + body('filterInfo.date.to') + .if(body('filterInfo.date').isObject()) + .isInt({ min: 0 }) + .withMessage( + 'filterInfo.date.to must be a non-negative integer representing a UNIX timestamp in milliseconds', + ), +]; + +export default CancelAllBody; diff --git a/src/interfaces/bodies/orders/CreateOrderBody.ts b/src/interfaces/bodies/orders/CreateOrderBody.ts index 877596e..3edc652 100644 --- a/src/interfaces/bodies/orders/CreateOrderBody.ts +++ b/src/interfaces/bodies/orders/CreateOrderBody.ts @@ -1,10 +1,24 @@ -import OfferType from '../../common/OfferType'; -import Side from '../../common/Side'; +import { body } from 'express-validator'; +import { NON_NEGATIVE_REAL_NUMBER_REGEX } from 'shared/constants'; import UserData from '../../common/UserData'; -interface OrderData { - type: OfferType; - side: Side; +export enum CreateOrderType { + // eslint-disable-next-line no-unused-vars + BUY = 'buy', + // eslint-disable-next-line no-unused-vars + SELL = 'sell', +} + +export enum CreateOrderSide { + // eslint-disable-next-line no-unused-vars + LIMIT = 'limit', + // eslint-disable-next-line no-unused-vars + MARKET = 'market', +} + +interface CreateOrderData { + type: CreateOrderType; + side: CreateOrderSide; price: string; amount: string; pairId: string; @@ -12,7 +26,26 @@ interface OrderData { interface CreateOrderBody { userData: UserData; - orderData: OrderData; + orderData: CreateOrderData; } +export const createOrderValidator = [ + body('orderData').isObject().withMessage('orderData must be an object'), + body('orderData.type') + .isIn(Object.values(CreateOrderType)) + .withMessage(`Invalid orderData.type value`), + body('orderData.side') + .isIn(Object.values(CreateOrderSide)) + .withMessage(`Invalid orderData.side value`), + body('orderData.price') + .isString() + .matches(NON_NEGATIVE_REAL_NUMBER_REGEX) + .withMessage('orderData.price must be a positive decimal string'), + body('orderData.amount') + .isString() + .matches(NON_NEGATIVE_REAL_NUMBER_REGEX) + .withMessage('orderData.amount must be a positive decimal string'), + body('orderData.pairId').isString().withMessage('orderData.pairId must be a string'), +]; + export default CreateOrderBody; diff --git a/src/interfaces/bodies/orders/GetUserOrdersAllPairsBody.ts b/src/interfaces/bodies/orders/GetUserOrdersAllPairsBody.ts new file mode 100644 index 0000000..06b6de2 --- /dev/null +++ b/src/interfaces/bodies/orders/GetUserOrdersAllPairsBody.ts @@ -0,0 +1,9 @@ +import UserData from '@/interfaces/common/UserData'; + +interface GetUserOrdersAllPairsBody { + userData: UserData; +} + +export const getUserOrdersAllPairsValidator = []; + +export default GetUserOrdersAllPairsBody; diff --git a/src/interfaces/bodies/orders/GetUserOrdersBody.ts b/src/interfaces/bodies/orders/GetUserOrdersBody.ts index 93e5015..800ec9a 100644 --- a/src/interfaces/bodies/orders/GetUserOrdersBody.ts +++ b/src/interfaces/bodies/orders/GetUserOrdersBody.ts @@ -1,7 +1,68 @@ -import UserData from '../../common/UserData'; +import UserData from '@/interfaces/common/UserData'; +import { body } from 'express-validator'; + +export enum GetUserOrdersBodyStatus { + // eslint-disable-next-line no-unused-vars + ACTIVE = 'active', + // eslint-disable-next-line no-unused-vars + FINISHED = 'finished', +} + +export enum GetUserOrdersBodyType { + // eslint-disable-next-line no-unused-vars + BUY = 'buy', + // eslint-disable-next-line no-unused-vars + SELL = 'sell', +} interface GetUserOrdersBody { userData: UserData; + + limit: number; + offset: number; + filterInfo: { + pairId?: number; + status?: GetUserOrdersBodyStatus; + type?: GetUserOrdersBodyType; + date?: { + // UNIX timestamps in milliseconds + from: number; + to: number; + }; + }; } +export const getUserOrdersValidator = [ + body('limit') + .isInt({ min: 1, max: 1000 }) + .withMessage('limit must be a positive integer within certain range'), + body('offset').isInt({ min: 0 }).withMessage('offset must be a non-negative integer'), + body('filterInfo').isObject().withMessage('filterInfo must be an object'), + body('filterInfo.pairId') + .optional() + .isInt({ min: 0 }) + .withMessage('filterInfo.pairId must be a non-negative integer'), + body('filterInfo.status') + .optional() + .isIn(Object.values(GetUserOrdersBodyStatus)) + .withMessage(`Invalid filterInfo.status value`), + body('filterInfo.type') + .optional() + .isIn(Object.values(GetUserOrdersBodyType)) + .withMessage(`Invalid filterInfo.type value`), + body('filterInfo.date').optional().isObject().withMessage('filterInfo.date must be an object'), + body('filterInfo.date.from') + .if(body('filterInfo.date').isObject()) + .isInt({ min: 0 }) + .withMessage( + 'filterInfo.date.from must be a non-negative integer representing a UNIX timestamp in milliseconds', + ), + body('filterInfo.date.to') + .if(body('filterInfo.date').isObject()) + .isInt({ min: 0 }) + .withMessage( + 'filterInfo.date.to must be a non-negative integer representing a UNIX timestamp in milliseconds', + ), +]; + export default GetUserOrdersBody; diff --git a/src/interfaces/database/modifiedRequests.ts b/src/interfaces/database/modifiedRequests.ts index 9a0f026..3e60529 100644 --- a/src/interfaces/database/modifiedRequests.ts +++ b/src/interfaces/database/modifiedRequests.ts @@ -32,3 +32,7 @@ export interface PairWithCurrencies extends Pair { export interface OrderWithPairAndCurrencies extends Order { pair: PairWithCurrencies; } + +export interface PairWithIdAndCurrencies extends PairWithCurrencies { + id: number; +} diff --git a/src/interfaces/responses/auth/RequestAuthRes.ts b/src/interfaces/responses/auth/RequestAuthRes.ts new file mode 100644 index 0000000..a200f5f --- /dev/null +++ b/src/interfaces/responses/auth/RequestAuthRes.ts @@ -0,0 +1,7 @@ +interface RequestAuthRes { + success: true; + // Auth message + data: string; +} + +export default RequestAuthRes; diff --git a/src/interfaces/responses/dex/GetAssetsPriceRatesRes.ts b/src/interfaces/responses/dex/GetAssetsPriceRatesRes.ts new file mode 100644 index 0000000..ed9cb16 --- /dev/null +++ b/src/interfaces/responses/dex/GetAssetsPriceRatesRes.ts @@ -0,0 +1,24 @@ +export type GetAssetsPriceRatesResPriceRate = { + asset_id: string; + rate: number | null; + day_change: number | null; + day_volume: number | null; + day_high: number | null; + day_low: number | null; +}; + +export type GetAssetsPriceRatesSuccessRes = { + success: true; + priceRates: GetAssetsPriceRatesResPriceRate[]; +}; + +export enum GetAssetsPriceRatesErrorCode {} + +export type GetAssetsPriceRatesErrorRes = { + success: false; + data: GetAssetsPriceRatesErrorCode; +}; + +type GetAssetsPriceRatesRes = GetAssetsPriceRatesSuccessRes | GetAssetsPriceRatesErrorRes; + +export default GetAssetsPriceRatesRes; diff --git a/src/interfaces/responses/orders/CancelAllRes.ts b/src/interfaces/responses/orders/CancelAllRes.ts new file mode 100644 index 0000000..1fe1096 --- /dev/null +++ b/src/interfaces/responses/orders/CancelAllRes.ts @@ -0,0 +1,17 @@ +export type CancelAllSuccessRes = { + success: true; +}; + +export enum CancelAllErrorCode { + // eslint-disable-next-line no-unused-vars + UNHANDLED_ERROR = 'Unhandled error', +} + +export type CancelAllErrorRes = { + success: false; + data: CancelAllErrorCode; +}; + +type CancelAllRes = CancelAllSuccessRes | CancelAllErrorRes; + +export default CancelAllRes; diff --git a/src/interfaces/responses/orders/CreateOrderRes.ts b/src/interfaces/responses/orders/CreateOrderRes.ts new file mode 100644 index 0000000..34f1796 --- /dev/null +++ b/src/interfaces/responses/orders/CreateOrderRes.ts @@ -0,0 +1,36 @@ +export type CreateOrderSuccessRes = { + success: true; + data: { + hasNotification: boolean; + id: number; + type: string; + timestamp: number; + side: string; + price: string; + amount: string; + total: string; + pair_id: number; + user_id: number; + status: string; + left: string; + immediateMatch?: true; + }; +}; + +export enum CreateOrderErrorCode { + // eslint-disable-next-line no-unused-vars + INVALID_ORDER_DATA = 'Invalid order data', + // eslint-disable-next-line no-unused-vars + SAME_ORDER = 'Same order', + // eslint-disable-next-line no-unused-vars + UNHANDLED_ERROR = 'Unhandled error', +} + +export type CreateOrderErrorRes = { + success: false; + data: CreateOrderErrorCode; +}; + +type CreateOrderRes = CreateOrderSuccessRes | CreateOrderErrorRes; + +export default CreateOrderRes; diff --git a/src/interfaces/responses/orders/GetUserOrdersAllPairsRes.ts b/src/interfaces/responses/orders/GetUserOrdersAllPairsRes.ts new file mode 100644 index 0000000..0523c71 --- /dev/null +++ b/src/interfaces/responses/orders/GetUserOrdersAllPairsRes.ts @@ -0,0 +1,30 @@ +export type GetUserOrdersAllPairsResPair = { + id: number; + firstCurrency: { + id: number; + ticker: string; + }; + secondCurrency: { + id: number; + ticker: string; + }; +}; + +export type GetUserOrdersAllPairsSuccessRes = { + success: true; + data: GetUserOrdersAllPairsResPair[]; +}; + +export enum GetUserOrdersAllPairsErrorCode { + // eslint-disable-next-line no-unused-vars + UNHANDLED_ERROR = 'Unhandled error', +} + +export type GetUserOrdersAllPairsErrorRes = { + success: false; + data: GetUserOrdersAllPairsErrorCode; +}; + +type GetUserOrdersAllPairsRes = GetUserOrdersAllPairsSuccessRes | GetUserOrdersAllPairsErrorRes; + +export default GetUserOrdersAllPairsRes; diff --git a/src/interfaces/responses/orders/GetUserOrdersRes.ts b/src/interfaces/responses/orders/GetUserOrdersRes.ts new file mode 100644 index 0000000..960513f --- /dev/null +++ b/src/interfaces/responses/orders/GetUserOrdersRes.ts @@ -0,0 +1,74 @@ +export type GetUserOrdersResCurrency = { + id: number; + name: string; + code: string; + type: string; + asset_id: string; + auto_parsed: boolean; + asset_info?: { + asset_id: string; + logo: string; + price_url: string; + ticker: string; + full_name: string; + total_max_supply: string; + current_supply: string; + decimal_point: number; + meta_info: string; + }; + whitelisted: boolean; +}; + +export type GetUserOrdersResOrderData = { + id: number; + type: string; + timestamp: number; + side: string; + price: string; + amount: string; + total: string; + pair_id: number; + user_id: number; + status: string; + left: string; + hasNotification: boolean; + + pair: { + id: number; + first_currency_id: number; + second_currency_id: number; + rate?: number; + coefficient?: number; + high?: number; + low?: number; + volume: number; + featured: boolean; + + first_currency: GetUserOrdersResCurrency; + second_currency: GetUserOrdersResCurrency; + }; + + first_currency: GetUserOrdersResCurrency; + second_currency: GetUserOrdersResCurrency; + isInstant: boolean; +}; + +export type GetUserOrdersSuccessRes = { + success: true; + totalItemsCount: number; + data: GetUserOrdersResOrderData[]; +}; + +export enum GetUserOrdersErrorCode { + // eslint-disable-next-line no-unused-vars + UNHANDLED_ERROR = 'Unhandled error', +} + +export type GetUserOrdersErrorRes = { + success: false; + data: GetUserOrdersErrorCode; +}; + +type GetUserOrdersRes = GetUserOrdersSuccessRes | GetUserOrdersErrorRes; + +export default GetUserOrdersRes; diff --git a/src/middleware/middleware.ts b/src/middleware/middleware.ts index 8e50f66..11f2432 100644 --- a/src/middleware/middleware.ts +++ b/src/middleware/middleware.ts @@ -1,4 +1,5 @@ import { NextFunction, Request, Response } from 'express'; +import { ValidationChain, validationResult } from 'express-validator'; import { rateLimit } from 'express-rate-limit'; import jwt from 'jsonwebtoken'; import User from '@/schemes/User'; @@ -49,6 +50,49 @@ class Middleware { defaultRateLimit = async (req: Request, res: Response, next: NextFunction) => defaultRateLimitMiddleware(req, res, next); + expressValidator(validators: ValidationChain[]) { + return [ + ...validators, + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + + if (!errors.isEmpty()) { + res.status(500).send({ + success: false, + data: 'Internal error', + }); + return; + } + + next(); + }, + ]; + } + + expressJSONErrorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => { + const isExpressJSONError = + err instanceof SyntaxError && 'status' in err && err.status === 400 && 'body' in err; + + if (isExpressJSONError) { + res.status(500).send({ + success: false, + data: 'Internal error', + }); + } else { + next(); + } + }; + + globalErrorHandler = (err: Error, req: Request, res: Response, _next: NextFunction) => { + console.error('Global error handler:'); + console.error(err); + res.status(500).send({ + success: false, + data: 'Internal error', + }); + }; + + resultGlobalErrorHandler = [this.expressJSONErrorHandler, this.globalErrorHandler]; } const middleware = new Middleware(); diff --git a/src/models/AuthMessages.ts b/src/models/AuthMessages.ts new file mode 100644 index 0000000..0cbee63 --- /dev/null +++ b/src/models/AuthMessages.ts @@ -0,0 +1,89 @@ +import { Op, Transaction } from 'sequelize'; + +import AuthMessage from '@/schemes/AuthMessage'; + +class AuthMessagesModel { + create = async ( + { + address, + alias, + message, + expiresAt, + }: { + address: string; + alias: string; + message: string; + expiresAt: Date; + }, + { transaction }: { transaction?: Transaction } = {}, + ): Promise => { + const authMessage = await AuthMessage.create( + { + address, + alias, + message, + expires_at: expiresAt, + }, + { transaction }, + ); + + return authMessage; + }; + + findOne = async ({ + address, + alias, + message, + }: { + address: string; + alias: string; + message: string; + }): Promise => + AuthMessage.findOne({ + where: { + address, + alias, + message, + }, + }); + + deleteOne = async ( + { + address, + alias, + message, + }: { + address: string; + alias: string; + message: string; + }, + { transaction }: { transaction?: Transaction } = {}, + ): Promise => { + await AuthMessage.destroy({ + where: { + address, + alias, + message, + }, + transaction, + }); + }; + + deleteAllExpired = async ( + { now }: { now: Date }, + { transaction }: { transaction?: Transaction } = {}, + ): Promise => { + await AuthMessage.destroy({ + where: { + expires_at: { + [Op.lt]: now, + }, + }, + transaction, + }); + }; +} + +const authMessagesModel = new AuthMessagesModel(); + +export default authMessagesModel; diff --git a/src/models/Dex.ts b/src/models/Dex.ts index fde4f98..740c79e 100644 --- a/src/models/Dex.ts +++ b/src/models/Dex.ts @@ -38,8 +38,58 @@ class DexModel { } private getPairsSearchCondition(searchText: string, whitelistedOnly: boolean) { + const tickerRegexp = /^[A-Za-z0-9]{1,14}$/; + const fullNameRegexp = /^[A-Za-z0-9.,:!?\-() ]*$/; + + const firstFullNameExpr = Sequelize.literal(`"first_currency"."asset_info"->>'full_name'`); + const secondFullNameExpr = Sequelize.literal( + `"second_currency"."asset_info"->>'full_name'`, + ); + const searchCondition: WhereOptions = { [Op.and]: [ + Sequelize.where(Sequelize.col('first_currency.name'), { + [Op.regexp]: tickerRegexp.source, + }), + { + [Op.or]: [ + Sequelize.where(Sequelize.col('first_currency.asset_info'), { + [Op.is]: null, + }), + Sequelize.where(firstFullNameExpr, { [Op.is]: null }), + { + [Op.and]: [ + Sequelize.where(firstFullNameExpr, { + [Op.regexp]: fullNameRegexp.source, + }), + Sequelize.where(Sequelize.fn('char_length', firstFullNameExpr), { + [Op.lte]: 400, + }), + ], + }, + ], + }, + Sequelize.where(Sequelize.col('second_currency.name'), { + [Op.regexp]: tickerRegexp.source, + }), + { + [Op.or]: [ + Sequelize.where(Sequelize.col('second_currency.asset_info'), { + [Op.is]: null, + }), + Sequelize.where(secondFullNameExpr, { [Op.is]: null }), + { + [Op.and]: [ + Sequelize.where(secondFullNameExpr, { + [Op.regexp]: fullNameRegexp.source, + }), + Sequelize.where(Sequelize.fn('char_length', secondFullNameExpr), { + [Op.lte]: 400, + }), + ], + }, + ], + }, { [Op.or]: [ Sequelize.where( @@ -70,7 +120,20 @@ class DexModel { ], }; - return searchCondition; + const includeCondition = [ + { + model: Currency, + as: 'first_currency', + attributes: ['asset_id', 'code', 'id', 'name', 'type', 'whitelisted'], + }, + { + model: Currency, + as: 'second_currency', + attributes: ['asset_id', 'code', 'id', 'name', 'type', 'whitelisted'], + }, + ]; + + return { searchCondition, includeCondition }; } async getPairRow(id: number) { @@ -88,7 +151,10 @@ class DexModel { sortOption: PairSortOption, ) { try { - const searchCondition = this.getPairsSearchCondition(searchText, whitelistedOnly); + const { searchCondition, includeCondition } = this.getPairsSearchCondition( + searchText, + whitelistedOnly, + ); const volumeSortDirection = sortOption === PairSortOption.VOLUME_LOW_TO_HIGH ? 'ASC' : 'DESC'; @@ -105,18 +171,7 @@ class DexModel { 'volume', 'featured', ], - include: [ - { - model: Currency, - as: 'first_currency', - attributes: ['asset_id', 'code', 'id', 'name', 'type', 'whitelisted'], - }, - { - model: Currency, - as: 'second_currency', - attributes: ['asset_id', 'code', 'id', 'name', 'type', 'whitelisted'], - }, - ], + include: includeCondition, where: searchCondition, order: [ ['volume', volumeSortDirection], @@ -152,21 +207,13 @@ class DexModel { async getPairsPagesAmount(searchText: string, whitelistedOnly: boolean) { try { - const searchCondition = this.getPairsSearchCondition(searchText, whitelistedOnly); + const { searchCondition, includeCondition } = this.getPairsSearchCondition( + searchText, + whitelistedOnly, + ); const count = await Pair.count({ - include: [ - { - model: Currency, - as: 'first_currency', - attributes: ['asset_id', 'code', 'id', 'name', 'type'], - }, - { - model: Currency, - as: 'second_currency', - attributes: ['asset_id', 'code', 'id', 'name', 'type'], - }, - ], + include: includeCondition, where: searchCondition, }); diff --git a/src/models/Orders.ts b/src/models/Orders.ts index 3f58bc3..2840c49 100644 --- a/src/models/Orders.ts +++ b/src/models/Orders.ts @@ -1,13 +1,12 @@ -import { Op } from 'sequelize'; +import { Op, Transaction as SequelizeTransaction, WhereOptions } from 'sequelize'; import Decimal from 'decimal.js'; import TransactionWithOrders from '@/interfaces/common/Transaction.js'; import Currency from '@/schemes/Currency.js'; import { - OrderWithAllTransactions, - OrderWithPair, OrderWithPairAndCurrencies, + PairWithCurrencies, + PairWithIdAndCurrencies, } from '@/interfaces/database/modifiedRequests.js'; -import configModel from './Config.js'; import dexModel from './Dex.js'; import userModel from './User.js'; import exchangeModel from './ExchangeTransactions.js'; @@ -22,10 +21,9 @@ import io from '../server.js'; import ApplyTip from '../interfaces/responses/orders/ApplyTip.js'; import CreateOrderBody from '../interfaces/bodies/orders/CreateOrderBody.js'; import GetUserOrdersPageBody from '../interfaces/bodies/orders/GetUserOrdersPageBody.js'; -import GetUserOrdersBody from '../interfaces/bodies/orders/GetUserOrdersBody.js'; import CancelOrderBody from '../interfaces/bodies/orders/CancelOrderBody.js'; import ApplyOrderBody from '../interfaces/bodies/orders/ApplyOrderBody.js'; -import Order from '../schemes/Order'; +import Order, { OrderStatus, OrderType } from '../schemes/Order'; import User from '../schemes/User'; import Transaction from '../schemes/Transaction'; import Pair from '../schemes/Pair'; @@ -68,7 +66,30 @@ class OrdersModel { return matchedOrders; } - async createOrder(body: CreateOrderBody) { + async createOrder(body: CreateOrderBody): Promise< + | { + success: false; + data: string; + } + | { + success: true; + data: { + id: number; + type: string; + timestamp: number; + side: string; + price: string; + amount: string; + total: string; + pairId: number; + userId: number; + status: string; + left: string; + hasNotification: boolean; + immediateMatch?: true; + }; + } + > { try { const { orderData } = body; const { userData } = body; @@ -173,13 +194,41 @@ class OrdersModel { return { success: true, data: { - ...newOrder.toJSON(), + id: newOrder.id, + type: newOrder.type, + timestamp: newOrder.timestamp, + side: newOrder.side, + price: newOrder.price, + amount: newOrder.amount, + total: newOrder.total, + pairId: newOrder.pair_id, + userId: newOrder.user_id, + status: newOrder.status, + left: newOrder.left, + hasNotification: newOrder.hasNotification, + immediateMatch: true, }, }; } - return { success: true, data: newOrder.toJSON() }; + return { + success: true, + data: { + id: newOrder.id, + type: newOrder.type, + timestamp: newOrder.timestamp, + side: newOrder.side, + price: newOrder.price, + amount: newOrder.amount, + total: newOrder.total, + pairId: newOrder.pair_id, + userId: newOrder.user_id, + status: newOrder.status, + left: newOrder.left, + hasNotification: newOrder.hasNotification, + }, + }; } catch (err) { console.log(err); return { success: false, data: 'Internal error' }; @@ -336,17 +385,85 @@ class OrdersModel { } } - async getUserOrders(body: GetUserOrdersBody) { + async getUserOrders({ + address, + offset, + limit, + filterInfo: { pairId, status, type, date }, + }: { + address: string; + offset: number; + limit: number; + filterInfo: { + pairId?: number; + status?: 'active' | 'finished'; + type?: 'buy' | 'sell'; + date?: { + from: number; + to: number; + }; + }; + }): Promise< + | { + success: false; + data: 'Internal error'; + } + | { + success: true; + totalItemsCount: number; + data: { + id: number; + type: string; + timestamp: number; + side: string; + price: string; + amount: string; + total: string; + pair_id: number; + user_id: number; + status: string; + left: string; + hasNotification: boolean; + + pair: PairWithCurrencies; + + first_currency: Currency; + second_currency: Currency; + isInstant: boolean; + }[]; + } + > { try { - const userRow = await userModel.getUserRow(body.userData.address); + const userRow = await userModel.getUserRow(address); if (!userRow) throw new Error('Invalid address from token.'); - const orders = (await Order.findAll({ - where: { - user_id: userRow.id, - }, + const ordersSelectWhereClause: WhereOptions = { + user_id: userRow.id, + ...(pairId !== undefined ? { pair_id: pairId } : {}), + ...(status !== undefined + ? { + status: + status === 'finished' ? OrderStatus.FINISHED : OrderStatus.ACTIVE, + } + : {}), + ...(type !== undefined + ? { type: type === 'buy' ? OrderType.BUY : OrderType.SELL } + : {}), + ...(date !== undefined + ? { timestamp: { [Op.between]: [date.from, date.to] } } + : {}), + }; + + const totalItemsCount = await Order.count({ + where: ordersSelectWhereClause, + }); + + const ordersRows = (await Order.findAll({ + where: ordersSelectWhereClause, order: [['timestamp', 'DESC']], + limit, + offset, include: [ { model: Pair, @@ -356,14 +473,32 @@ class OrdersModel { ], })) as OrderWithPairAndCurrencies[]; - const result = orders.map((e) => ({ - ...e.toJSON(), + const result = ordersRows.map((e) => ({ + id: e.id, + type: e.type, + timestamp: e.timestamp, + side: e.side, + price: e.price, + amount: e.amount, + total: e.total, + pair_id: e.pair_id, + user_id: e.user_id, + status: e.status, + left: e.left, + hasNotification: e.hasNotification, + + pair: e.pair, + first_currency: e.pair.first_currency, second_currency: e.pair.second_currency, isInstant: dexModel.isBotActive(e.id), })); - return { success: true, data: result }; + return { + success: true, + totalItemsCount, + data: result, + }; } catch (err) { console.log(err); return { success: false, data: 'Internal error' }; @@ -677,6 +812,184 @@ class OrdersModel { return { success: false, data: 'Internal error' }; } } + + static GET_USER_ORDERS_ALL_PAIRS_USER_NOT_FOUND = 'No user found'; + getUserOrdersAllPairs = async ( + address: string, + ): Promise<{ + success: true; + data: { + id: number; + firstCurrency: { + id: number; + ticker: string; + }; + secondCurrency: { + id: number; + ticker: string; + }; + }[]; + }> => { + const userRow = await userModel.getUserRow(address); + + if (!userRow) { + throw new Error(OrdersModel.GET_USER_ORDERS_ALL_PAIRS_USER_NOT_FOUND); + } + + // Select distinct pair IDs for the user's orders, then fetch pairs + const distinctPairIdRows = (await Order.findAll({ + attributes: [[sequelize.fn('DISTINCT', sequelize.col('pair_id')), 'pair_id']], + where: { user_id: userRow.id }, + raw: true, + })) as { pair_id: number }[]; + + const pairIds = distinctPairIdRows.map((row) => row.pair_id); + + const pairsSelection = (await Pair.findAll({ + where: { id: pairIds }, + include: [ + { model: Currency, as: 'first_currency' }, + { model: Currency, as: 'second_currency' }, + ], + attributes: ['id'], + })) as PairWithIdAndCurrencies[]; + + const pairs = pairsSelection.map((e) => { + const firstCurrencyTicker = e.first_currency.name; + const secondCurrencyTicker = e.second_currency.name; + + return { + id: e.id, + firstCurrency: { + id: e.first_currency.id, + ticker: firstCurrencyTicker, + }, + secondCurrency: { + id: e.second_currency.id, + ticker: secondCurrencyTicker, + }, + }; + }); + + return { + success: true, + data: pairs, + }; + }; + + static CANCEL_ALL_USER_NOT_FOUND = 'No user found'; + cancelAll = async ( + { + address, + filterInfo: { pairId, type, date }, + }: { + address: string; + filterInfo: { + pairId?: number; + type?: 'buy' | 'sell'; + date?: { + from: number; + to: number; + }; + }; + }, + { transaction }: { transaction: SequelizeTransaction }, + ): Promise<{ + success: true; + }> => { + const userRow = await userModel.getUserRow(address); + + if (!userRow) { + throw new Error(OrdersModel.CANCEL_ALL_USER_NOT_FOUND); + } + + const ordersToCancelWhereClause: WhereOptions = { + user_id: userRow.id, + status: { + [Op.ne]: OrderStatus.FINISHED, + }, + ...(pairId !== undefined ? { pair_id: pairId } : {}), + ...(type !== undefined + ? { type: type === 'buy' ? OrderType.BUY : OrderType.SELL } + : {}), + ...(date !== undefined ? { timestamp: { [Op.between]: [date.from, date.to] } } : {}), + }; + + const ORDERS_PER_CANCEL_CHUNK = 10_000; + let lastOrderTimestamp: number | undefined; + let finished = false; + + while (!finished) { + const orderRows = await Order.findAll({ + where: { + ...ordersToCancelWhereClause, + ...(lastOrderTimestamp !== undefined + ? { timestamp: { [Op.gt]: lastOrderTimestamp } } + : {}), + }, + order: [['timestamp', 'ASC']], + limit: ORDERS_PER_CANCEL_CHUNK, + }); + + const lastOrderRow = orderRows.at(-1); + + // if there are no more orders to cancel, finish the process + if (!lastOrderRow) { + finished = true; + } + + for (const orderRow of orderRows) { + await this.cancelOrderNotifications(orderRow, userRow); + + const eps = new Decimal(1e-8); + const leftDecimal = new Decimal(orderRow.left); + const amountDecimal = new Decimal(orderRow.amount); + + // if order was partially filled + if (leftDecimal.minus(amountDecimal).abs().greaterThan(eps)) { + const connectedTransactions = await Transaction.findAll({ + where: { + [Op.or]: [ + { buy_order_id: orderRow.id }, + { sell_order_id: orderRow.id }, + ], + status: 'pending', + }, + }); + + for (const tx of connectedTransactions) { + await exchangeModel.returnTransactionAmount(tx.id, transaction); + } + + await Order.update( + { status: OrderStatus.FINISHED }, + { + where: { id: orderRow.id, user_id: userRow.id }, + transaction, + }, + ); + } else { + await Order.destroy({ + where: { + id: orderRow.id, + user_id: userRow.id, + }, + transaction, + }); + } + + transaction.afterCommit(() => { + sendDeleteOrderMessage(io, orderRow.pair_id.toString(), orderRow.id.toString()); + }); + } + + if (lastOrderRow) { + lastOrderTimestamp = lastOrderRow.timestamp; + } + } + + return { success: true }; + }; } const ordersModel = new OrdersModel(); diff --git a/src/models/User.ts b/src/models/User.ts index 999ab65..83135fc 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -1,4 +1,4 @@ -import { Op } from 'sequelize'; +import { Op, Transaction } from 'sequelize'; import Offer from '../schemes/Offer'; import GetUserBody from '../interfaces/bodies/user/GetUserBody'; import SetFavouriteCurrsBody from '../interfaces/bodies/user/SetFavouriteCurrsBody'; @@ -15,7 +15,7 @@ class UserModel { return selected; } - async add(userData: UserData) { + async add(userData: UserData, { transaction }: { transaction?: Transaction } = {}) { try { const userRow = await this.getUserRow(userData.address); if (userRow) return true; @@ -27,12 +27,15 @@ class UserModel { if (oldAddressOfCurrentAlias) { await User.update( { address: userData.address }, - { where: { alias: userData.alias } }, + { where: { alias: userData.alias }, transaction }, ); return true; } - await User.create({ alias: userData.alias, address: userData.address }); + await User.create( + { alias: userData.alias, address: userData.address }, + { transaction }, + ); return true; } catch (err) { diff --git a/src/routes/auth.router.ts b/src/routes/auth.router.ts index 77dfc5b..fd7d8ca 100644 --- a/src/routes/auth.router.ts +++ b/src/routes/auth.router.ts @@ -1,8 +1,16 @@ import express from 'express'; + +import middleware from '@/middleware/middleware.js'; +import { requestAuthBodyValidator } from '@/interfaces/bodies/auth/RequestAuthBody.js'; import authController from '../controllers/auth.controller.js'; const authRouter = express.Router(); +authRouter.post( + '/auth/request-auth', + middleware.expressValidator(requestAuthBodyValidator), + authController.requestAuth.bind(authController), +); authRouter.post('/auth', authController.auth); export default authRouter; diff --git a/src/routes/dex.router.ts b/src/routes/dex.router.ts index ca0b6c7..7cba915 100644 --- a/src/routes/dex.router.ts +++ b/src/routes/dex.router.ts @@ -1,4 +1,5 @@ import express from 'express'; +import { getAssetsPriceRatesValidator } from '@/interfaces/bodies/dex/GetAssetsPriceRatesBody.js'; import dexController from '../controllers/dex.controller.js'; import middleware from '../middleware/middleware.js'; @@ -9,7 +10,11 @@ dexRouter.post('/dex/get-pairs-pages-amount', dexController.getPairsPagesAmount) dexRouter.post('/dex/get-pair', dexController.getPair); dexRouter.post('/dex/renew-bot', middleware.verifyToken, dexController.registerBot); dexRouter.post('/dex/volume-stats', dexController.volumeStats); -dexRouter.post('/dex/get-assets-price-rates', dexController.getAssetsPriceRates); +dexRouter.post( + '/dex/get-assets-price-rates', + middleware.expressValidator(getAssetsPriceRatesValidator), + dexController.getAssetsPriceRates, +); dexRouter.post('/dex/find-pair', dexController.findPairID); export default dexRouter; diff --git a/src/routes/orders.router.ts b/src/routes/orders.router.ts index 479aaca..5e36de9 100644 --- a/src/routes/orders.router.ts +++ b/src/routes/orders.router.ts @@ -1,4 +1,9 @@ import express from 'express'; + +import { createOrderValidator } from '@/interfaces/bodies/orders/CreateOrderBody.js'; +import { getUserOrdersValidator } from '@/interfaces/bodies/orders/GetUserOrdersBody.js'; +import { getUserOrdersAllPairsValidator } from '@/interfaces/bodies/orders/GetUserOrdersAllPairsBody.js'; +import { cancelAllValidator } from '@/interfaces/bodies/orders/CancelAllBody.js'; import middleware from '../middleware/middleware.js'; import ordersController from '../controllers/orders.controller.js'; @@ -11,19 +16,39 @@ ordersRouter.use( '/orders/get', '/orders/cancel', '/orders/apply-order', + '/orders/get-user-orders-pairs', + '/orders/cancel-all', ], middleware.verifyToken, ); -ordersRouter.post('/orders/create', ordersController.createOrder); +ordersRouter.post( + '/orders/create', + middleware.expressValidator(createOrderValidator), + ordersController.createOrder, +); ordersRouter.post('/orders/get-page', ordersController.getOrdersPage); ordersRouter.post('/orders/get-user-page', ordersController.getUserOrdersPage); -ordersRouter.post('/orders/get', ordersController.getUserOrders); +ordersRouter.patch( + '/orders/get', + middleware.expressValidator(getUserOrdersValidator), + ordersController.getUserOrders.bind(ordersController), +); ordersRouter.post('/orders/cancel', ordersController.cancelOrder); ordersRouter.post('/orders/get-candles', ordersController.getCandles); ordersRouter.post('/orders/get-chart-orders', ordersController.getChartOrders); ordersRouter.post('/orders/get-pair-stats', ordersController.getPairStats); ordersRouter.post('/orders/apply-order', ordersController.applyOrder); ordersRouter.post('/orders/get-trades', ordersController.getTrades); +ordersRouter.patch( + '/orders/get-user-orders-pairs', + middleware.expressValidator(getUserOrdersAllPairsValidator), + ordersController.getUserOrdersAllPairs.bind(ordersController), +); +ordersRouter.patch( + '/orders/cancel-all', + middleware.expressValidator(cancelAllValidator), + ordersController.cancelAll.bind(ordersController), +); export default ordersRouter; diff --git a/src/schemes/AuthMessage.ts b/src/schemes/AuthMessage.ts new file mode 100644 index 0000000..ef8ab38 --- /dev/null +++ b/src/schemes/AuthMessage.ts @@ -0,0 +1,45 @@ +import sequelize from '@/sequelize'; +import { DataTypes, Model } from 'sequelize'; + +class AuthMessage extends Model { + declare readonly id: number; + declare address: string; + declare alias: string; + declare message: string; + declare expires_at: Date; + + declare readonly createdAt: Date; + declare readonly updatedAt: Date; +} + +AuthMessage.init( + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + address: { + type: DataTypes.STRING, + allowNull: false, + }, + alias: { + type: DataTypes.STRING, + allowNull: false, + }, + message: { + type: DataTypes.STRING, + allowNull: false, + }, + expires_at: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { + sequelize, + modelName: 'AuthMessage', + }, +); + +export default AuthMessage; diff --git a/src/schemes/Order.ts b/src/schemes/Order.ts index 08b7b6e..00de457 100644 --- a/src/schemes/Order.ts +++ b/src/schemes/Order.ts @@ -1,6 +1,22 @@ import { Model, DataTypes } from 'sequelize'; import sequelize from '../sequelize'; +export enum OrderType { + // eslint-disable-next-line no-unused-vars + BUY = 'buy', + // eslint-disable-next-line no-unused-vars + SELL = 'sell', +} + +export enum OrderStatus { + // eslint-disable-next-line no-unused-vars + ACTIVE = 'active', + // eslint-disable-next-line no-unused-vars + ZERO = 'zero', + // eslint-disable-next-line no-unused-vars + FINISHED = 'finished', +} + class Order extends Model { declare readonly id: number; @@ -8,6 +24,7 @@ class Order extends Model { declare timestamp: number; + // Currently not used declare side: string; declare price: string; diff --git a/src/server.ts b/src/server.ts index ed5f774..2c5ba0e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ import express from 'express'; import http from 'http'; import { Server } from 'socket.io'; +import authMessagesCleanService from '@/workers/authMessagesCleanService'; import authRouter from './routes/auth.router'; import offersRouter from './routes/offers.router'; import userRouter from './routes/user.router'; @@ -73,6 +74,7 @@ process.on('unhandledRejection', (reason, promise) => { assetsUpdateChecker.run(); ordersModerationService.run(); + authMessagesCleanService.run(); exchangeModel.runPairStatsDaemon(); statsModel.init(); @@ -101,6 +103,8 @@ process.on('unhandledRejection', (reason, promise) => { res.send({ success: true, userData: req.body.userData }), ); + app.use(middleware.resultGlobalErrorHandler); + server.listen(PORT, () => console.log(`Server is running on port ${PORT}`)); })(); diff --git a/src/workers/authMessagesCleanService.ts b/src/workers/authMessagesCleanService.ts new file mode 100644 index 0000000..aa26c9a --- /dev/null +++ b/src/workers/authMessagesCleanService.ts @@ -0,0 +1,41 @@ +import authMessagesModel from '@/models/AuthMessages'; + +const CLEAN_INTERVAL = 60 * 60 * 1000; // 1 hour + +class AuthMessagesCleanService { + run = async () => { + console.log( + `Auth messages clean service is running. Cleaning interval: ${CLEAN_INTERVAL / 1000} sec.`, + ); + + async function clean() { + console.log(`[${new Date()}] Cleaning auth messages...`); + + await authMessagesModel.deleteAllExpired({ now: new Date() }); + + console.log(`[${new Date()}] Auth messages cleaned.`); + } + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + await clean(); + } catch (error) { + console.log( + `[${new Date()}] Error while cleaning auth messages. Continuing on next iteration. Error:`, + ); + console.error(error); + } + + console.log( + `[${new Date()}] Auth messages cleaned. Next cleaning in ${CLEAN_INTERVAL / 1000} sec.`, + ); + + await new Promise((resolve) => setTimeout(resolve, CLEAN_INTERVAL)); + } + }; +} + +const authMessagesCleanService = new AuthMessagesCleanService(); + +export default authMessagesCleanService;