From fe8ba5e8a91f5ac4fe49a9fd47100cfad5c6a494 Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Wed, 11 Feb 2026 16:08:26 +0300 Subject: [PATCH 01/18] add: add desired global setup for basic api security and reliability --- package-lock.json | 14 ++++++++++++ package.json | 1 + src/middleware/middleware.ts | 44 ++++++++++++++++++++++++++++++++++++ src/server.ts | 2 ++ 4 files changed, 61 insertions(+) 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/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/server.ts b/src/server.ts index ed5f774..4c20936 100644 --- a/src/server.ts +++ b/src/server.ts @@ -101,6 +101,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}`)); })(); From abdb4a6b4bc79f14efb53b0bee4c64ff81553d0d Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Wed, 11 Feb 2026 15:51:16 +0300 Subject: [PATCH 02/18] update: extend response API for get-assets-price-rates endpoint --- src/controllers/dex.controller.ts | 85 ++++++++----------- .../bodies/dex/GetAssetsPriceRatesBody.ts | 14 +++ .../responses/dex/GetAssetsPriceRatesRes.ts | 24 ++++++ src/routes/dex.router.ts | 7 +- 4 files changed, 79 insertions(+), 51 deletions(-) create mode 100644 src/interfaces/bodies/dex/GetAssetsPriceRatesBody.ts create mode 100644 src/interfaces/responses/dex/GetAssetsPriceRatesRes.ts 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/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/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/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; From 0f3aa4a73f6d207d0ebb8a2e9e43851702f05cfc Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Fri, 13 Feb 2026 19:53:32 +0300 Subject: [PATCH 03/18] update: refactor create-order endpoint --- shared/constants.ts | 1 + src/controllers/orders.controller.ts | 113 +++++++++++------- .../bodies/orders/CreateOrderBody.ts | 45 ++++++- .../responses/orders/CreateOrderRes.ts | 36 ++++++ src/models/Orders.ts | 57 ++++++++- src/routes/orders.router.ts | 7 +- 6 files changed, 207 insertions(+), 52 deletions(-) create mode 100644 shared/constants.ts create mode 100644 src/interfaces/responses/orders/CreateOrderRes.ts diff --git a/shared/constants.ts b/shared/constants.ts new file mode 100644 index 0000000..204f551 --- /dev/null +++ b/shared/constants.ts @@ -0,0 +1 @@ +export const NON_NEGATIVE_REAL_NUMBER_REGEX = /^\d+(\.\d+)?$/; diff --git a/src/controllers/orders.controller.ts b/src/controllers/orders.controller.ts index 61bc154..e3d6643 100644 --- a/src/controllers/orders.controller.ts +++ b/src/controllers/orders.controller.ts @@ -1,5 +1,7 @@ import { Request, Response } from 'express'; import Decimal from 'decimal.js'; + +import CreateOrderRes, { CreateOrderErrorCode } from '@/interfaces/responses/orders/CreateOrderRes'; import candlesModel from '../models/Candles'; import ordersModel from '../models/Orders'; import CreateOrderBody from '../interfaces/bodies/orders/CreateOrderBody'; @@ -16,70 +18,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 }); } } 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/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/models/Orders.ts b/src/models/Orders.ts index 3f58bc3..89320e7 100644 --- a/src/models/Orders.ts +++ b/src/models/Orders.ts @@ -68,7 +68,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 +196,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' }; diff --git a/src/routes/orders.router.ts b/src/routes/orders.router.ts index 479aaca..712b106 100644 --- a/src/routes/orders.router.ts +++ b/src/routes/orders.router.ts @@ -1,4 +1,5 @@ import express from 'express'; +import { createOrderValidator } from '@/interfaces/bodies/orders/CreateOrderBody.js'; import middleware from '../middleware/middleware.js'; import ordersController from '../controllers/orders.controller.js'; @@ -15,7 +16,11 @@ ordersRouter.use( 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); From 2d2746acb93b50f238447c2ed17b24efc2555eb1 Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Sat, 14 Feb 2026 03:42:12 +0300 Subject: [PATCH 04/18] update: add get-user-orders pagination --- src/controllers/orders.controller.ts | 138 ++++++++++++++++-- .../bodies/orders/GetUserOrdersBody.ts | 58 +++++++- .../responses/orders/GetUserOrdersRes.ts | 73 +++++++++ src/models/Orders.ts | 88 ++++++++++- src/routes/orders.router.ts | 7 +- src/schemes/Order.ts | 17 +++ 6 files changed, 363 insertions(+), 18 deletions(-) create mode 100644 src/interfaces/responses/orders/GetUserOrdersRes.ts diff --git a/src/controllers/orders.controller.ts b/src/controllers/orders.controller.ts index e3d6643..813dad3 100644 --- a/src/controllers/orders.controller.ts +++ b/src/controllers/orders.controller.ts @@ -2,11 +2,19 @@ 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 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'; @@ -157,21 +165,131 @@ 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: { + 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 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, + 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, + }); } - } + }; async cancelOrder(req: Request, res: Response) { try { diff --git a/src/interfaces/bodies/orders/GetUserOrdersBody.ts b/src/interfaces/bodies/orders/GetUserOrdersBody.ts index 93e5015..d5d0c2a 100644 --- a/src/interfaces/bodies/orders/GetUserOrdersBody.ts +++ b/src/interfaces/bodies/orders/GetUserOrdersBody.ts @@ -1,7 +1,63 @@ -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: { + 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.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/responses/orders/GetUserOrdersRes.ts b/src/interfaces/responses/orders/GetUserOrdersRes.ts new file mode 100644 index 0000000..98a6828 --- /dev/null +++ b/src/interfaces/responses/orders/GetUserOrdersRes.ts @@ -0,0 +1,73 @@ +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; + 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/models/Orders.ts b/src/models/Orders.ts index 89320e7..11b9dc6 100644 --- a/src/models/Orders.ts +++ b/src/models/Orders.ts @@ -6,6 +6,7 @@ import { OrderWithAllTransactions, OrderWithPair, OrderWithPairAndCurrencies, + PairWithCurrencies, } from '@/interfaces/database/modifiedRequests.js'; import configModel from './Config.js'; import dexModel from './Dex.js'; @@ -25,7 +26,7 @@ import GetUserOrdersPageBody from '../interfaces/bodies/orders/GetUserOrdersPage 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'; @@ -387,17 +388,78 @@ class OrdersModel { } } - async getUserOrders(body: GetUserOrdersBody) { + async getUserOrders({ + address, + offset, + limit, + filterInfo: { status, type, date }, + }: { + address: string; + offset: number; + limit: number; + filterInfo: { + status?: 'active' | 'finished'; + type?: 'buy' | 'sell'; + date?: { + from: number; + to: number; + }; + }; + }): Promise< + | { + success: false; + data: 'Internal error'; + } + | { + success: true; + 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({ + const ordersRows = (await Order.findAll({ where: { user_id: userRow.id, + ...(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] } } + : {}), }, order: [['timestamp', 'DESC']], + limit, + offset, include: [ { model: Pair, @@ -407,8 +469,22 @@ 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), diff --git a/src/routes/orders.router.ts b/src/routes/orders.router.ts index 712b106..28fc64d 100644 --- a/src/routes/orders.router.ts +++ b/src/routes/orders.router.ts @@ -1,5 +1,6 @@ import express from 'express'; import { createOrderValidator } from '@/interfaces/bodies/orders/CreateOrderBody.js'; +import { getUserOrdersValidator } from '@/interfaces/bodies/orders/GetUserOrdersBody.js'; import middleware from '../middleware/middleware.js'; import ordersController from '../controllers/orders.controller.js'; @@ -23,7 +24,11 @@ ordersRouter.post( ); 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); 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; From 5aa1da59e1f44c1d40e9a04e56ecca4938d3be1c Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Mon, 16 Feb 2026 15:52:41 +0300 Subject: [PATCH 05/18] add: add get-user-orders-all-pairs endpoint --- src/controllers/orders.controller.ts | 41 ++++++++++++ .../orders/GetUserOrdersAllPairsBody.ts | 9 +++ src/interfaces/database/modifiedRequests.ts | 5 ++ .../orders/GetUserOrdersAllPairsRes.ts | 30 +++++++++ src/models/Orders.ts | 65 +++++++++++++++++-- src/routes/orders.router.ts | 7 ++ 6 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 src/interfaces/bodies/orders/GetUserOrdersAllPairsBody.ts create mode 100644 src/interfaces/responses/orders/GetUserOrdersAllPairsRes.ts diff --git a/src/controllers/orders.controller.ts b/src/controllers/orders.controller.ts index 813dad3..2914175 100644 --- a/src/controllers/orders.controller.ts +++ b/src/controllers/orders.controller.ts @@ -7,6 +7,11 @@ import GetUserOrdersRes, { GetUserOrdersResCurrency, GetUserOrdersResOrderData, } from '@/interfaces/responses/orders/GetUserOrdersRes'; +import GetUserOrdersAllPairsBody from '@/interfaces/bodies/orders/GetUserOrdersAllPairsBody'; +import GetUserOrdersAllPairsRes, { + GetUserOrdersAllPairsErrorCode, + GetUserOrdersAllPairsResPair, +} from '@/interfaces/responses/orders/GetUserOrdersAllPairsRes'; import candlesModel from '../models/Candles'; import ordersModel from '../models/Orders'; import CreateOrderBody from '../interfaces/bodies/orders/CreateOrderBody'; @@ -291,6 +296,42 @@ class OrdersController { } }; + 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 { if (!(req.body as CancelOrderBody).orderId) 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/database/modifiedRequests.ts b/src/interfaces/database/modifiedRequests.ts index 9a0f026..a55aa3d 100644 --- a/src/interfaces/database/modifiedRequests.ts +++ b/src/interfaces/database/modifiedRequests.ts @@ -32,3 +32,8 @@ export interface PairWithCurrencies extends Pair { export interface OrderWithPairAndCurrencies extends Order { pair: PairWithCurrencies; } + +export interface GroupByIdPair { + pair_id: number; + pair: PairWithCurrencies; +} diff --git a/src/interfaces/responses/orders/GetUserOrdersAllPairsRes.ts b/src/interfaces/responses/orders/GetUserOrdersAllPairsRes.ts new file mode 100644 index 0000000..8dd7210 --- /dev/null +++ b/src/interfaces/responses/orders/GetUserOrdersAllPairsRes.ts @@ -0,0 +1,30 @@ +export type GetUserOrdersAllPairsResPair = { + id: number; + firstCurrency: { + id: number; + ticker: string | null; + }; + secondCurrency: { + id: number; + ticker: string | null; + }; +}; + +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/models/Orders.ts b/src/models/Orders.ts index 11b9dc6..ff335c9 100644 --- a/src/models/Orders.ts +++ b/src/models/Orders.ts @@ -3,12 +3,10 @@ import Decimal from 'decimal.js'; import TransactionWithOrders from '@/interfaces/common/Transaction.js'; import Currency from '@/schemes/Currency.js'; import { - OrderWithAllTransactions, - OrderWithPair, + GroupByIdPair, OrderWithPairAndCurrencies, PairWithCurrencies, } 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'; @@ -23,7 +21,6 @@ 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, { OrderStatus, OrderType } from '../schemes/Order'; @@ -804,6 +801,66 @@ 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 | null; + }; + secondCurrency: { + id: number; + ticker: string | null; + }; + }[]; + }> => { + const userRow = await userModel.getUserRow(address); + + if (!userRow) { + throw new Error(OrdersModel.GET_USER_ORDERS_ALL_PAIRS_USER_NOT_FOUND); + } + + const pairsGroupedSelection = (await Order.findAll({ + where: { + user_id: userRow.id, + }, + group: 'pair_id', + include: [ + { + model: Pair, + as: 'pair', + include: ['first_currency', 'second_currency'], + }, + ], + })) as unknown as GroupByIdPair[]; + + const pairs = pairsGroupedSelection.map((e) => { + const firstCurrencyTicker = e.pair.first_currency.asset_info?.ticker; + const secondCurrencyTicker = e.pair.second_currency.asset_info?.ticker; + + return { + id: e.pair.id, + firstCurrency: { + id: e.pair.first_currency.id, + ticker: firstCurrencyTicker ?? null, + }, + secondCurrency: { + id: e.pair.second_currency.id, + ticker: secondCurrencyTicker ?? null, + }, + }; + }); + + return { + success: true, + data: pairs, + }; + }; } const ordersModel = new OrdersModel(); diff --git a/src/routes/orders.router.ts b/src/routes/orders.router.ts index 28fc64d..f9d2c6e 100644 --- a/src/routes/orders.router.ts +++ b/src/routes/orders.router.ts @@ -1,6 +1,8 @@ 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 middleware from '../middleware/middleware.js'; import ordersController from '../controllers/orders.controller.js'; @@ -35,5 +37,10 @@ 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.get( + '/orders/get-user-orders-pairs', + middleware.expressValidator(getUserOrdersAllPairsValidator), + ordersController.getUserOrdersAllPairs.bind(ordersController), +); export default ordersRouter; From 186e8019ccfe2d803927aa34d0fbabdfa942961f Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Mon, 16 Feb 2026 22:17:37 +0300 Subject: [PATCH 06/18] update: add totalItemsCount in get-user-orders response --- src/controllers/orders.controller.ts | 3 ++ .../responses/orders/GetUserOrdersRes.ts | 1 + src/models/Orders.ts | 47 +++++++++++-------- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/controllers/orders.controller.ts b/src/controllers/orders.controller.ts index 2914175..2df33fa 100644 --- a/src/controllers/orders.controller.ts +++ b/src/controllers/orders.controller.ts @@ -240,6 +240,8 @@ class OrdersController { throw new Error('ordersModel.getUserOrders returned Internal error'); } + const { totalItemsCount } = result; + const userOrders = result.data.map((order) => { const mappedOrder: GetUserOrdersResOrderData = { id: order.id, @@ -285,6 +287,7 @@ class OrdersController { res.status(200).send({ success: true, + totalItemsCount, data: userOrders, }); } catch (err) { diff --git a/src/interfaces/responses/orders/GetUserOrdersRes.ts b/src/interfaces/responses/orders/GetUserOrdersRes.ts index 98a6828..960513f 100644 --- a/src/interfaces/responses/orders/GetUserOrdersRes.ts +++ b/src/interfaces/responses/orders/GetUserOrdersRes.ts @@ -55,6 +55,7 @@ export type GetUserOrdersResOrderData = { export type GetUserOrdersSuccessRes = { success: true; + totalItemsCount: number; data: GetUserOrdersResOrderData[]; }; diff --git a/src/models/Orders.ts b/src/models/Orders.ts index ff335c9..f510f38 100644 --- a/src/models/Orders.ts +++ b/src/models/Orders.ts @@ -1,4 +1,4 @@ -import { Op } from 'sequelize'; +import { Op, WhereOptions } from 'sequelize'; import Decimal from 'decimal.js'; import TransactionWithOrders from '@/interfaces/common/Transaction.js'; import Currency from '@/schemes/Currency.js'; @@ -409,6 +409,7 @@ class OrdersModel { } | { success: true; + totalItemsCount: number; data: { id: number; type: string; @@ -436,24 +437,28 @@ class OrdersModel { if (!userRow) throw new Error('Invalid address from token.'); + const ordersSelectWhereClause: WhereOptions = { + user_id: userRow.id, + ...(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: { - user_id: userRow.id, - ...(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] } } - : {}), - }, + where: ordersSelectWhereClause, order: [['timestamp', 'DESC']], limit, offset, @@ -487,7 +492,11 @@ class OrdersModel { 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' }; From 4bf4978f5716577c2ac1253f029b5f5bfdc635b2 Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Tue, 17 Feb 2026 00:22:16 +0300 Subject: [PATCH 07/18] update: replace get method with patch in get-user-orders-pairs endpoint --- src/routes/orders.router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/orders.router.ts b/src/routes/orders.router.ts index f9d2c6e..ad4260f 100644 --- a/src/routes/orders.router.ts +++ b/src/routes/orders.router.ts @@ -37,7 +37,7 @@ 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.get( +ordersRouter.patch( '/orders/get-user-orders-pairs', middleware.expressValidator(getUserOrdersAllPairsValidator), ordersController.getUserOrdersAllPairs.bind(ordersController), From a4e0406426675d1aacf964b0a167662d5168fe97 Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Tue, 17 Feb 2026 02:26:31 +0300 Subject: [PATCH 08/18] fix: fix get-user-orders-all-pairs issues --- src/interfaces/database/modifiedRequests.ts | 5 +- .../orders/GetUserOrdersAllPairsRes.ts | 4 +- src/models/Orders.ts | 52 ++++++++++--------- src/routes/orders.router.ts | 1 + 4 files changed, 33 insertions(+), 29 deletions(-) diff --git a/src/interfaces/database/modifiedRequests.ts b/src/interfaces/database/modifiedRequests.ts index a55aa3d..3e60529 100644 --- a/src/interfaces/database/modifiedRequests.ts +++ b/src/interfaces/database/modifiedRequests.ts @@ -33,7 +33,6 @@ export interface OrderWithPairAndCurrencies extends Order { pair: PairWithCurrencies; } -export interface GroupByIdPair { - pair_id: number; - pair: PairWithCurrencies; +export interface PairWithIdAndCurrencies extends PairWithCurrencies { + id: number; } diff --git a/src/interfaces/responses/orders/GetUserOrdersAllPairsRes.ts b/src/interfaces/responses/orders/GetUserOrdersAllPairsRes.ts index 8dd7210..0523c71 100644 --- a/src/interfaces/responses/orders/GetUserOrdersAllPairsRes.ts +++ b/src/interfaces/responses/orders/GetUserOrdersAllPairsRes.ts @@ -2,11 +2,11 @@ export type GetUserOrdersAllPairsResPair = { id: number; firstCurrency: { id: number; - ticker: string | null; + ticker: string; }; secondCurrency: { id: number; - ticker: string | null; + ticker: string; }; }; diff --git a/src/models/Orders.ts b/src/models/Orders.ts index f510f38..262efa4 100644 --- a/src/models/Orders.ts +++ b/src/models/Orders.ts @@ -3,9 +3,9 @@ import Decimal from 'decimal.js'; import TransactionWithOrders from '@/interfaces/common/Transaction.js'; import Currency from '@/schemes/Currency.js'; import { - GroupByIdPair, OrderWithPairAndCurrencies, PairWithCurrencies, + PairWithIdAndCurrencies, } from '@/interfaces/database/modifiedRequests.js'; import dexModel from './Dex.js'; import userModel from './User.js'; @@ -820,11 +820,11 @@ class OrdersModel { id: number; firstCurrency: { id: number; - ticker: string | null; + ticker: string; }; secondCurrency: { id: number; - ticker: string | null; + ticker: string; }; }[]; }> => { @@ -834,33 +834,37 @@ class OrdersModel { throw new Error(OrdersModel.GET_USER_ORDERS_ALL_PAIRS_USER_NOT_FOUND); } - const pairsGroupedSelection = (await Order.findAll({ - where: { - user_id: userRow.id, - }, - group: 'pair_id', - include: [ - { - model: Pair, - as: 'pair', - include: ['first_currency', 'second_currency'], - }, - ], - })) as unknown as GroupByIdPair[]; + // 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 pairs = pairsGroupedSelection.map((e) => { - const firstCurrencyTicker = e.pair.first_currency.asset_info?.ticker; - const secondCurrencyTicker = e.pair.second_currency.asset_info?.ticker; + 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.pair.id, + id: e.id, firstCurrency: { - id: e.pair.first_currency.id, - ticker: firstCurrencyTicker ?? null, + id: e.first_currency.id, + ticker: firstCurrencyTicker, }, secondCurrency: { - id: e.pair.second_currency.id, - ticker: secondCurrencyTicker ?? null, + id: e.second_currency.id, + ticker: secondCurrencyTicker, }, }; }); diff --git a/src/routes/orders.router.ts b/src/routes/orders.router.ts index ad4260f..fdf1896 100644 --- a/src/routes/orders.router.ts +++ b/src/routes/orders.router.ts @@ -15,6 +15,7 @@ ordersRouter.use( '/orders/get', '/orders/cancel', '/orders/apply-order', + '/orders/get-user-orders-pairs', ], middleware.verifyToken, ); From ac430e0d172d1d22e64424b98aaa5de55b130a7a Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Tue, 17 Feb 2026 10:39:08 +0300 Subject: [PATCH 09/18] update: add get-user-orders pairId filter --- src/controllers/orders.controller.ts | 1 + src/interfaces/bodies/orders/GetUserOrdersBody.ts | 5 +++++ src/models/Orders.ts | 4 +++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/controllers/orders.controller.ts b/src/controllers/orders.controller.ts index 2df33fa..08c3adf 100644 --- a/src/controllers/orders.controller.ts +++ b/src/controllers/orders.controller.ts @@ -224,6 +224,7 @@ class OrdersController { offset, limit, filterInfo: { + pairId: filterInfo.pairId, type: serviceOrderType, status: serviceOrderStatus, date: diff --git a/src/interfaces/bodies/orders/GetUserOrdersBody.ts b/src/interfaces/bodies/orders/GetUserOrdersBody.ts index d5d0c2a..800ec9a 100644 --- a/src/interfaces/bodies/orders/GetUserOrdersBody.ts +++ b/src/interfaces/bodies/orders/GetUserOrdersBody.ts @@ -21,6 +21,7 @@ interface GetUserOrdersBody { limit: number; offset: number; filterInfo: { + pairId?: number; status?: GetUserOrdersBodyStatus; type?: GetUserOrdersBodyType; date?: { @@ -37,6 +38,10 @@ export const getUserOrdersValidator = [ .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)) diff --git a/src/models/Orders.ts b/src/models/Orders.ts index 262efa4..16fede8 100644 --- a/src/models/Orders.ts +++ b/src/models/Orders.ts @@ -389,12 +389,13 @@ class OrdersModel { address, offset, limit, - filterInfo: { status, type, date }, + filterInfo: { pairId, status, type, date }, }: { address: string; offset: number; limit: number; filterInfo: { + pairId?: number; status?: 'active' | 'finished'; type?: 'buy' | 'sell'; date?: { @@ -439,6 +440,7 @@ class OrdersModel { const ordersSelectWhereClause: WhereOptions = { user_id: userRow.id, + ...(pairId !== undefined ? { pair_id: pairId } : {}), ...(status !== undefined ? { status: From afe5ce2fa3c2ce0c1284ae21221ea405241dc062 Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Wed, 18 Feb 2026 02:37:27 +0300 Subject: [PATCH 10/18] add: add orders cancel-all endpoint --- src/controllers/orders.controller.ts | 48 ++++++++ src/interfaces/bodies/orders/CancelAllBody.ts | 50 ++++++++ .../responses/orders/CancelAllRes.ts | 17 +++ src/models/Orders.ts | 110 +++++++++++++++++- src/routes/orders.router.ts | 6 + 5 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 src/interfaces/bodies/orders/CancelAllBody.ts create mode 100644 src/interfaces/responses/orders/CancelAllRes.ts diff --git a/src/controllers/orders.controller.ts b/src/controllers/orders.controller.ts index 08c3adf..518318d 100644 --- a/src/controllers/orders.controller.ts +++ b/src/controllers/orders.controller.ts @@ -12,6 +12,9 @@ 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'; @@ -452,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/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/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/models/Orders.ts b/src/models/Orders.ts index 16fede8..c2aa8a7 100644 --- a/src/models/Orders.ts +++ b/src/models/Orders.ts @@ -1,4 +1,4 @@ -import { Op, WhereOptions } 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'; @@ -876,6 +876,114 @@ class OrdersModel { data: pairs, }; }; + + static CANCEL_ALL_USER_NOT_FOUND = 'No user found'; + static CANCEL_ALL_ORDER_NOT_FOUND = 'Order not found during cancel all process'; + 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 ordersToCancelCount = await Order.count({ + where: ordersToCancelWhereClause, + }); + + const cancelPromises = []; + + for (let offset = 0; offset < ordersToCancelCount; offset += 1) { + cancelPromises.push(async () => { + const orderRow = await Order.findOne({ + where: ordersToCancelWhereClause, + order: [['timestamp', 'ASC']], + offset, + limit: 1, + }); + + if (!orderRow) { + throw new Error(OrdersModel.CANCEL_ALL_ORDER_NOT_FOUND); + } + + 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()); + }); + }); + } + + await Promise.all(cancelPromises); + + return { success: true }; + }; } const ordersModel = new OrdersModel(); diff --git a/src/routes/orders.router.ts b/src/routes/orders.router.ts index fdf1896..71a6ec4 100644 --- a/src/routes/orders.router.ts +++ b/src/routes/orders.router.ts @@ -3,6 +3,7 @@ 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'; @@ -43,5 +44,10 @@ ordersRouter.patch( middleware.expressValidator(getUserOrdersAllPairsValidator), ordersController.getUserOrdersAllPairs.bind(ordersController), ); +ordersRouter.patch( + '/orders/cancel-all', + middleware.expressValidator(cancelAllValidator), + ordersController.cancelAll.bind(ordersController), +); export default ordersRouter; From 5eaa10bdfcfbec2e8f637b68f985ad3ab5ce599a Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Wed, 18 Feb 2026 17:01:29 +0300 Subject: [PATCH 11/18] update: fix cancel-all endpoint --- src/models/Orders.ts | 44 +++++++++++++++++++++---------------- src/routes/orders.router.ts | 1 + 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/models/Orders.ts b/src/models/Orders.ts index c2aa8a7..2840c49 100644 --- a/src/models/Orders.ts +++ b/src/models/Orders.ts @@ -878,7 +878,6 @@ class OrdersModel { }; static CANCEL_ALL_USER_NOT_FOUND = 'No user found'; - static CANCEL_ALL_ORDER_NOT_FOUND = 'Order not found during cancel all process'; cancelAll = async ( { address, @@ -916,25 +915,30 @@ class OrdersModel { ...(date !== undefined ? { timestamp: { [Op.between]: [date.from, date.to] } } : {}), }; - const ordersToCancelCount = await Order.count({ - where: ordersToCancelWhereClause, - }); + const ORDERS_PER_CANCEL_CHUNK = 10_000; + let lastOrderTimestamp: number | undefined; + let finished = false; - const cancelPromises = []; + while (!finished) { + const orderRows = await Order.findAll({ + where: { + ...ordersToCancelWhereClause, + ...(lastOrderTimestamp !== undefined + ? { timestamp: { [Op.gt]: lastOrderTimestamp } } + : {}), + }, + order: [['timestamp', 'ASC']], + limit: ORDERS_PER_CANCEL_CHUNK, + }); - for (let offset = 0; offset < ordersToCancelCount; offset += 1) { - cancelPromises.push(async () => { - const orderRow = await Order.findOne({ - where: ordersToCancelWhereClause, - order: [['timestamp', 'ASC']], - offset, - limit: 1, - }); + const lastOrderRow = orderRows.at(-1); - if (!orderRow) { - throw new Error(OrdersModel.CANCEL_ALL_ORDER_NOT_FOUND); - } + // 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); @@ -977,10 +981,12 @@ class OrdersModel { transaction.afterCommit(() => { sendDeleteOrderMessage(io, orderRow.pair_id.toString(), orderRow.id.toString()); }); - }); - } + } - await Promise.all(cancelPromises); + if (lastOrderRow) { + lastOrderTimestamp = lastOrderRow.timestamp; + } + } return { success: true }; }; diff --git a/src/routes/orders.router.ts b/src/routes/orders.router.ts index 71a6ec4..5e36de9 100644 --- a/src/routes/orders.router.ts +++ b/src/routes/orders.router.ts @@ -17,6 +17,7 @@ ordersRouter.use( '/orders/cancel', '/orders/apply-order', '/orders/get-user-orders-pairs', + '/orders/cancel-all', ], middleware.verifyToken, ); From fb68580f86687a93945e6f4910e648e90e6e2de9 Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Wed, 18 Feb 2026 21:00:40 +0300 Subject: [PATCH 12/18] update: add pairs obsolete filter --- src/models/Dex.ts | 87 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/src/models/Dex.ts b/src/models/Dex.ts index fde4f98..0a1174b 100644 --- a/src/models/Dex.ts +++ b/src/models/Dex.ts @@ -38,8 +38,44 @@ class DexModel { } private getPairsSearchCondition(searchText: string, whitelistedOnly: boolean) { + const tickerRegexp = /^[A-Za-z0-9]{1,14}$/; + const fullNameRegexp = /^[A-Za-z0-9.,:!?\-() ]{0,255}$/; + + 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 }), + Sequelize.where(firstFullNameExpr, { + [Op.regexp]: fullNameRegexp.source, + }), + ], + }, + 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 }), + Sequelize.where(secondFullNameExpr, { + [Op.regexp]: fullNameRegexp.source, + }), + ], + }, { [Op.or]: [ Sequelize.where( @@ -70,7 +106,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 +137,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 +157,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 +193,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, }); From d3b762d6e9a6a8773a19fd94dc2d8c2b65635163 Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Wed, 18 Feb 2026 21:36:18 +0300 Subject: [PATCH 13/18] update: update pair currencies full_name length validation for char_length 400 --- src/models/Dex.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/models/Dex.ts b/src/models/Dex.ts index 0a1174b..740c79e 100644 --- a/src/models/Dex.ts +++ b/src/models/Dex.ts @@ -39,7 +39,7 @@ class DexModel { private getPairsSearchCondition(searchText: string, whitelistedOnly: boolean) { const tickerRegexp = /^[A-Za-z0-9]{1,14}$/; - const fullNameRegexp = /^[A-Za-z0-9.,:!?\-() ]{0,255}$/; + const fullNameRegexp = /^[A-Za-z0-9.,:!?\-() ]*$/; const firstFullNameExpr = Sequelize.literal(`"first_currency"."asset_info"->>'full_name'`); const secondFullNameExpr = Sequelize.literal( @@ -57,9 +57,16 @@ class DexModel { [Op.is]: null, }), Sequelize.where(firstFullNameExpr, { [Op.is]: null }), - Sequelize.where(firstFullNameExpr, { - [Op.regexp]: fullNameRegexp.source, - }), + { + [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'), { @@ -71,9 +78,16 @@ class DexModel { [Op.is]: null, }), Sequelize.where(secondFullNameExpr, { [Op.is]: null }), - Sequelize.where(secondFullNameExpr, { - [Op.regexp]: fullNameRegexp.source, - }), + { + [Op.and]: [ + Sequelize.where(secondFullNameExpr, { + [Op.regexp]: fullNameRegexp.source, + }), + Sequelize.where(Sequelize.fn('char_length', secondFullNameExpr), { + [Op.lte]: 400, + }), + ], + }, ], }, { From 9b08ee4fed80870d1f471ef9ec79d63c94bb6e43 Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Thu, 19 Feb 2026 01:22:32 +0300 Subject: [PATCH 14/18] add: add auth session --- shared/constants.ts | 2 + src/controllers/auth.controller.ts | 35 ++++++++++++ src/interfaces/bodies/auth/RequestAuthBody.ts | 13 +++++ .../responses/auth/RequestAuthRes.ts | 7 +++ src/models/AuthMessages.ts | 53 +++++++++++++++++++ src/routes/auth.router.ts | 8 +++ src/schemes/AuthMessage.ts | 46 ++++++++++++++++ 7 files changed, 164 insertions(+) create mode 100644 src/interfaces/bodies/auth/RequestAuthBody.ts create mode 100644 src/interfaces/responses/auth/RequestAuthRes.ts create mode 100644 src/models/AuthMessages.ts create mode 100644 src/schemes/AuthMessage.ts diff --git a/shared/constants.ts b/shared/constants.ts index 204f551..ae8726f 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -1 +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..80e17e8 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -1,13 +1,38 @@ 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 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 +43,16 @@ class AuthController { return res.status(400).send({ success: false, data: 'Invalid auth data' }); } + const authMessageRow = await authMessagesModel.findOne({ + address, + alias, + message, + }); + + if (!authMessageRow) { + return res.status(400).send({ success: false, data: 'Invalid auth message' }); + } + const dataValid = !!( userData && userData.address && 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/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/models/AuthMessages.ts b/src/models/AuthMessages.ts new file mode 100644 index 0000000..7326da9 --- /dev/null +++ b/src/models/AuthMessages.ts @@ -0,0 +1,53 @@ +import { 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, + expiresAt, + }, + { transaction }, + ); + + return authMessage; + }; + + findOne = async ({ + address, + alias, + message, + }: { + address: string; + alias: string; + message: string; + }): Promise => + AuthMessage.findOne({ + where: { + address, + alias, + message, + }, + }); +} + +const authMessagesModel = new AuthMessagesModel(); + +export default authMessagesModel; diff --git a/src/routes/auth.router.ts b/src/routes/auth.router.ts index 77dfc5b..43f6af4 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( + '/request-auth', + middleware.expressValidator(requestAuthBodyValidator), + authController.requestAuth.bind(authController), +); authRouter.post('/auth', authController.auth); export default authRouter; diff --git a/src/schemes/AuthMessage.ts b/src/schemes/AuthMessage.ts new file mode 100644 index 0000000..01e9725 --- /dev/null +++ b/src/schemes/AuthMessage.ts @@ -0,0 +1,46 @@ +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 expiresAt: 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, + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { + sequelize, + modelName: 'AuthMessage', + tableName: 'auth_messages', + }, +); + +export default AuthMessage; From 1493a2512c07d6fcc0e88968f9fc4bab0eff598b Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Thu, 19 Feb 2026 12:03:02 +0300 Subject: [PATCH 15/18] update: add AuthMessage item delete on successful auth --- src/controllers/auth.controller.ts | 35 ++++++++++++++++++++++-------- src/models/AuthMessages.ts | 22 +++++++++++++++++++ src/models/User.ts | 11 ++++++---- 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 80e17e8..5c06917 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -8,6 +8,7 @@ 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'; @@ -63,17 +64,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/models/AuthMessages.ts b/src/models/AuthMessages.ts index 7326da9..d105126 100644 --- a/src/models/AuthMessages.ts +++ b/src/models/AuthMessages.ts @@ -46,6 +46,28 @@ class AuthMessagesModel { message, }, }); + + deleteOne = async ( + { + address, + alias, + message, + }: { + address: string; + alias: string; + message: string; + }, + { transaction }: { transaction?: Transaction } = {}, + ): Promise => { + await AuthMessage.destroy({ + where: { + address, + alias, + message, + }, + transaction, + }); + }; } const authMessagesModel = new AuthMessagesModel(); 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) { From d7fe71069025f8cb7c7b059f7007ab890ccec26e Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Thu, 19 Feb 2026 12:13:39 +0300 Subject: [PATCH 16/18] update: update request-auth endpoint route --- src/routes/auth.router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/auth.router.ts b/src/routes/auth.router.ts index 43f6af4..fd7d8ca 100644 --- a/src/routes/auth.router.ts +++ b/src/routes/auth.router.ts @@ -7,7 +7,7 @@ import authController from '../controllers/auth.controller.js'; const authRouter = express.Router(); authRouter.post( - '/request-auth', + '/auth/request-auth', middleware.expressValidator(requestAuthBodyValidator), authController.requestAuth.bind(authController), ); From 094cc327d7d8227ada018b6024680d3416695aac Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Thu, 19 Feb 2026 13:14:03 +0300 Subject: [PATCH 17/18] update: add auth message expiration validation --- src/controllers/auth.controller.ts | 5 ++++- src/models/AuthMessages.ts | 2 +- src/schemes/AuthMessage.ts | 5 ++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 5c06917..dfec3ee 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -50,7 +50,10 @@ class AuthController { message, }); - if (!authMessageRow) { + const isAuthMessageValid = + !!authMessageRow && authMessageRow.expires_at.getTime() > Date.now(); + + if (!isAuthMessageValid) { return res.status(400).send({ success: false, data: 'Invalid auth message' }); } diff --git a/src/models/AuthMessages.ts b/src/models/AuthMessages.ts index d105126..a4b4922 100644 --- a/src/models/AuthMessages.ts +++ b/src/models/AuthMessages.ts @@ -22,7 +22,7 @@ class AuthMessagesModel { address, alias, message, - expiresAt, + expires_at: expiresAt, }, { transaction }, ); diff --git a/src/schemes/AuthMessage.ts b/src/schemes/AuthMessage.ts index 01e9725..ef8ab38 100644 --- a/src/schemes/AuthMessage.ts +++ b/src/schemes/AuthMessage.ts @@ -6,7 +6,7 @@ class AuthMessage extends Model { declare address: string; declare alias: string; declare message: string; - declare expiresAt: Date; + declare expires_at: Date; declare readonly createdAt: Date; declare readonly updatedAt: Date; @@ -31,7 +31,7 @@ AuthMessage.init( type: DataTypes.STRING, allowNull: false, }, - expiresAt: { + expires_at: { type: DataTypes.DATE, allowNull: false, }, @@ -39,7 +39,6 @@ AuthMessage.init( { sequelize, modelName: 'AuthMessage', - tableName: 'auth_messages', }, ); From 2ea7db5154915f3e29a18d917a338a8db090daa7 Mon Sep 17 00:00:00 2001 From: Andrew Besedin Date: Thu, 19 Feb 2026 13:42:54 +0300 Subject: [PATCH 18/18] update: add auth messages clean service for periodic expired auth messages clean --- src/models/AuthMessages.ts | 16 +++++++++- src/server.ts | 2 ++ src/workers/authMessagesCleanService.ts | 41 +++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 src/workers/authMessagesCleanService.ts diff --git a/src/models/AuthMessages.ts b/src/models/AuthMessages.ts index a4b4922..0cbee63 100644 --- a/src/models/AuthMessages.ts +++ b/src/models/AuthMessages.ts @@ -1,4 +1,4 @@ -import { Transaction } from 'sequelize'; +import { Op, Transaction } from 'sequelize'; import AuthMessage from '@/schemes/AuthMessage'; @@ -68,6 +68,20 @@ class AuthMessagesModel { transaction, }); }; + + deleteAllExpired = async ( + { now }: { now: Date }, + { transaction }: { transaction?: Transaction } = {}, + ): Promise => { + await AuthMessage.destroy({ + where: { + expires_at: { + [Op.lt]: now, + }, + }, + transaction, + }); + }; } const authMessagesModel = new AuthMessagesModel(); diff --git a/src/server.ts b/src/server.ts index 4c20936..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(); 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;