From f2336fda57dd7e355844b5786b1a066ca5630335 Mon Sep 17 00:00:00 2001 From: Alexey Zhila Date: Thu, 30 Apr 2026 15:50:54 +0300 Subject: [PATCH 01/21] adds verify password method --- .env-example | 2 ++ package-lock.json | 15 +++++++++++++++ package.json | 1 + src/shared/libs/config/rest.schema.ts | 7 +++++++ src/shared/modules/user/user.entity.ts | 5 +++++ 5 files changed, 30 insertions(+) diff --git a/.env-example b/.env-example index 669ca99..5d2969c 100644 --- a/.env-example +++ b/.env-example @@ -10,3 +10,5 @@ DB_PASSWORD=password DB_NAME=six-cities-2 DB_PORT=27017 UPLOAD_DIRECTORY=upload + +JWT_SECRET=secret diff --git a/package-lock.json b/package-lock.json index 7dffb7b..9295840 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "got": "14.6.6", "http-status-codes": "2.3.0", "inversify": "6.2.2", + "jose": "6.2.3", "mime-types": "3.0.2", "mongoose": "9.2.2", "multer": "2.1.1", @@ -3688,6 +3689,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -9044,6 +9054,11 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==" + }, "joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", diff --git a/package.json b/package.json index 0e89de1..2109d06 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "got": "14.6.6", "http-status-codes": "2.3.0", "inversify": "6.2.2", + "jose": "6.2.3", "mime-types": "3.0.2", "mongoose": "9.2.2", "multer": "2.1.1", diff --git a/src/shared/libs/config/rest.schema.ts b/src/shared/libs/config/rest.schema.ts index 000617c..151c9f4 100644 --- a/src/shared/libs/config/rest.schema.ts +++ b/src/shared/libs/config/rest.schema.ts @@ -26,6 +26,7 @@ export type RestSchema = { DB_PORT: string; DB_NAME: string; UPLOAD_DIRECTORY: string; + JWT_SECRET: string; }; export const configRestSchema = convict({ @@ -77,4 +78,10 @@ export const configRestSchema = convict({ env: 'UPLOAD_DIRECTORY', default: null, }, + JWT_SECRET: { + doc: 'Secret for sign JWT', + format: String, + env: 'JWT_SECRET', + default: null, + }, }); diff --git a/src/shared/modules/user/user.entity.ts b/src/shared/modules/user/user.entity.ts index dedd51f..8c889d4 100644 --- a/src/shared/modules/user/user.entity.ts +++ b/src/shared/modules/user/user.entity.ts @@ -54,6 +54,11 @@ export class UserEntity extends defaultClasses.TimeStamps implements User { public getPassword() { return this.password; } + + public verifyPassword(password: string, salt: string) { + const hashPassword = createSHA256(password, salt); + return hashPassword === this.password; + } } export const UserModel = getModelForClass(UserEntity); From 9ecfecaa3688b4b777a33468eb247ea91efd82a1 Mon Sep 17 00:00:00 2001 From: Alexey Zhila Date: Thu, 30 Apr 2026 17:02:57 +0300 Subject: [PATCH 02/21] adds auth service --- .../modules/auth/auth-service.interface.ts | 6 ++ src/shared/modules/auth/auth.constant.ts | 2 + .../modules/auth/default-auth.service.ts | 61 +++++++++++++++++++ .../auth/errors/base-user.exception.ts | 7 +++ src/shared/modules/auth/errors/index.ts | 3 + .../auth/errors/user-not-found.exception.ts | 8 +++ .../user-password-incorrect.exception.ts | 8 +++ src/shared/modules/user/index.ts | 1 + src/shared/types/TokenPayload.ts | 6 ++ 9 files changed, 102 insertions(+) create mode 100644 src/shared/modules/auth/auth-service.interface.ts create mode 100644 src/shared/modules/auth/auth.constant.ts create mode 100644 src/shared/modules/auth/default-auth.service.ts create mode 100644 src/shared/modules/auth/errors/base-user.exception.ts create mode 100644 src/shared/modules/auth/errors/index.ts create mode 100644 src/shared/modules/auth/errors/user-not-found.exception.ts create mode 100644 src/shared/modules/auth/errors/user-password-incorrect.exception.ts create mode 100644 src/shared/types/TokenPayload.ts diff --git a/src/shared/modules/auth/auth-service.interface.ts b/src/shared/modules/auth/auth-service.interface.ts new file mode 100644 index 0000000..1bb9afc --- /dev/null +++ b/src/shared/modules/auth/auth-service.interface.ts @@ -0,0 +1,6 @@ +import { UserEntity, LoginUserDto } from '../user/index.js'; + +export interface AuthService { + authenticate(user: UserEntity): Promise; + verify(dto: LoginUserDto): Promise; +} diff --git a/src/shared/modules/auth/auth.constant.ts b/src/shared/modules/auth/auth.constant.ts new file mode 100644 index 0000000..d15abf3 --- /dev/null +++ b/src/shared/modules/auth/auth.constant.ts @@ -0,0 +1,2 @@ +export const JWT_ALGORITHM = 'H256'; +export const JWT_EXPIRED = '2d'; diff --git a/src/shared/modules/auth/default-auth.service.ts b/src/shared/modules/auth/default-auth.service.ts new file mode 100644 index 0000000..babedff --- /dev/null +++ b/src/shared/modules/auth/default-auth.service.ts @@ -0,0 +1,61 @@ +import { inject, injectable } from 'inversify'; +import * as crypto from 'node:crypto'; +import { AuthService } from './auth-service.interface.js'; +import { Logger } from '../../libs/logger/index.js'; +import { Component } from '../../types/index.js'; +import { LoginUserDto, UserEntity, UserService } from '../user/index.js'; +import { Config, RestSchema } from '../../libs/config/index.js'; +import { TokenPayload } from '../../types/TokenPayload.js'; +import { SignJWT } from 'jose'; +import { JWT_ALGORITHM, JWT_EXPIRED } from './auth.constant.js'; +import { + UserNotFoundException, + UserPasswordIncorrectException, +} from './errors/index.js'; + +@injectable() +export class DefaultAuthService implements AuthService { + constructor( + @inject(Component.Logger) private readonly logger: Logger, + @inject(Component.UserService) private readonly userService: UserService, + @inject(Component.Config) private readonly config: Config, + ) {} + + public async authenticate(user: UserEntity): Promise { + const jwtSecret = this.config.get('JWT_SECRET'); + const secretKey = crypto.createSecretKey(jwtSecret, 'utf-8'); + + const { email, firstname, lastname, id } = user; + + const tokenPayload: TokenPayload = { + email, + firstname, + lastname, + id, + }; + + this.logger.info(`Create token for ${email}`); + + return new SignJWT(tokenPayload) + .setProtectedHeader({ alg: JWT_ALGORITHM }) + .setIssuedAt() + .setExpirationTime(JWT_EXPIRED) + .sign(secretKey); + } + + public async verify(dto: LoginUserDto): Promise { + const user = await this.userService.findByEmail(dto.email); + + if (!user) { + this.logger.warn(`User with ${dto.email} not found`); + throw new UserNotFoundException(); + } + + if (!user.verifyPassword(dto.password, this.config.get('SALT'))) { + this.logger.warn(`Incorrect password for ${dto.email}`); + throw new UserPasswordIncorrectException(); + } + + return user; + } +} diff --git a/src/shared/modules/auth/errors/base-user.exception.ts b/src/shared/modules/auth/errors/base-user.exception.ts new file mode 100644 index 0000000..27f8f1b --- /dev/null +++ b/src/shared/modules/auth/errors/base-user.exception.ts @@ -0,0 +1,7 @@ +import { HttpError } from '../../../libs/rest/index.js'; + +export class BaseUserException extends HttpError { + constructor(httpStatusCode: number, message: string) { + super(httpStatusCode, message); + } +} diff --git a/src/shared/modules/auth/errors/index.ts b/src/shared/modules/auth/errors/index.ts new file mode 100644 index 0000000..e1154ae --- /dev/null +++ b/src/shared/modules/auth/errors/index.ts @@ -0,0 +1,3 @@ +export * from './base-user.exception.js'; +export * from './user-not-found.exception.js'; +export * from './user-password-incorrect.exception.js'; diff --git a/src/shared/modules/auth/errors/user-not-found.exception.ts b/src/shared/modules/auth/errors/user-not-found.exception.ts new file mode 100644 index 0000000..4ae0b72 --- /dev/null +++ b/src/shared/modules/auth/errors/user-not-found.exception.ts @@ -0,0 +1,8 @@ +import { StatusCodes } from 'http-status-codes'; +import { BaseUserException } from './base-user.exception.js'; + +export class UserNotFoundException extends BaseUserException { + constructor() { + super(StatusCodes.NOT_FOUND, 'User noot found'); + } +} diff --git a/src/shared/modules/auth/errors/user-password-incorrect.exception.ts b/src/shared/modules/auth/errors/user-password-incorrect.exception.ts new file mode 100644 index 0000000..8579750 --- /dev/null +++ b/src/shared/modules/auth/errors/user-password-incorrect.exception.ts @@ -0,0 +1,8 @@ +import { StatusCodes } from 'http-status-codes'; +import { BaseUserException } from './base-user.exception.js'; + +export class UserPasswordIncorrectException extends BaseUserException { + constructor() { + super(StatusCodes.UNAUTHORIZED, 'Incorrect user name or password'); + } +} diff --git a/src/shared/modules/user/index.ts b/src/shared/modules/user/index.ts index 19328e9..9fcf0b8 100644 --- a/src/shared/modules/user/index.ts +++ b/src/shared/modules/user/index.ts @@ -1,5 +1,6 @@ export * from './user.entity.js'; export * from './dto/create-user.dto.js'; +export * from './dto/login-user.dto.js'; export * from './default-user.service.js'; export * from './user.container.js'; export * from './user-service.interface.js'; diff --git a/src/shared/types/TokenPayload.ts b/src/shared/types/TokenPayload.ts new file mode 100644 index 0000000..b4c88cb --- /dev/null +++ b/src/shared/types/TokenPayload.ts @@ -0,0 +1,6 @@ +export type TokenPayload = { + email: string; + firstname: string; + lastname: string; + id: string; +}; From e3c8692fd82827c111954c98fd1d2ae3ff0c9d16 Mon Sep 17 00:00:00 2001 From: Alexey Zhila Date: Thu, 30 Apr 2026 17:12:53 +0300 Subject: [PATCH 03/21] adds auth exception filter --- .../modules/auth/auth.exception-filter.ts | 30 +++++++++++++++++++ .../modules/auth/default-auth.service.ts | 2 +- src/shared/modules/auth/index.ts | 3 ++ .../{ => modules/auth}/types/TokenPayload.ts | 0 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/shared/modules/auth/auth.exception-filter.ts create mode 100644 src/shared/modules/auth/index.ts rename src/shared/{ => modules/auth}/types/TokenPayload.ts (100%) diff --git a/src/shared/modules/auth/auth.exception-filter.ts b/src/shared/modules/auth/auth.exception-filter.ts new file mode 100644 index 0000000..aa587db --- /dev/null +++ b/src/shared/modules/auth/auth.exception-filter.ts @@ -0,0 +1,30 @@ +import { inject, injectable } from 'inversify'; +import { NextFunction, Request, Response } from 'express'; +import { ExceptionFilter } from '../../libs/rest/index.js'; +import { Component } from '../../types/index.js'; +import { Logger } from '../../libs/logger/index.js'; +import { BaseUserException } from './errors/base-user.exception.js'; + +@injectable() +export class AuthExceptionFilter implements ExceptionFilter { + constructor(@inject(Component.Logger) private readonly logger: Logger) { + this.logger.info('Register AuthExceptionFilter'); + } + + public catch( + error: Error, + _req: Request, + res: Response, + next: NextFunction, + ): void { + if (!(error instanceof BaseUserException)) { + return next(error); + } + + this.logger.error(`[AuthModule] ${error.message}`, error); + res.status(error.httpStatusCode).json({ + type: 'AUTHORIZATION', + error: error.message, + }); + } +} diff --git a/src/shared/modules/auth/default-auth.service.ts b/src/shared/modules/auth/default-auth.service.ts index babedff..a5a1cb9 100644 --- a/src/shared/modules/auth/default-auth.service.ts +++ b/src/shared/modules/auth/default-auth.service.ts @@ -5,7 +5,7 @@ import { Logger } from '../../libs/logger/index.js'; import { Component } from '../../types/index.js'; import { LoginUserDto, UserEntity, UserService } from '../user/index.js'; import { Config, RestSchema } from '../../libs/config/index.js'; -import { TokenPayload } from '../../types/TokenPayload.js'; +import { TokenPayload } from './types/TokenPayload.js'; import { SignJWT } from 'jose'; import { JWT_ALGORITHM, JWT_EXPIRED } from './auth.constant.js'; import { diff --git a/src/shared/modules/auth/index.ts b/src/shared/modules/auth/index.ts new file mode 100644 index 0000000..dc29539 --- /dev/null +++ b/src/shared/modules/auth/index.ts @@ -0,0 +1,3 @@ +export * from './auth-service.interface.js'; +export * from './types/TokenPayload.js'; +export * from './default-auth.service.js'; diff --git a/src/shared/types/TokenPayload.ts b/src/shared/modules/auth/types/TokenPayload.ts similarity index 100% rename from src/shared/types/TokenPayload.ts rename to src/shared/modules/auth/types/TokenPayload.ts From 00c90f8b677658d79f8b3e4b3353790f4977e406 Mon Sep 17 00:00:00 2001 From: Alexey Zhila Date: Thu, 30 Apr 2026 17:20:21 +0300 Subject: [PATCH 04/21] adds auth container and exception filter to app --- src/main.rest.ts | 2 ++ src/rest/rest.application.ts | 5 +++++ src/shared/modules/auth/auth.container.ts | 19 +++++++++++++++++++ src/shared/modules/auth/index.ts | 1 + src/shared/types/component.enum.ts | 2 ++ 5 files changed, 29 insertions(+) create mode 100644 src/shared/modules/auth/auth.container.ts diff --git a/src/main.rest.ts b/src/main.rest.ts index 353ddd2..9aaa90d 100644 --- a/src/main.rest.ts +++ b/src/main.rest.ts @@ -9,6 +9,7 @@ import { Component } from './shared/types/index.js'; import { createUserContainer } from './shared/modules/user/index.js'; import { createOfferContainer } from './shared/modules/offer/index.js'; import { createCommentContainer } from './shared/modules/comment/index.js'; +import { createAuthContainer } from './shared/modules/auth/index.js'; async function bootstrap() { const appContainer = Container.merge( @@ -16,6 +17,7 @@ async function bootstrap() { createUserContainer(), createOfferContainer(), createCommentContainer(), + createAuthContainer(), ); const app = appContainer.get(Component.RestApplication); diff --git a/src/rest/rest.application.ts b/src/rest/rest.application.ts index 54358cc..728688f 100644 --- a/src/rest/rest.application.ts +++ b/src/rest/rest.application.ts @@ -24,6 +24,8 @@ export class RestApplication { private readonly offerController: Controller, @inject(Component.CommentController) private readonly commentController: Controller, + @inject(Component.AuthExceptionFilter) + private readonly authExceptionFilter: ExceptionFilter, ) { this.server = express(); } @@ -60,6 +62,9 @@ export class RestApplication { } private async _initExceptionFilters() { + this.server.use( + this.authExceptionFilter.catch.bind(this.authExceptionFilter), + ); this.server.use( this.appExceptionFilter.catch.bind(this.appExceptionFilter), ); diff --git a/src/shared/modules/auth/auth.container.ts b/src/shared/modules/auth/auth.container.ts new file mode 100644 index 0000000..f2909b4 --- /dev/null +++ b/src/shared/modules/auth/auth.container.ts @@ -0,0 +1,19 @@ +import { Container } from 'inversify'; +import { AuthService } from './auth-service.interface.js'; +import { Component } from '../../types/index.js'; +import { DefaultAuthService } from './default-auth.service.js'; +import { AuthExceptionFilter } from './auth.exception-filter.js'; + +export function createAuthContainer() { + const authContainer = new Container(); + authContainer + .bind(Component.AuthService) + .to(DefaultAuthService) + .inSingletonScope(); + authContainer + .bind(Component.AuthExceptionFilter) + .to(AuthExceptionFilter) + .inSingletonScope(); + + return authContainer; +} diff --git a/src/shared/modules/auth/index.ts b/src/shared/modules/auth/index.ts index dc29539..ea59cdb 100644 --- a/src/shared/modules/auth/index.ts +++ b/src/shared/modules/auth/index.ts @@ -1,3 +1,4 @@ export * from './auth-service.interface.js'; export * from './types/TokenPayload.js'; export * from './default-auth.service.js'; +export * from './auth.container.js'; diff --git a/src/shared/types/component.enum.ts b/src/shared/types/component.enum.ts index 5d591ad..97134cc 100644 --- a/src/shared/types/component.enum.ts +++ b/src/shared/types/component.enum.ts @@ -13,4 +13,6 @@ export const Component = { UserController: Symbol.for('UserController'), OfferController: Symbol.for('OfferController'), CommentController: Symbol.for('CommentController'), + AuthService: Symbol.for('AuthService'), + AuthExceptionFilter: Symbol.for('AuthExceptionFilter'), } as const; From 36fb9779020c1ad8284792582c51ffd1aa9c45ee Mon Sep 17 00:00:00 2001 From: Alexey Zhila Date: Thu, 30 Apr 2026 17:43:36 +0300 Subject: [PATCH 05/21] adds login method to user controller --- pnpm-lock.yaml | 171 ++++++++++++++++++ src/shared/modules/auth/auth.constant.ts | 2 +- .../modules/user/rdo/logged-user.rdo.ts | 9 + src/shared/modules/user/user.controller.ts | 30 ++- 4 files changed, 192 insertions(+), 20 deletions(-) create mode 100644 src/shared/modules/user/rdo/logged-user.rdo.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97dd1ad..9681e06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: class-transformer: specifier: 0.5.1 version: 0.5.1 + class-validator: + specifier: 0.15.1 + version: 0.15.1 convict: specifier: 6.2.5 version: 6.2.5 @@ -38,9 +41,21 @@ importers: inversify: specifier: 6.2.2 version: 6.2.2(reflect-metadata@0.2.2) + jose: + specifier: 6.2.3 + version: 6.2.3 + mime-types: + specifier: 3.0.2 + version: 3.0.2 mongoose: specifier: 9.2.2 version: 9.2.2 + multer: + specifier: 2.1.1 + version: 2.1.1 + nanoid: + specifier: 5.1.9 + version: 5.1.9 nodemon: specifier: 3.1.14 version: 3.1.14 @@ -69,6 +84,12 @@ importers: '@types/express-serve-static-core': specifier: 5.1.1 version: 5.1.1 + '@types/mime-types': + specifier: 3.0.1 + version: 3.0.1 + '@types/multer': + specifier: 2.1.0 + version: 2.1.0 '@types/node': specifier: 25.5.2 version: 25.5.2 @@ -329,6 +350,12 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mime-types@3.0.1': + resolution: {integrity: sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==} + + '@types/multer@2.1.0': + resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==} + '@types/node@25.5.2': resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} @@ -350,6 +377,9 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} @@ -455,6 +485,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -538,10 +571,17 @@ packages: resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==} engines: {node: '>=20.19.0'} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + byte-counter@0.1.0: resolution: {integrity: sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==} engines: {node: '>=20'} @@ -597,6 +637,9 @@ packages: class-transformer@0.5.1: resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + class-validator@0.15.1: + resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==} + clean-regexp@1.0.0: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} @@ -614,6 +657,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -1261,6 +1308,9 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1321,6 +1371,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.12.42: + resolution: {integrity: sha512-oKQFPTibqQwZZkChCDVMFVJXMZdyJNqDWZWYNn8BgyAaK/6yFJEowxCY0RVFirRyWP63hMRuKlkSEd9qlvbWXg==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -1367,6 +1420,10 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -1390,10 +1447,18 @@ packages: resolution: {integrity: sha512-jkEDaSWZp4/Q3vprqdqukBqUEyNNqC1pwTjZ5cp9YkaR1wv5fvTCd8VFsecbw7i8DNBGjzhJ83MDoPZlcTaPQg==} engines: {node: '>=18.13 || >=19.20 || >=20'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -1479,6 +1544,15 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multer@2.1.1: + resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} + engines: {node: '>= 10.16.0'} + + nanoid@5.1.9: + resolution: {integrity: sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==} + engines: {node: ^18 || >=20} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -1704,6 +1778,10 @@ packages: resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} engines: {node: '>=8'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -1789,6 +1867,9 @@ packages: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -1927,6 +2008,10 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1954,6 +2039,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -2064,6 +2152,10 @@ packages: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -2084,6 +2176,9 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2106,6 +2201,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -2417,6 +2515,12 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/mime-types@3.0.1': {} + + '@types/multer@2.1.0': + dependencies: + '@types/express': 5.0.6 + '@types/node@25.5.2': dependencies: undici-types: 7.18.2 @@ -2438,6 +2542,8 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 25.5.2 + '@types/validator@13.15.10': {} + '@types/webidl-conversions@7.0.3': {} '@types/whatwg-url@13.0.0': @@ -2566,6 +2672,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + append-field@1.0.0: {} + arg@4.1.3: {} argparse@2.0.1: {} @@ -2676,8 +2784,14 @@ snapshots: bson@7.2.0: {} + buffer-from@1.1.2: {} + builtin-modules@3.3.0: {} + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + byte-counter@0.1.0: {} bytes@3.1.2: {} @@ -2740,6 +2854,12 @@ snapshots: class-transformer@0.5.1: {} + class-validator@0.15.1: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.12.42 + validator: 13.15.35 + clean-regexp@1.0.0: dependencies: escape-string-regexp: 1.0.5 @@ -2754,6 +2874,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -3575,6 +3702,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jose@6.2.3: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -3635,6 +3764,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.12.42: {} + lines-and-columns@1.2.4: {} locate-path@5.0.0: @@ -3669,6 +3800,8 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@0.3.0: {} + media-typer@1.1.0: {} memory-pager@1.5.0: {} @@ -3684,8 +3817,14 @@ snapshots: milliparsec@5.1.1: {} + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -3748,6 +3887,15 @@ snapshots: ms@2.1.3: {} + multer@2.1.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + + nanoid@5.1.9: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} @@ -3998,6 +4146,12 @@ snapshots: parse-json: 5.2.0 type-fest: 0.6.0 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.2 @@ -4094,6 +4248,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -4257,6 +4413,8 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + streamsearch@1.1.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -4313,6 +4471,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -4401,6 +4563,11 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -4440,6 +4607,8 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typedarray@0.0.6: {} + typescript@5.9.3: {} unbox-primitive@1.1.0: @@ -4459,6 +4628,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + v8-compile-cache-lib@3.0.1: {} validate-npm-package-license@3.0.4: diff --git a/src/shared/modules/auth/auth.constant.ts b/src/shared/modules/auth/auth.constant.ts index d15abf3..269747d 100644 --- a/src/shared/modules/auth/auth.constant.ts +++ b/src/shared/modules/auth/auth.constant.ts @@ -1,2 +1,2 @@ -export const JWT_ALGORITHM = 'H256'; +export const JWT_ALGORITHM = 'HS256'; export const JWT_EXPIRED = '2d'; diff --git a/src/shared/modules/user/rdo/logged-user.rdo.ts b/src/shared/modules/user/rdo/logged-user.rdo.ts new file mode 100644 index 0000000..543bd7d --- /dev/null +++ b/src/shared/modules/user/rdo/logged-user.rdo.ts @@ -0,0 +1,9 @@ +import { Expose } from 'class-transformer'; + +export class LoggedUserRdo { + @Expose() + public token: string; + + @Expose() + public email: string; +} diff --git a/src/shared/modules/user/user.controller.ts b/src/shared/modules/user/user.controller.ts index 078fca0..f4b98ab 100644 --- a/src/shared/modules/user/user.controller.ts +++ b/src/shared/modules/user/user.controller.ts @@ -19,6 +19,8 @@ import { UserRdo } from './rdo/user.rdo.js'; import { LoginUserRequest } from './login-user-request.type.js'; import { CreateUserDto } from './dto/create-user.dto.js'; import { LoginUserDto } from './dto/login-user.dto.js'; +import { AuthService } from '../auth/index.js'; +import { LoggedUserRdo } from './rdo/logged-user.rdo.js'; @injectable() export class UserController extends BaseController { @@ -27,6 +29,7 @@ export class UserController extends BaseController { @inject(Component.UserService) protected readonly userService: UserService, @inject(Component.Config) protected readonly configService: Config, + @inject(Component.AuthService) private readonly authService: AuthService, ) { super(logger); this.logger.info('Register routes for UserController...'); @@ -77,25 +80,14 @@ export class UserController extends BaseController { this.created(res, fillDTO(UserRdo, result)); } - public async login( - { body }: LoginUserRequest, - _res: Response, - ): Promise { - const existsUser = await this.userService.findByEmail(body.email); - - if (!existsUser) { - throw new HttpError( - StatusCodes.UNAUTHORIZED, - `User with email ${body.email} not found.`, - 'UserController', - ); - } - - throw new HttpError( - StatusCodes.NOT_IMPLEMENTED, - 'Not implemented', - 'UserController', - ); + public async login({ body }: LoginUserRequest, res: Response): Promise { + const user = await this.authService.verify(body); + const token = await this.authService.authenticate(user); + const responseData = fillDTO(LoggedUserRdo, { + email: user.email, + token, + }); + this.ok(res, responseData); } public async uploadAvatar(req: Request, res: Response) { From 10da6919db9a83fa74b4f25da1917442cc2e9988 Mon Sep 17 00:00:00 2001 From: Adonariz Date: Thu, 30 Apr 2026 20:54:36 +0300 Subject: [PATCH 06/21] adds parse token middleware --- custom.d.ts | 7 +++ src/rest/rest.application.ts | 8 +++ .../rest/middleware/parse-token.middleware.ts | 62 +++++++++++++++++++ tsconfig.json | 3 +- 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 custom.d.ts create mode 100644 src/shared/libs/rest/middleware/parse-token.middleware.ts diff --git a/custom.d.ts b/custom.d.ts new file mode 100644 index 0000000..778a665 --- /dev/null +++ b/custom.d.ts @@ -0,0 +1,7 @@ +import { TokenPayload } from './src/shared/modules/auth/index.js'; + +declare module 'express-serve-static-core' { + export interface Request { + tokenPayload: TokenPayload; + } +} diff --git a/src/rest/rest.application.ts b/src/rest/rest.application.ts index 728688f..836394f 100644 --- a/src/rest/rest.application.ts +++ b/src/rest/rest.application.ts @@ -6,6 +6,7 @@ import { Component } from '../shared/types/index.js'; import { DatabaseClient } from '../shared/libs/database-client/index.js'; import { getMongoURI } from '../shared/helpers/index.js'; import { Controller, ExceptionFilter } from '../shared/libs/rest/index.js'; +import { ParseTokenMiddleware } from '../shared/libs/rest/middleware/parse-token.middleware.js'; @injectable() export class RestApplication { @@ -54,11 +55,18 @@ export class RestApplication { } private async _initMiddleware() { + const authenticateMiddleware = new ParseTokenMiddleware( + this.config.get('JWT_SECRET'), + ); + this.server.use(express.json()); this.server.use( '/upload', express.static(this.config.get('UPLOAD_DIRECTORY')), ); + this.server.use( + authenticateMiddleware.execute.bind(authenticateMiddleware), + ); } private async _initExceptionFilters() { diff --git a/src/shared/libs/rest/middleware/parse-token.middleware.ts b/src/shared/libs/rest/middleware/parse-token.middleware.ts new file mode 100644 index 0000000..798aef5 --- /dev/null +++ b/src/shared/libs/rest/middleware/parse-token.middleware.ts @@ -0,0 +1,62 @@ +import { NextFunction, Request, Response } from 'express'; +import { jwtVerify } from 'jose'; +import { StatusCodes } from 'http-status-codes'; +import { createSecretKey } from 'node:crypto'; +import { Middleware } from './middleware.interface.js'; +import { HttpError } from '../errors/index.js'; +import { TokenPayload } from '../../../modules/auth/index.js'; + +function isTokenPayload(payload: unknown): payload is TokenPayload { + return ( + typeof payload === 'object' && + payload !== null && + 'email' in payload && + typeof payload.email === 'string' && + 'firstname' in payload && + typeof payload.firstname === 'string' && + 'lastname' in payload && + typeof payload.lastname === 'string' && + 'id' in payload && + typeof payload.id === 'string' + ); +} + +export class ParseTokenMiddleware implements Middleware { + constructor(private readonly jwtSecret: string) {} + + public async execute( + req: Request, + _res: Response, + next: NextFunction, + ): Promise { + const authorizationHeader = req?.headers?.authorization?.split(' '); + + if (!authorizationHeader) { + return next(); + } + + const [, token] = authorizationHeader; + + try { + const { payload } = await jwtVerify( + token, + createSecretKey(this.jwtSecret, 'utf-8'), + ); + + if (isTokenPayload(payload)) { + req.tokenPayload = { ...payload }; + return next(); + } else { + throw new Error('Bad token'); + } + } catch { + return next( + new HttpError( + StatusCodes.UNAUTHORIZED, + 'Invalid token', + 'AuthenticateMiddleware', + ), + ); + } + } +} diff --git a/tsconfig.json b/tsconfig.json index c0a0a7e..9b2240d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,5 +38,6 @@ ], "ts-node": { "esm": true - } + }, + "files": ["./custom.d.ts"] } From 612d29bfe9f10971dc8fd270f848c323438289bd Mon Sep 17 00:00:00 2001 From: Adonariz Date: Thu, 30 Apr 2026 20:58:38 +0300 Subject: [PATCH 07/21] updates comment controller with tokenPayload --- src/shared/modules/comment/comment.controller.ts | 7 +++++-- src/shared/modules/comment/dto/create-comment.dto.ts | 1 - 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/shared/modules/comment/comment.controller.ts b/src/shared/modules/comment/comment.controller.ts index 87fb636..6fce65a 100644 --- a/src/shared/modules/comment/comment.controller.ts +++ b/src/shared/modules/comment/comment.controller.ts @@ -38,11 +38,14 @@ export default class CommentController extends BaseController { } public async create( - { body }: CreateCommentRequest, + { body, tokenPayload }: CreateCommentRequest, res: Response, ): Promise { const { offerId } = body; - const comment = await this.commentService.create(body); + const comment = await this.commentService.create({ + ...body, + userId: tokenPayload.id, + }); await this.offerService.incCommentCount(offerId); this.created(res, fillDTO(CommentRdo, comment)); } diff --git a/src/shared/modules/comment/dto/create-comment.dto.ts b/src/shared/modules/comment/dto/create-comment.dto.ts index cd8500c..168a851 100644 --- a/src/shared/modules/comment/dto/create-comment.dto.ts +++ b/src/shared/modules/comment/dto/create-comment.dto.ts @@ -14,6 +14,5 @@ export class CreateCommentDTO { @IsMongoId({ message: CreateCommentMessages.offerId.invalidFormat }) public offerId: string; - @IsMongoId({ message: CreateCommentMessages.userId.invalidFormat }) public userId: string; } From 1157e05726f31e18d0b895e2562c2a42b5733782 Mon Sep 17 00:00:00 2001 From: Adonariz Date: Thu, 30 Apr 2026 21:11:12 +0300 Subject: [PATCH 08/21] adds private route middleware and updates offer and comment controllers --- src/rest/rest.application.ts | 7 ++++-- src/shared/libs/rest/index.ts | 2 ++ .../middleware/private-route.middleware.ts | 22 +++++++++++++++++++ .../modules/comment/comment.controller.ts | 2 ++ src/shared/modules/offer/offer.controller.ts | 8 ++++++- 5 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 src/shared/libs/rest/middleware/private-route.middleware.ts diff --git a/src/rest/rest.application.ts b/src/rest/rest.application.ts index 836394f..f7a760d 100644 --- a/src/rest/rest.application.ts +++ b/src/rest/rest.application.ts @@ -5,8 +5,11 @@ import { Config, RestSchema } from '../shared/libs/config/index.js'; import { Component } from '../shared/types/index.js'; import { DatabaseClient } from '../shared/libs/database-client/index.js'; import { getMongoURI } from '../shared/helpers/index.js'; -import { Controller, ExceptionFilter } from '../shared/libs/rest/index.js'; -import { ParseTokenMiddleware } from '../shared/libs/rest/middleware/parse-token.middleware.js'; +import { + Controller, + ExceptionFilter, + ParseTokenMiddleware, +} from '../shared/libs/rest/index.js'; @injectable() export class RestApplication { diff --git a/src/shared/libs/rest/index.ts b/src/shared/libs/rest/index.ts index ed47184..d8a47e9 100644 --- a/src/shared/libs/rest/index.ts +++ b/src/shared/libs/rest/index.ts @@ -13,3 +13,5 @@ export * from './middleware/validate-objectid.middleware.js'; export * from './middleware/validate-dto.middleware.js'; export * from './middleware/document-exists.middleware.js'; export * from './middleware/upload-file.middleware.js'; +export * from './middleware/parse-token.middleware.js'; +export * from './middleware/private-route.middleware.js'; diff --git a/src/shared/libs/rest/middleware/private-route.middleware.ts b/src/shared/libs/rest/middleware/private-route.middleware.ts new file mode 100644 index 0000000..3f50bf5 --- /dev/null +++ b/src/shared/libs/rest/middleware/private-route.middleware.ts @@ -0,0 +1,22 @@ +import { StatusCodes } from 'http-status-codes'; +import { NextFunction, Request, Response } from 'express'; +import { Middleware } from './middleware.interface.js'; +import { HttpError } from '../errors/index.js'; + +export class PrivateRouteMiddleware implements Middleware { + public async execute( + { tokenPayload }: Request, + _res: Response, + next: NextFunction, + ): Promise { + if (!tokenPayload) { + throw new HttpError( + StatusCodes.UNAUTHORIZED, + 'Unauthorized', + 'PrivateRouteMiddleware', + ); + } + + return next(); + } +} diff --git a/src/shared/modules/comment/comment.controller.ts b/src/shared/modules/comment/comment.controller.ts index 6fce65a..66fb89a 100644 --- a/src/shared/modules/comment/comment.controller.ts +++ b/src/shared/modules/comment/comment.controller.ts @@ -5,6 +5,7 @@ import { DocumentExistsMiddleware, HttpMethod, ValidateDtoMiddleware, + PrivateRouteMiddleware, } from '../../libs/rest/index.js'; import { Component } from '../../types/index.js'; import { Logger } from '../../libs/logger/index.js'; @@ -31,6 +32,7 @@ export default class CommentController extends BaseController { method: HttpMethod.Post, handler: this.create, middlewares: [ + new PrivateRouteMiddleware(), new ValidateDtoMiddleware(CreateCommentDTO), new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), ], diff --git a/src/shared/modules/offer/offer.controller.ts b/src/shared/modules/offer/offer.controller.ts index bbd39b6..b8cc86a 100644 --- a/src/shared/modules/offer/offer.controller.ts +++ b/src/shared/modules/offer/offer.controller.ts @@ -6,6 +6,7 @@ import { DocumentExistsMiddleware, HttpError, HttpMethod, + PrivateRouteMiddleware, RequestQuery, ValidateDtoMiddleware, ValidateObjectIdMiddleware, @@ -48,7 +49,10 @@ export class OfferController extends BaseController { path: '/', method: HttpMethod.Post, handler: this.create, - middlewares: [new ValidateDtoMiddleware(CreateOfferDTO)], + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateDtoMiddleware(CreateOfferDTO), + ], }); this.addRoute({ path: '/:offerId', @@ -64,6 +68,7 @@ export class OfferController extends BaseController { method: HttpMethod.Delete, handler: this.delete, middlewares: [ + new PrivateRouteMiddleware(), new ValidateObjectIdMiddleware('offerId'), new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), ], @@ -73,6 +78,7 @@ export class OfferController extends BaseController { method: HttpMethod.Patch, handler: this.update, middlewares: [ + new PrivateRouteMiddleware(), new ValidateObjectIdMiddleware('offerId'), new ValidateDtoMiddleware(UpdateOfferDTO), new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), From bd9e422abba872c77044a31e66e9aa3f6af78d2d Mon Sep 17 00:00:00 2001 From: Adonariz Date: Thu, 30 Apr 2026 21:17:05 +0300 Subject: [PATCH 09/21] updates create offer with token --- src/shared/modules/offer/dto/create-offer.dto.ts | 1 - src/shared/modules/offer/offer.controller.ts | 7 +++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/shared/modules/offer/dto/create-offer.dto.ts b/src/shared/modules/offer/dto/create-offer.dto.ts index 3e22670..de6d8e4 100644 --- a/src/shared/modules/offer/dto/create-offer.dto.ts +++ b/src/shared/modules/offer/dto/create-offer.dto.ts @@ -107,7 +107,6 @@ export class CreateOfferDTO { }) facilities: Facility[]; - @IsMongoId({ message: CreateOfferValidationMessage.userId.invalidId }) userId: string; @ValidateNested({ message: CreateOfferValidationMessage.location.invalid }) diff --git a/src/shared/modules/offer/offer.controller.ts b/src/shared/modules/offer/offer.controller.ts index b8cc86a..ed42606 100644 --- a/src/shared/modules/offer/offer.controller.ts +++ b/src/shared/modules/offer/offer.controller.ts @@ -105,10 +105,13 @@ export class OfferController extends BaseController { } public async create( - { body }: CreateOfferRequest, + { body, tokenPayload }: CreateOfferRequest, res: Response, ): Promise { - const result = await this.offerService.create(body); + const result = await this.offerService.create({ + ...body, + userId: tokenPayload.id, + }); const offer = await this.offerService.findById(result.id); this.created(res, fillDTO(OfferRdo, offer)); } From 002ac07cf891aa83ffe01a932de895c7b25e1fd2 Mon Sep 17 00:00:00 2001 From: Adonariz Date: Thu, 30 Apr 2026 22:02:09 +0300 Subject: [PATCH 10/21] adds check auth method to user controller --- .../modules/offer/dto/create-offer.dto.ts | 1 - src/shared/modules/user/user.controller.ts | 19 +++++++++++++++++++ src/shared/modules/user/user.http | 8 ++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/shared/modules/offer/dto/create-offer.dto.ts b/src/shared/modules/offer/dto/create-offer.dto.ts index de6d8e4..ffa73b3 100644 --- a/src/shared/modules/offer/dto/create-offer.dto.ts +++ b/src/shared/modules/offer/dto/create-offer.dto.ts @@ -8,7 +8,6 @@ import { IsEnum, IsIn, IsInt, - IsMongoId, IsNumber, IsUrl, Max, diff --git a/src/shared/modules/user/user.controller.ts b/src/shared/modules/user/user.controller.ts index f4b98ab..21d0d89 100644 --- a/src/shared/modules/user/user.controller.ts +++ b/src/shared/modules/user/user.controller.ts @@ -45,6 +45,11 @@ export class UserController extends BaseController { handler: this.login, middlewares: [new ValidateDtoMiddleware(LoginUserDto)], }); + this.addRoute({ + path: '/login', + method: HttpMethod.Get, + handler: this.checkAuth, + }); this.addRoute({ path: '/:userId/avatar', method: HttpMethod.Post, @@ -95,4 +100,18 @@ export class UserController extends BaseController { filepath: req.file?.path, }); } + + public async checkAuth({ tokenPayload: { email } }: Request, res: Response) { + const user = await this.userService.findByEmail(email); + + if (!user) { + throw new HttpError( + StatusCodes.UNAUTHORIZED, + 'Unauthorized', + 'UserController', + ); + } + + this.ok(res, fillDTO(LoggedUserRdo, user)); + } } diff --git a/src/shared/modules/user/user.http b/src/shared/modules/user/user.http index d4c9be0..d9abad0 100644 --- a/src/shared/modules/user/user.http +++ b/src/shared/modules/user/user.http @@ -40,3 +40,11 @@ Content-Type: image/png ------WebKitFormBoundary7MA4YWxkTrZu0gW-- ## + +### + +## Проверить токен пользователя +GET http://localhost:3000/users/login HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM + +### From 4a28253776b6e1f56c769412e6bd0b8263179e61 Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 2 May 2026 15:47:29 +0300 Subject: [PATCH 11/21] updates offer entity and service interface --- src/shared/modules/offer/offer-service.interface.ts | 8 ++++++-- src/shared/modules/offer/offer.entity.ts | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/shared/modules/offer/offer-service.interface.ts b/src/shared/modules/offer/offer-service.interface.ts index bdd6172..3438002 100644 --- a/src/shared/modules/offer/offer-service.interface.ts +++ b/src/shared/modules/offer/offer-service.interface.ts @@ -6,7 +6,10 @@ import { City, DocumentExists } from '../../types/index.js'; export interface OfferService extends DocumentExists { create(dto: CreateOfferDTO): Promise>; - findById(offerId: string): Promise | null>; + findById( + offerId: string, + userId?: string, + ): Promise | null>; find(count?: number): Promise[]>; deleteById(offerId: string): Promise | null>; updateById( @@ -17,8 +20,9 @@ export interface OfferService extends DocumentExists { findPremiumByCity( city: City, count?: number, + userId?: string, ): Promise[]>; - findByFavorite(): Promise[]>; + findByFavorite(userId: string): Promise[]>; calcRating(offerId: string): Promise; exists(documentId: string): Promise; } diff --git a/src/shared/modules/offer/offer.entity.ts b/src/shared/modules/offer/offer.entity.ts index a95f38e..ca2590b 100644 --- a/src/shared/modules/offer/offer.entity.ts +++ b/src/shared/modules/offer/offer.entity.ts @@ -47,12 +47,15 @@ export class OfferEntity extends defaultClasses.TimeStamps { @prop({ required: true }) public isPremium!: boolean; - @prop({ required: true }) + @prop({ default: false }) public isFavorite!: boolean; - @prop({ required: true, min: 0, max: 5 }) + @prop({ default: 0 }) public rating!: number; + @prop({ default: 0 }) + public commentCount!: number; + @prop({ required: true, type: () => String, enum: HousingValues }) public type!: Housing; From cb79fad3038e5bd41456c8fd29039fa9942ea135 Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 2 May 2026 15:48:38 +0300 Subject: [PATCH 12/21] adds favorite entity --- .../modules/favorite/favorite.entity.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/shared/modules/favorite/favorite.entity.ts diff --git a/src/shared/modules/favorite/favorite.entity.ts b/src/shared/modules/favorite/favorite.entity.ts new file mode 100644 index 0000000..d94a7ae --- /dev/null +++ b/src/shared/modules/favorite/favorite.entity.ts @@ -0,0 +1,37 @@ +import { + prop, + index, + getModelForClass, + Ref, + modelOptions, + defaultClasses, +} from '@typegoose/typegoose'; +import { UserEntity } from '../user/index.js'; +import { OfferEntity } from '../offer/index.js'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface FavoriteEntity extends defaultClasses.Base {} + +@modelOptions({ + schemaOptions: { + collection: 'favorites', + timestamps: true, + }, +}) +@index({ userId: 1, offerId: 1 }, { unique: true }) +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class FavoriteEntity extends defaultClasses.TimeStamps { + @prop({ + ref: UserEntity, + required: true, + }) + public userId!: Ref; + + @prop({ + ref: OfferEntity, + required: true, + }) + public offerId!: Ref; +} + +export const FavoriteModel = getModelForClass(FavoriteEntity); From 37aed827ba247677705d59664d00c8d664dac33d Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 2 May 2026 17:00:18 +0300 Subject: [PATCH 13/21] downgrades mongoose and typegoose versions --- package-lock.json | 344 ++++++++++++++++++++++++++++------------------ package.json | 4 +- 2 files changed, 210 insertions(+), 138 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9295840..f4bd236 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "inversify": "6.2.2", "jose": "6.2.3", "mime-types": "3.0.2", - "mongoose": "9.2.2", + "mongoose": "7.8.9", "multer": "2.1.1", "nanoid": "5.1.9", "nodemon": "3.1.14", @@ -30,7 +30,7 @@ "tslib": "2.8.1" }, "devDependencies": { - "@typegoose/typegoose": "13.2.1", + "@typegoose/typegoose": "11.8.1", "@types/convict": "6.1.6", "@types/convict-format-with-validator": "6.0.6", "@types/express": "5.0.6", @@ -374,10 +374,11 @@ "license": "MIT" }, "node_modules/@mongodb-js/saslprep": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", - "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.10.tgz", + "integrity": "sha512-DDb3OAw8ezai9p2i1F7R5wMVmyrMIuT8ixjV56R4Hl4cazCo2tOMTDyPR5rrCUcHiMbWHzCXOdife5OD6H1C8w==", "license": "MIT", + "optional": true, "dependencies": { "sparse-bitfield": "^3.0.3" } @@ -741,25 +742,32 @@ "dev": true }, "node_modules/@typegoose/typegoose": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/@typegoose/typegoose/-/typegoose-13.2.1.tgz", - "integrity": "sha512-RGoaTYTy2P7RQ81Pr+LWS5bhmcGMsL5w3X6GPT16HK8NC7fC3WwUnPrsT92xRyRXByUIA3rTCIHq0uryck8Vpw==", + "version": "11.8.1", + "resolved": "https://registry.npmjs.org/@typegoose/typegoose/-/typegoose-11.8.1.tgz", + "integrity": "sha512-Vozadw9OjE+Jz98m2YcK1WFMxPXoOfYOOuLGmrpFRfRLAlCXYiCpwMW5mwhJNSkO4FDjXuzrCDA6sgirJipRgw==", "dev": true, "license": "MIT", "dependencies": { "lodash": "^4.17.20", - "loglevel": "^1.9.2", - "reflect-metadata": "^0.2.2", - "semver": "^7.7.3", - "tslib": "^2.8.1" + "loglevel": "^1.9.1", + "reflect-metadata": "^0.1.14", + "semver": "^7.6.2", + "tslib": "^2.6.3" }, "engines": { - "node": ">=20.19.0" + "node": ">=14.17.0" }, "peerDependencies": { - "mongoose": "~9.2.2" + "mongoose": "~7.8.0" } }, + "node_modules/@typegoose/typegoose/node_modules/reflect-metadata": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", + "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -866,7 +874,6 @@ "version": "25.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -932,11 +939,12 @@ "license": "MIT" }, "node_modules/@types/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", + "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", "license": "MIT", "dependencies": { + "@types/node": "*", "@types/webidl-conversions": "*" } }, @@ -1403,12 +1411,12 @@ } }, "node_modules/bson": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", - "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz", + "integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==", "license": "Apache-2.0", "engines": { - "node": ">=20.19.0" + "node": ">=14.20.1" } }, "node_modules/buffer-from": { @@ -3363,6 +3371,15 @@ "reflect-metadata": "~0.2.2" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3812,12 +3829,12 @@ } }, "node_modules/kareem": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz", - "integrity": "sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", + "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", "license": "Apache-2.0", "engines": { - "node": ">=18.0.0" + "node": ">=12.0.0" } }, "node_modules/keyv": { @@ -3970,7 +3987,8 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/merge-descriptors": { "version": "2.0.0", @@ -4113,26 +4131,27 @@ } }, "node_modules/mongodb": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.0.0.tgz", - "integrity": "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz", + "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==", "license": "Apache-2.0", "dependencies": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^7.0.0", - "mongodb-connection-string-url": "^7.0.0" + "bson": "^5.5.0", + "mongodb-connection-string-url": "^2.6.0", + "socks": "^2.7.1" }, "engines": { - "node": ">=20.19.0" + "node": ">=14.20.1" + }, + "optionalDependencies": { + "@mongodb-js/saslprep": "^1.1.0" }, "peerDependencies": { - "@aws-sdk/credential-providers": "^3.806.0", - "@mongodb-js/zstd": "^7.0.0", - "gcp-metadata": "^7.0.1", - "kerberos": "^7.0.0", - "mongodb-client-encryption": ">=7.0.0 <7.1.0", - "snappy": "^7.3.2", - "socks": "^2.8.6" + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.0.0", + "kerberos": "^1.0.0 || ^2.0.0", + "mongodb-client-encryption": ">=2.3.0 <3", + "snappy": "^7.2.2" }, "peerDependenciesMeta": { "@aws-sdk/credential-providers": { @@ -4141,9 +4160,6 @@ "@mongodb-js/zstd": { "optional": true }, - "gcp-metadata": { - "optional": true - }, "kerberos": { "optional": true }, @@ -4152,40 +4168,35 @@ }, "snappy": { "optional": true - }, - "socks": { - "optional": true } } }, "node_modules/mongodb-connection-string-url": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", - "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", + "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", "license": "Apache-2.0", "dependencies": { - "@types/whatwg-url": "^13.0.0", - "whatwg-url": "^14.1.0" - }, - "engines": { - "node": ">=20.19.0" + "@types/whatwg-url": "^8.2.1", + "whatwg-url": "^11.0.0" } }, "node_modules/mongoose": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.2.2.tgz", - "integrity": "sha512-e06XdPPlH/L9aEq4vcnqIz5AxFFdfhlqrmymYDO7fZwnqwVp/u8pAH/cCEvvpXg0VlV0Tt5qwu6RUk8lhu6ifg==", + "version": "7.8.9", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.8.9.tgz", + "integrity": "sha512-V3GBAJbmOAdzEP8murOvlg7q1szlbe4jTBRyW+JBHRduJBe7F9dk5eyqJDTuYrdBcOOWfLbr6AgXrDK7F0/o5A==", "license": "MIT", "dependencies": { - "kareem": "3.2.0", - "mongodb": "~7.0", + "bson": "^5.5.0", + "kareem": "2.5.1", + "mongodb": "5.9.2", "mpath": "0.9.0", - "mquery": "6.0.0", + "mquery": "5.0.0", "ms": "2.1.3", - "sift": "17.1.3" + "sift": "16.0.1" }, "engines": { - "node": ">=20.19.0" + "node": ">=14.20.1" }, "funding": { "type": "opencollective", @@ -4202,12 +4213,15 @@ } }, "node_modules/mquery": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", - "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", "license": "MIT", + "dependencies": { + "debug": "4.x" + }, "engines": { - "node": ">=20.19.0" + "node": ">=14.0.0" } }, "node_modules/mrmime": { @@ -5636,9 +5650,9 @@ } }, "node_modules/sift": { - "version": "17.1.3", - "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", - "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", + "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==", "license": "MIT" }, "node_modules/signal-exit": { @@ -5689,6 +5703,30 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", + "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -5719,6 +5757,7 @@ "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", "license": "MIT", + "optional": true, "dependencies": { "memory-pager": "^1.0.2" } @@ -6084,15 +6123,15 @@ } }, "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", "license": "MIT", "dependencies": { - "punycode": "^2.3.1" + "punycode": "^2.1.1" }, "engines": { - "node": ">=18" + "node": ">=12" } }, "node_modules/ts-api-utils": { @@ -6254,7 +6293,6 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -6325,16 +6363,16 @@ } }, "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", "license": "MIT", "dependencies": { - "tr46": "^5.1.0", + "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=18" + "node": ">=12" } }, "node_modules/which": { @@ -6765,9 +6803,10 @@ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==" }, "@mongodb-js/saslprep": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", - "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.10.tgz", + "integrity": "sha512-DDb3OAw8ezai9p2i1F7R5wMVmyrMIuT8ixjV56R4Hl4cazCo2tOMTDyPR5rrCUcHiMbWHzCXOdife5OD6H1C8w==", + "optional": true, "requires": { "sparse-bitfield": "^3.0.3" } @@ -7016,16 +7055,24 @@ "dev": true }, "@typegoose/typegoose": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/@typegoose/typegoose/-/typegoose-13.2.1.tgz", - "integrity": "sha512-RGoaTYTy2P7RQ81Pr+LWS5bhmcGMsL5w3X6GPT16HK8NC7fC3WwUnPrsT92xRyRXByUIA3rTCIHq0uryck8Vpw==", + "version": "11.8.1", + "resolved": "https://registry.npmjs.org/@typegoose/typegoose/-/typegoose-11.8.1.tgz", + "integrity": "sha512-Vozadw9OjE+Jz98m2YcK1WFMxPXoOfYOOuLGmrpFRfRLAlCXYiCpwMW5mwhJNSkO4FDjXuzrCDA6sgirJipRgw==", "dev": true, "requires": { "lodash": "^4.17.20", - "loglevel": "^1.9.2", - "reflect-metadata": "^0.2.2", - "semver": "^7.7.3", - "tslib": "^2.8.1" + "loglevel": "^1.9.1", + "reflect-metadata": "^0.1.14", + "semver": "^7.6.2", + "tslib": "^2.6.3" + }, + "dependencies": { + "reflect-metadata": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", + "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", + "dev": true + } } }, "@types/body-parser": { @@ -7124,7 +7171,6 @@ "version": "25.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", - "dev": true, "requires": { "undici-types": "~7.18.0" } @@ -7183,10 +7229,11 @@ "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" }, "@types/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", + "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", "requires": { + "@types/node": "*", "@types/webidl-conversions": "*" } }, @@ -7482,9 +7529,9 @@ } }, "bson": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", - "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==" + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz", + "integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==" }, "buffer-from": { "version": "1.1.2", @@ -8842,6 +8889,11 @@ "@inversifyjs/core": "1.3.5" } }, + "ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==" + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9142,9 +9194,9 @@ } }, "kareem": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz", - "integrity": "sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==" + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", + "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==" }, "keyv": { "version": "5.6.0", @@ -9249,7 +9301,8 @@ "memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "optional": true }, "merge-descriptors": { "version": "2.0.0", @@ -9330,35 +9383,37 @@ "dev": true }, "mongodb": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.0.0.tgz", - "integrity": "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz", + "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==", "requires": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^7.0.0", - "mongodb-connection-string-url": "^7.0.0" + "@mongodb-js/saslprep": "^1.1.0", + "bson": "^5.5.0", + "mongodb-connection-string-url": "^2.6.0", + "socks": "^2.7.1" } }, "mongodb-connection-string-url": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", - "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", + "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", "requires": { - "@types/whatwg-url": "^13.0.0", - "whatwg-url": "^14.1.0" + "@types/whatwg-url": "^8.2.1", + "whatwg-url": "^11.0.0" } }, "mongoose": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.2.2.tgz", - "integrity": "sha512-e06XdPPlH/L9aEq4vcnqIz5AxFFdfhlqrmymYDO7fZwnqwVp/u8pAH/cCEvvpXg0VlV0Tt5qwu6RUk8lhu6ifg==", + "version": "7.8.9", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.8.9.tgz", + "integrity": "sha512-V3GBAJbmOAdzEP8murOvlg7q1szlbe4jTBRyW+JBHRduJBe7F9dk5eyqJDTuYrdBcOOWfLbr6AgXrDK7F0/o5A==", "requires": { - "kareem": "3.2.0", - "mongodb": "~7.0", + "bson": "^5.5.0", + "kareem": "2.5.1", + "mongodb": "5.9.2", "mpath": "0.9.0", - "mquery": "6.0.0", + "mquery": "5.0.0", "ms": "2.1.3", - "sift": "17.1.3" + "sift": "16.0.1" } }, "mpath": { @@ -9367,9 +9422,12 @@ "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==" }, "mquery": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", - "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==" + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "requires": { + "debug": "4.x" + } }, "mrmime": { "version": "2.0.1", @@ -10295,9 +10353,9 @@ } }, "sift": { - "version": "17.1.3", - "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", - "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==" + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", + "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" }, "signal-exit": { "version": "4.1.0", @@ -10330,6 +10388,20 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true }, + "smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==" + }, + "socks": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", + "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==", + "requires": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + } + }, "sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -10351,6 +10423,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "optional": true, "requires": { "memory-pager": "^1.0.2" } @@ -10605,11 +10678,11 @@ "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==" }, "tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", "requires": { - "punycode": "^2.3.1" + "punycode": "^2.1.1" } }, "ts-api-utils": { @@ -10712,8 +10785,7 @@ "undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==" }, "unpipe": { "version": "1.0.0", @@ -10766,11 +10838,11 @@ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" }, "whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", "requires": { - "tr46": "^5.1.0", + "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" } }, diff --git a/package.json b/package.json index 2109d06..d574872 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "mock:server": "json-server ./mocks/mock-server-data.json --port 3123" }, "devDependencies": { - "@typegoose/typegoose": "13.2.1", + "@typegoose/typegoose": "11.8.1", "@types/convict": "6.1.6", "@types/convict-format-with-validator": "6.0.6", "@types/express": "5.0.6", @@ -57,7 +57,7 @@ "inversify": "6.2.2", "jose": "6.2.3", "mime-types": "3.0.2", - "mongoose": "9.2.2", + "mongoose": "7.8.9", "multer": "2.1.1", "nanoid": "5.1.9", "nodemon": "3.1.14", From f9c0082008aac821d2f3bff3f25be20a7641ee79 Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 2 May 2026 17:11:39 +0300 Subject: [PATCH 14/21] adds favorite service --- src/main.rest.ts | 2 + .../favorite/default-favorite.service.ts | 63 +++++++++++++++++++ .../favorite/favorite-service.interface.ts | 10 +++ .../modules/favorite/favorite.container.ts | 19 ++++++ .../modules/favorite/favorite.entity.ts | 15 ++--- src/shared/modules/favorite/index.ts | 4 ++ src/shared/types/component.enum.ts | 2 + 7 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 src/shared/modules/favorite/default-favorite.service.ts create mode 100644 src/shared/modules/favorite/favorite-service.interface.ts create mode 100644 src/shared/modules/favorite/favorite.container.ts create mode 100644 src/shared/modules/favorite/index.ts diff --git a/src/main.rest.ts b/src/main.rest.ts index 9aaa90d..6eaa4e7 100644 --- a/src/main.rest.ts +++ b/src/main.rest.ts @@ -10,6 +10,7 @@ import { createUserContainer } from './shared/modules/user/index.js'; import { createOfferContainer } from './shared/modules/offer/index.js'; import { createCommentContainer } from './shared/modules/comment/index.js'; import { createAuthContainer } from './shared/modules/auth/index.js'; +import { createFavoriteContainer } from './shared/modules/favorite/index.js'; async function bootstrap() { const appContainer = Container.merge( @@ -18,6 +19,7 @@ async function bootstrap() { createOfferContainer(), createCommentContainer(), createAuthContainer(), + createFavoriteContainer(), ); const app = appContainer.get(Component.RestApplication); diff --git a/src/shared/modules/favorite/default-favorite.service.ts b/src/shared/modules/favorite/default-favorite.service.ts new file mode 100644 index 0000000..aa1c67b --- /dev/null +++ b/src/shared/modules/favorite/default-favorite.service.ts @@ -0,0 +1,63 @@ +import { inject, injectable } from 'inversify'; +import { FavoriteService } from './favorite-service.interface.js'; +import { Component } from '../../types/index.js'; +import { Logger } from '../../libs/logger/index.js'; +import { types } from '@typegoose/typegoose'; +import { OfferEntity } from '../offer/index.js'; +import { FavoriteEntity } from './favorite.entity.js'; + +@injectable() +export class DefaultFavoriteService implements FavoriteService { + constructor( + @inject(Component.Logger) private readonly logger: Logger, + @inject(Component.FavoriteModel) + private readonly favoriteModel: types.ModelType, + @inject(Component.OfferModel) + private readonly offerModel: types.ModelType, + ) {} + + public async add(userId: string, offerId: string): Promise { + const exists = await this.favoriteModel.findOne({ userId, offerId }).exec(); + + if (!exists) { + await this.favoriteModel.create({ userId, offerId }); + this.logger.info( + `Offer ${offerId} added to favorites for user ${userId}`, + ); + } + } + + public async remove(userId: string, offerId: string): Promise { + await this.favoriteModel.findOneAndDelete({ userId, offerId }).exec(); + this.logger.info( + `Offer ${offerId} removed from favorites for user ${userId}`, + ); + } + + public async removeByOfferId(offerId: string): Promise { + await this.favoriteModel.deleteMany({ offerId }).exec(); + this.logger.info(`All favorites removed for offer ${offerId}`); + } + + public async getFavoriteStatus( + userId: string, + offerId: string, + ): Promise { + const isFavorite = await this.favoriteModel + .findOne({ userId, offerId }) + .exec(); + return !!isFavorite; + } + + public async findByUserId( + userId: string, + ): Promise[]> { + const favorites = await this.favoriteModel.find({ userId }).exec(); + const offerIds = favorites.map((item) => item.offerId); + + return this.offerModel + .find({ _id: { $in: offerIds } }) + .populate('userId') + .exec(); + } +} diff --git a/src/shared/modules/favorite/favorite-service.interface.ts b/src/shared/modules/favorite/favorite-service.interface.ts new file mode 100644 index 0000000..c6b78d0 --- /dev/null +++ b/src/shared/modules/favorite/favorite-service.interface.ts @@ -0,0 +1,10 @@ +import { DocumentType } from '@typegoose/typegoose'; +import { OfferEntity } from '../offer/index.js'; + +export interface FavoriteService { + add(userId: string, offerId: string): Promise; + remove(userId: string, offerId: string): Promise; + removeByOfferId(offerId: string): Promise; + getFavoriteStatus(userId: string, offerId: string): Promise; + findByUserId(userId: string): Promise[]>; +} diff --git a/src/shared/modules/favorite/favorite.container.ts b/src/shared/modules/favorite/favorite.container.ts new file mode 100644 index 0000000..94d8fdb --- /dev/null +++ b/src/shared/modules/favorite/favorite.container.ts @@ -0,0 +1,19 @@ +import { Container } from 'inversify'; +import { Component } from '../../types/index.js'; +import { FavoriteService } from './favorite-service.interface.js'; +import { DefaultFavoriteService } from './default-favorite.service.js'; +import { FavoriteModel } from './favorite.entity.js'; + +export function createFavoriteContainer() { + const favoriteContainer = new Container(); + + favoriteContainer + .bind(Component.FavoriteService) + .to(DefaultFavoriteService) + .inSingletonScope(); + favoriteContainer + .bind(Component.FavoriteModel) + .toConstantValue(FavoriteModel); + + return favoriteContainer; +} diff --git a/src/shared/modules/favorite/favorite.entity.ts b/src/shared/modules/favorite/favorite.entity.ts index d94a7ae..499f5b3 100644 --- a/src/shared/modules/favorite/favorite.entity.ts +++ b/src/shared/modules/favorite/favorite.entity.ts @@ -1,10 +1,9 @@ import { prop, - index, getModelForClass, - Ref, modelOptions, defaultClasses, + Ref, } from '@typegoose/typegoose'; import { UserEntity } from '../user/index.js'; import { OfferEntity } from '../offer/index.js'; @@ -18,19 +17,13 @@ export interface FavoriteEntity extends defaultClasses.Base {} timestamps: true, }, }) -@index({ userId: 1, offerId: 1 }, { unique: true }) + // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class FavoriteEntity extends defaultClasses.TimeStamps { - @prop({ - ref: UserEntity, - required: true, - }) + @prop({ required: true, ref: UserEntity }) public userId!: Ref; - @prop({ - ref: OfferEntity, - required: true, - }) + @prop({ required: true, ref: OfferEntity }) public offerId!: Ref; } diff --git a/src/shared/modules/favorite/index.ts b/src/shared/modules/favorite/index.ts new file mode 100644 index 0000000..837b17c --- /dev/null +++ b/src/shared/modules/favorite/index.ts @@ -0,0 +1,4 @@ +export * from './favorite-service.interface.js'; +export * from './default-favorite.service.js'; +export * from './favorite.entity.js'; +export * from './favorite.container.js'; diff --git a/src/shared/types/component.enum.ts b/src/shared/types/component.enum.ts index 97134cc..1e11b8b 100644 --- a/src/shared/types/component.enum.ts +++ b/src/shared/types/component.enum.ts @@ -15,4 +15,6 @@ export const Component = { CommentController: Symbol.for('CommentController'), AuthService: Symbol.for('AuthService'), AuthExceptionFilter: Symbol.for('AuthExceptionFilter'), + FavoriteService: Symbol.for('FavoriteService'), + FavoriteModel: Symbol.for('FavoriteModel'), } as const; From 79b950fa7734201e02414af7f22c7383748e8b1c Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 2 May 2026 19:02:28 +0300 Subject: [PATCH 15/21] updates favorite service --- .../favorite/default-favorite.service.ts | 44 ++++++++----------- .../favorite/favorite-service.interface.ts | 5 +-- 2 files changed, 19 insertions(+), 30 deletions(-) diff --git a/src/shared/modules/favorite/default-favorite.service.ts b/src/shared/modules/favorite/default-favorite.service.ts index aa1c67b..9559d31 100644 --- a/src/shared/modules/favorite/default-favorite.service.ts +++ b/src/shared/modules/favorite/default-favorite.service.ts @@ -3,7 +3,6 @@ import { FavoriteService } from './favorite-service.interface.js'; import { Component } from '../../types/index.js'; import { Logger } from '../../libs/logger/index.js'; import { types } from '@typegoose/typegoose'; -import { OfferEntity } from '../offer/index.js'; import { FavoriteEntity } from './favorite.entity.js'; @injectable() @@ -12,23 +11,20 @@ export class DefaultFavoriteService implements FavoriteService { @inject(Component.Logger) private readonly logger: Logger, @inject(Component.FavoriteModel) private readonly favoriteModel: types.ModelType, - @inject(Component.OfferModel) - private readonly offerModel: types.ModelType, ) {} public async add(userId: string, offerId: string): Promise { - const exists = await this.favoriteModel.findOne({ userId, offerId }).exec(); - - if (!exists) { - await this.favoriteModel.create({ userId, offerId }); - this.logger.info( - `Offer ${offerId} added to favorites for user ${userId}`, - ); - } + await this.favoriteModel.updateOne( + { userId, offerId }, + { $setOnInsert: { userId, offerId } }, + { upsert: true }, + ); + + this.logger.info(`Offer ${offerId} added to favorites for user ${userId}`); } public async remove(userId: string, offerId: string): Promise { - await this.favoriteModel.findOneAndDelete({ userId, offerId }).exec(); + await this.favoriteModel.deleteOne({ userId, offerId }).exec(); this.logger.info( `Offer ${offerId} removed from favorites for user ${userId}`, ); @@ -43,21 +39,17 @@ export class DefaultFavoriteService implements FavoriteService { userId: string, offerId: string, ): Promise { - const isFavorite = await this.favoriteModel - .findOne({ userId, offerId }) - .exec(); - return !!isFavorite; + const result = await this.favoriteModel.exists({ userId, offerId }).exec(); + + return result !== null; } - public async findByUserId( - userId: string, - ): Promise[]> { - const favorites = await this.favoriteModel.find({ userId }).exec(); - const offerIds = favorites.map((item) => item.offerId); - - return this.offerModel - .find({ _id: { $in: offerIds } }) - .populate('userId') - .exec(); + public async getOfferIdsByUser(userId: string): Promise { + const favorites = await this.favoriteModel + .find({ userId }) + .select('offerId') + .lean(); + + return favorites.map((item) => item.offerId.toString()); } } diff --git a/src/shared/modules/favorite/favorite-service.interface.ts b/src/shared/modules/favorite/favorite-service.interface.ts index c6b78d0..c90b902 100644 --- a/src/shared/modules/favorite/favorite-service.interface.ts +++ b/src/shared/modules/favorite/favorite-service.interface.ts @@ -1,10 +1,7 @@ -import { DocumentType } from '@typegoose/typegoose'; -import { OfferEntity } from '../offer/index.js'; - export interface FavoriteService { add(userId: string, offerId: string): Promise; remove(userId: string, offerId: string): Promise; removeByOfferId(offerId: string): Promise; getFavoriteStatus(userId: string, offerId: string): Promise; - findByUserId(userId: string): Promise[]>; + getOfferIdsByUser(userId: string): Promise; } From a525a08de79e6bd29b5caaac68425c527c9d9828 Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 2 May 2026 19:04:19 +0300 Subject: [PATCH 16/21] removes isFavorite from entity and dto --- src/shared/modules/offer/dto/create-offer.dto.ts | 3 --- src/shared/modules/offer/dto/create-offer.messages.ts | 3 --- src/shared/modules/offer/dto/update-offer.dto.ts | 4 ---- src/shared/modules/offer/dto/update-offer.messages.ts | 3 --- src/shared/modules/offer/offer.entity.ts | 7 ++++--- 5 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/shared/modules/offer/dto/create-offer.dto.ts b/src/shared/modules/offer/dto/create-offer.dto.ts index ffa73b3..1dbb43f 100644 --- a/src/shared/modules/offer/dto/create-offer.dto.ts +++ b/src/shared/modules/offer/dto/create-offer.dto.ts @@ -67,9 +67,6 @@ export class CreateOfferDTO { @IsBoolean({ message: CreateOfferValidationMessage.isPremium.invalidFormat }) isPremium: boolean; - @IsBoolean({ message: CreateOfferValidationMessage.isFavorite.invalidFormat }) - isFavorite: boolean; - @IsNumber( { maxDecimalPlaces: 1 }, { message: CreateOfferValidationMessage.rating.invalidFormat }, diff --git a/src/shared/modules/offer/dto/create-offer.messages.ts b/src/shared/modules/offer/dto/create-offer.messages.ts index 954e5d7..bdf4a35 100644 --- a/src/shared/modules/offer/dto/create-offer.messages.ts +++ b/src/shared/modules/offer/dto/create-offer.messages.ts @@ -23,9 +23,6 @@ export const CreateOfferValidationMessage = { isPremium: { invalidFormat: 'isPremium must be a boolean', }, - isFavorite: { - invalidFormat: 'isFavorite must be a boolean', - }, rating: { invalidFormat: 'rating must be a number with max 1 decimal place', minValue: 'Minimum rating is 0', diff --git a/src/shared/modules/offer/dto/update-offer.dto.ts b/src/shared/modules/offer/dto/update-offer.dto.ts index a84f13d..1fd63a3 100644 --- a/src/shared/modules/offer/dto/update-offer.dto.ts +++ b/src/shared/modules/offer/dto/update-offer.dto.ts @@ -72,10 +72,6 @@ export class UpdateOfferDTO { @IsBoolean({ message: CreateUpdateOfferMessage.isPremium.invalidFormat }) isPremium: boolean; - @IsOptional() - @IsBoolean({ message: CreateUpdateOfferMessage.isFavorite.invalidFormat }) - isFavorite: boolean; - @IsOptional() @IsNumber( { maxDecimalPlaces: 1 }, diff --git a/src/shared/modules/offer/dto/update-offer.messages.ts b/src/shared/modules/offer/dto/update-offer.messages.ts index 1fb3e24..db2ea42 100644 --- a/src/shared/modules/offer/dto/update-offer.messages.ts +++ b/src/shared/modules/offer/dto/update-offer.messages.ts @@ -23,9 +23,6 @@ export const CreateUpdateOfferMessage = { isPremium: { invalidFormat: 'isPremium must be a boolean', }, - isFavorite: { - invalidFormat: 'isFavorite must be a boolean', - }, rating: { invalidFormat: 'rating must be a number with max 1 decimal place', minValue: 'Minimum rating is 0', diff --git a/src/shared/modules/offer/offer.entity.ts b/src/shared/modules/offer/offer.entity.ts index ca2590b..831fcf7 100644 --- a/src/shared/modules/offer/offer.entity.ts +++ b/src/shared/modules/offer/offer.entity.ts @@ -14,9 +14,13 @@ import { HousingValues, } from '../../types/index.js'; import { UserEntity } from '../user/index.js'; +import { Document } from 'mongoose'; // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export interface OfferEntity extends defaultClasses.Base {} +export type OfferWithFavorite = Omit & { + isFavorite: boolean; +}; @modelOptions({ schemaOptions: { @@ -47,9 +51,6 @@ export class OfferEntity extends defaultClasses.TimeStamps { @prop({ required: true }) public isPremium!: boolean; - @prop({ default: false }) - public isFavorite!: boolean; - @prop({ default: 0 }) public rating!: number; From 4d8e0943725c9a8e2acb2ea5a587e1a86b9bb248 Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 2 May 2026 19:05:58 +0300 Subject: [PATCH 17/21] updates index method with auth --- .../modules/comment/comment.controller.ts | 1 + .../modules/offer/default-offer.service.ts | 35 ++++++++++++------- .../modules/offer/offer-service.interface.ts | 5 ++- src/shared/modules/offer/offer.controller.ts | 12 +++++-- src/shared/modules/offer/offers.http | 1 - src/shared/modules/offer/rdo/offer.rdo.ts | 4 ++- 6 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/shared/modules/comment/comment.controller.ts b/src/shared/modules/comment/comment.controller.ts index 66fb89a..be5f84c 100644 --- a/src/shared/modules/comment/comment.controller.ts +++ b/src/shared/modules/comment/comment.controller.ts @@ -49,6 +49,7 @@ export default class CommentController extends BaseController { userId: tokenPayload.id, }); await this.offerService.incCommentCount(offerId); + await this.offerService.calcRating(offerId); this.created(res, fillDTO(CommentRdo, comment)); } } diff --git a/src/shared/modules/offer/default-offer.service.ts b/src/shared/modules/offer/default-offer.service.ts index acd8683..03a0b26 100644 --- a/src/shared/modules/offer/default-offer.service.ts +++ b/src/shared/modules/offer/default-offer.service.ts @@ -3,7 +3,7 @@ import { OfferService } from './offer-service.interface.js'; import { City, Component, SortType } from '../../types/index.js'; import { Logger } from '../../libs/logger/index.js'; import { types } from '@typegoose/typegoose'; -import { OfferEntity } from './offer.entity.js'; +import { OfferEntity, OfferWithFavorite } from './offer.entity.js'; import { CreateOfferDTO } from './dto/create-offer.dto.js'; import { UpdateOfferDTO } from './dto/update-offer.dto.js'; import { @@ -12,6 +12,7 @@ import { } from './offer.constants.js'; import { CommentEntity } from '../comment/index.js'; import { Types } from 'mongoose'; +import { FavoriteEntity } from '../favorite/index.js'; @injectable() export class DefaultOfferService implements OfferService { @@ -21,6 +22,8 @@ export class DefaultOfferService implements OfferService { private readonly offerModel: types.ModelType, @inject(Component.CommentModel) private readonly commentModel: types.ModelType, + @inject(Component.FavoriteModel) + private readonly favoriteModel: types.ModelType, ) {} public async create( @@ -40,13 +43,28 @@ export class DefaultOfferService implements OfferService { public async find( count?: number, - ): Promise[]> { - return this.offerModel + userId?: string, + ): Promise { + const offers = await this.offerModel .find() - .sort({ createdAt: SortType.Down }) .limit(count ?? DEFAULT_OFFER_COUNT) .populate(['userId']) - .exec(); + .lean(); + + if (!userId) { + return offers.map((offer) => ({ ...offer, isFavorite: false })); + } + + const favoriteIds = await this.favoriteModel.distinct('offerId', { + userId, + }); + + const set = new Set(favoriteIds.map(String)); + + return offers.map((offer) => ({ + ...offer, + isFavorite: set.has(offer._id.toString()), + })); } public async deleteById( @@ -120,11 +138,4 @@ export class DefaultOfferService implements OfferService { .populate(['userId']) .exec(); } - - public async findByFavorite(): Promise[]> { - return this.offerModel - .find({ isFavorite: true }) - .populate(['userId']) - .exec(); - } } diff --git a/src/shared/modules/offer/offer-service.interface.ts b/src/shared/modules/offer/offer-service.interface.ts index 3438002..ec101d5 100644 --- a/src/shared/modules/offer/offer-service.interface.ts +++ b/src/shared/modules/offer/offer-service.interface.ts @@ -1,6 +1,6 @@ import { CreateOfferDTO } from './dto/create-offer.dto.js'; import { DocumentType } from '@typegoose/typegoose'; -import { OfferEntity } from './offer.entity.js'; +import { OfferEntity, OfferWithFavorite } from './offer.entity.js'; import { UpdateOfferDTO } from './dto/update-offer.dto.js'; import { City, DocumentExists } from '../../types/index.js'; @@ -10,7 +10,7 @@ export interface OfferService extends DocumentExists { offerId: string, userId?: string, ): Promise | null>; - find(count?: number): Promise[]>; + find(count?: number, userId?: string): Promise; deleteById(offerId: string): Promise | null>; updateById( offerId: string, @@ -22,7 +22,6 @@ export interface OfferService extends DocumentExists { count?: number, userId?: string, ): Promise[]>; - findByFavorite(userId: string): Promise[]>; calcRating(offerId: string): Promise; exists(documentId: string): Promise; } diff --git a/src/shared/modules/offer/offer.controller.ts b/src/shared/modules/offer/offer.controller.ts index ed42606..2097adf 100644 --- a/src/shared/modules/offer/offer.controller.ts +++ b/src/shared/modules/offer/offer.controller.ts @@ -22,6 +22,7 @@ import { UpdateOfferDTO } from './dto/update-offer.dto.js'; import { ParamsDictionary } from 'express-serve-static-core'; import { CommentRdo, CommentService } from '../comment/index.js'; import { CreateOfferDTO } from './dto/create-offer.dto.js'; +// import { FavoriteService } from '../favorite/index.js'; @injectable() export class OfferController extends BaseController { @@ -31,6 +32,8 @@ export class OfferController extends BaseController { private readonly offerService: OfferService, @inject(Component.CommentService) private readonly commentService: CommentService, + // @inject(Component.FavoriteService) + // private readonly favoriteService: FavoriteService, ) { super(logger); @@ -117,10 +120,15 @@ export class OfferController extends BaseController { } public async index( - { query }: Request, + { + query, + tokenPayload, + }: Request, res: Response, ): Promise { - const offers = await this.offerService.find(query?.limit); + const userId = tokenPayload?.id; + const offers = await this.offerService.find(query?.limit, userId); + this.ok(res, fillDTO(OfferRdo, offers)); } diff --git a/src/shared/modules/offer/offers.http b/src/shared/modules/offer/offers.http index fe4188c..d3e2b43 100644 --- a/src/shared/modules/offer/offers.http +++ b/src/shared/modules/offer/offers.http @@ -28,7 +28,6 @@ Content-Type: application/json "https://example.com/images/brussels6.jpg" ], "isPremium": true, - "isFavorite": true, "rating": 4.7, "type": "apartment", "rooms": 2, diff --git a/src/shared/modules/offer/rdo/offer.rdo.ts b/src/shared/modules/offer/rdo/offer.rdo.ts index b54961e..104d17b 100644 --- a/src/shared/modules/offer/rdo/offer.rdo.ts +++ b/src/shared/modules/offer/rdo/offer.rdo.ts @@ -14,7 +14,9 @@ export class OfferRdo { public description: string; @Expose() - @Transform(({ value }) => value.toISOString()) + @Transform(({ value }) => + value instanceof Date ? value.toISOString() : value, + ) public publishDate: string; @Expose() From 28a46206beb013f80dba3eadc70651a111baaf7c Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 2 May 2026 19:11:55 +0300 Subject: [PATCH 18/21] updates getPremiumOffersByCity with auth and favorites --- .../modules/offer/default-offer.service.ts | 21 +++++++++++++++++-- .../modules/offer/offer-service.interface.ts | 4 ++-- src/shared/modules/offer/offer.controller.ts | 7 ++++++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/shared/modules/offer/default-offer.service.ts b/src/shared/modules/offer/default-offer.service.ts index 03a0b26..d5bbdfd 100644 --- a/src/shared/modules/offer/default-offer.service.ts +++ b/src/shared/modules/offer/default-offer.service.ts @@ -125,17 +125,34 @@ export class DefaultOfferService implements OfferService { public async findPremiumByCity( city: City, + userId?: string, count?: number, - ): Promise[]> { + ): Promise { if (!city) { return []; } - return this.offerModel + const premiumOffers = await this.offerModel .find({ city, isPremium: true }) .sort({ createdAt: SortType.Down }) .limit(count ?? DEFAULT_PREMIUM_OFFER_COUNT) .populate(['userId']) + .lean() .exec(); + + if (!userId) { + return premiumOffers.map((offer) => ({ ...offer, isFavorite: false })); + } + + const favoriteIds = await this.favoriteModel.distinct('offerId', { + userId, + }); + + const set = new Set(favoriteIds.map(String)); + + return premiumOffers.map((offer) => ({ + ...offer, + isFavorite: set.has(offer._id.toString()), + })); } } diff --git a/src/shared/modules/offer/offer-service.interface.ts b/src/shared/modules/offer/offer-service.interface.ts index ec101d5..32f2bef 100644 --- a/src/shared/modules/offer/offer-service.interface.ts +++ b/src/shared/modules/offer/offer-service.interface.ts @@ -19,9 +19,9 @@ export interface OfferService extends DocumentExists { incCommentCount(offerId: string): Promise | null>; findPremiumByCity( city: City, - count?: number, userId?: string, - ): Promise[]>; + count?: number, + ): Promise; calcRating(offerId: string): Promise; exists(documentId: string): Promise; } diff --git a/src/shared/modules/offer/offer.controller.ts b/src/shared/modules/offer/offer.controller.ts index 2097adf..cf12833 100644 --- a/src/shared/modules/offer/offer.controller.ts +++ b/src/shared/modules/offer/offer.controller.ts @@ -161,10 +161,14 @@ export class OfferController extends BaseController { } public async getPremiumOffersByCity( - { query }: Request, + { + query, + tokenPayload, + }: Request, res: Response, ): Promise { const city = String(query.city).trim() as City; + const userId = tokenPayload?.id; const limit = query.limit && Number(query.limit); if (!city) { @@ -177,6 +181,7 @@ export class OfferController extends BaseController { const premiumOffersByCity = await this.offerService.findPremiumByCity( city, + userId, limit, ); this.ok(res, fillDTO(OfferRdo, premiumOffersByCity)); From a9e4ab38221d98b84beed48d44536dfe154d2b4b Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 2 May 2026 19:37:45 +0300 Subject: [PATCH 19/21] updates comment controller with correct middleware --- src/shared/modules/comment/comment.controller.ts | 4 ++-- src/shared/modules/comment/comment.http | 5 +++-- src/shared/modules/offer/default-offer.service.ts | 2 +- src/shared/modules/offer/offers.http | 1 + 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/shared/modules/comment/comment.controller.ts b/src/shared/modules/comment/comment.controller.ts index be5f84c..750442f 100644 --- a/src/shared/modules/comment/comment.controller.ts +++ b/src/shared/modules/comment/comment.controller.ts @@ -2,10 +2,10 @@ import { Response } from 'express'; import { inject, injectable } from 'inversify'; import { BaseController, - DocumentExistsMiddleware, HttpMethod, ValidateDtoMiddleware, PrivateRouteMiddleware, + ValidateObjectIdMiddleware, } from '../../libs/rest/index.js'; import { Component } from '../../types/index.js'; import { Logger } from '../../libs/logger/index.js'; @@ -34,7 +34,7 @@ export default class CommentController extends BaseController { middlewares: [ new PrivateRouteMiddleware(), new ValidateDtoMiddleware(CreateCommentDTO), - new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), + new ValidateObjectIdMiddleware('offerId'), ], }); } diff --git a/src/shared/modules/comment/comment.http b/src/shared/modules/comment/comment.http index a103f2a..4755fc8 100644 --- a/src/shared/modules/comment/comment.http +++ b/src/shared/modules/comment/comment.http @@ -3,10 +3,11 @@ POST http://localhost:3000/comments HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM { - "text": "Отличное расположение!", - "rating": 5, + "text": "Грязища, фу!", + "rating": 1, "offerId": "69c98315edbb02aef834bfd1", "userId": "69c982e424f79692fbaad886" } diff --git a/src/shared/modules/offer/default-offer.service.ts b/src/shared/modules/offer/default-offer.service.ts index d5bbdfd..a81bbe8 100644 --- a/src/shared/modules/offer/default-offer.service.ts +++ b/src/shared/modules/offer/default-offer.service.ts @@ -98,7 +98,7 @@ export class DefaultOfferService implements OfferService { .exec(); } - async calcRating(offerId: string): Promise { + public async calcRating(offerId: string): Promise { const ratings = await this.commentModel.aggregate([ { $match: { offerId: new Types.ObjectId(offerId) } }, { $group: { _id: '$offerId', avgRating: { $avg: '$rating' } } }, diff --git a/src/shared/modules/offer/offers.http b/src/shared/modules/offer/offers.http index d3e2b43..0117082 100644 --- a/src/shared/modules/offer/offers.http +++ b/src/shared/modules/offer/offers.http @@ -12,6 +12,7 @@ GET http://localhost:3000/offers?limit=10 HTTP/1.1 POST http://localhost:3000/offers HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM { "title": "Modern apartment in city center", From 12d482656b5b0872dce3ff16c2beac93b9be3fdb Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 2 May 2026 21:41:14 +0300 Subject: [PATCH 20/21] adds favorite functionality --- .../favorite/default-favorite.service.ts | 20 ++++++ .../favorite/favorite-service.interface.ts | 4 ++ .../modules/offer/default-offer.service.ts | 27 ++++++- .../modules/offer/offer-service.interface.ts | 5 +- src/shared/modules/offer/offer.controller.ts | 70 +++++++++++++++++-- src/shared/modules/offer/offer.entity.ts | 3 +- src/shared/modules/offer/offers.http | 24 ++++++- src/shared/modules/offer/rdo/offer.rdo.ts | 1 + 8 files changed, 140 insertions(+), 14 deletions(-) diff --git a/src/shared/modules/favorite/default-favorite.service.ts b/src/shared/modules/favorite/default-favorite.service.ts index 9559d31..d6921bb 100644 --- a/src/shared/modules/favorite/default-favorite.service.ts +++ b/src/shared/modules/favorite/default-favorite.service.ts @@ -4,6 +4,7 @@ import { Component } from '../../types/index.js'; import { Logger } from '../../libs/logger/index.js'; import { types } from '@typegoose/typegoose'; import { FavoriteEntity } from './favorite.entity.js'; +import { OfferEntity, OfferWithFavorite } from '../offer/index.js'; @injectable() export class DefaultFavoriteService implements FavoriteService { @@ -52,4 +53,23 @@ export class DefaultFavoriteService implements FavoriteService { return favorites.map((item) => item.offerId.toString()); } + + public async getFavoriteOffers(userId: string): Promise { + const favorites = await this.favoriteModel + .find({ userId }) + .populate({ + path: 'offerId', + populate: { path: 'userId' }, + }) + .lean(); + + return favorites.map((fav) => ({ + ...(fav.offerId as OfferEntity), + isFavorite: true, + })); + } + + public async exists(documentId: string): Promise { + return (await this.favoriteModel.exists({ _id: documentId })) !== null; + } } diff --git a/src/shared/modules/favorite/favorite-service.interface.ts b/src/shared/modules/favorite/favorite-service.interface.ts index c90b902..4aa0b54 100644 --- a/src/shared/modules/favorite/favorite-service.interface.ts +++ b/src/shared/modules/favorite/favorite-service.interface.ts @@ -1,7 +1,11 @@ +import { OfferWithFavorite } from '../offer/index.js'; + export interface FavoriteService { add(userId: string, offerId: string): Promise; remove(userId: string, offerId: string): Promise; removeByOfferId(offerId: string): Promise; getFavoriteStatus(userId: string, offerId: string): Promise; getOfferIdsByUser(userId: string): Promise; + getFavoriteOffers(userId: string): Promise; + exists(documentId: string): Promise; } diff --git a/src/shared/modules/offer/default-offer.service.ts b/src/shared/modules/offer/default-offer.service.ts index a81bbe8..16d9b82 100644 --- a/src/shared/modules/offer/default-offer.service.ts +++ b/src/shared/modules/offer/default-offer.service.ts @@ -37,8 +37,31 @@ export class DefaultOfferService implements OfferService { public async findById( offerId: string, - ): Promise | null> { - return this.offerModel.findById(offerId).populate(['userId']).exec(); + userId?: string, + ): Promise { + const offer = await this.offerModel + .findById(offerId) + .populate(['userId']) + .lean() + .exec(); + + if (!offer) { + return null; + } + + if (!userId) { + return { ...offer, isFavorite: false }; + } + + const isFavorite = await this.favoriteModel.exists({ + userId, + offerId, + }); + + return { + ...offer, + isFavorite: isFavorite !== null, + }; } public async find( diff --git a/src/shared/modules/offer/offer-service.interface.ts b/src/shared/modules/offer/offer-service.interface.ts index 32f2bef..b85a07f 100644 --- a/src/shared/modules/offer/offer-service.interface.ts +++ b/src/shared/modules/offer/offer-service.interface.ts @@ -6,10 +6,7 @@ import { City, DocumentExists } from '../../types/index.js'; export interface OfferService extends DocumentExists { create(dto: CreateOfferDTO): Promise>; - findById( - offerId: string, - userId?: string, - ): Promise | null>; + findById(offerId: string, userId?: string): Promise; find(count?: number, userId?: string): Promise; deleteById(offerId: string): Promise | null>; updateById( diff --git a/src/shared/modules/offer/offer.controller.ts b/src/shared/modules/offer/offer.controller.ts index cf12833..3011793 100644 --- a/src/shared/modules/offer/offer.controller.ts +++ b/src/shared/modules/offer/offer.controller.ts @@ -22,7 +22,7 @@ import { UpdateOfferDTO } from './dto/update-offer.dto.js'; import { ParamsDictionary } from 'express-serve-static-core'; import { CommentRdo, CommentService } from '../comment/index.js'; import { CreateOfferDTO } from './dto/create-offer.dto.js'; -// import { FavoriteService } from '../favorite/index.js'; +import { FavoriteService } from '../favorite/index.js'; @injectable() export class OfferController extends BaseController { @@ -32,8 +32,8 @@ export class OfferController extends BaseController { private readonly offerService: OfferService, @inject(Component.CommentService) private readonly commentService: CommentService, - // @inject(Component.FavoriteService) - // private readonly favoriteService: FavoriteService, + @inject(Component.FavoriteService) + private readonly favoriteService: FavoriteService, ) { super(logger); @@ -48,6 +48,12 @@ export class OfferController extends BaseController { method: HttpMethod.Get, handler: this.getPremiumOffersByCity, }); + this.addRoute({ + path: '/favorites', + method: HttpMethod.Get, + handler: this.getFavorites, + middlewares: [new PrivateRouteMiddleware()], + }); this.addRoute({ path: '/', method: HttpMethod.Post, @@ -96,14 +102,35 @@ export class OfferController extends BaseController { new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), ], }); + this.addRoute({ + path: '/:offerId/favorite', + method: HttpMethod.Post, + handler: this.addFavorite, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateObjectIdMiddleware('offerId'), + new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), + ], + }); + this.addRoute({ + path: '/:offerId/favorite', + method: HttpMethod.Delete, + handler: this.removeFavorite, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateObjectIdMiddleware('offerId'), + new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), + ], + }); } public async show( - { params }: Request, + { params, tokenPayload }: Request, res: Response, ): Promise { const { offerId } = params; - const offer = await this.offerService.findById(offerId); + const userId = tokenPayload?.id; + const offer = await this.offerService.findById(offerId, userId); this.ok(res, fillDTO(OfferRdo, offer)); } @@ -186,4 +213,37 @@ export class OfferController extends BaseController { ); this.ok(res, fillDTO(OfferRdo, premiumOffersByCity)); } + + public async addFavorite( + { params, tokenPayload }: Request, + res: Response, + ): Promise { + const favorite = await this.favoriteService.add( + tokenPayload.id, + params.offerId, + ); + this.noContent(res, favorite); + } + + public async removeFavorite( + { params, tokenPayload }: Request, + res: Response, + ): Promise { + const favorite = await this.favoriteService.remove( + tokenPayload.id, + params.offerId, + ); + this.noContent(res, favorite); + } + + public async getFavorites( + { tokenPayload }: Request, + res: Response, + ): Promise { + const offers = await this.favoriteService.getFavoriteOffers( + tokenPayload.id, + ); + + this.ok(res, fillDTO(OfferRdo, offers)); + } } diff --git a/src/shared/modules/offer/offer.entity.ts b/src/shared/modules/offer/offer.entity.ts index 831fcf7..f460f56 100644 --- a/src/shared/modules/offer/offer.entity.ts +++ b/src/shared/modules/offer/offer.entity.ts @@ -14,11 +14,10 @@ import { HousingValues, } from '../../types/index.js'; import { UserEntity } from '../user/index.js'; -import { Document } from 'mongoose'; // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export interface OfferEntity extends defaultClasses.Base {} -export type OfferWithFavorite = Omit & { +export type OfferWithFavorite = OfferEntity & { isFavorite: boolean; }; diff --git a/src/shared/modules/offer/offers.http b/src/shared/modules/offer/offers.http index 0117082..e1d3c7a 100644 --- a/src/shared/modules/offer/offers.http +++ b/src/shared/modules/offer/offers.http @@ -1,11 +1,12 @@ # Получить объявление по идентификатору GET http://localhost:3000/offers/69c98315edbb02aef834bfd1 HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM ### ## Получить список объявлений GET http://localhost:3000/offers?limit=10 HTTP/1.1 - +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM ### ## Создать новое объявление @@ -78,3 +79,24 @@ GET http://localhost:3000/offers/69c98315edbb02aef834bfd1/comments HTTP/1.1 GET http://localhost:3000/offers/premium?city=Paris&limit=10 HTTP/1.1 ### + +## Добавить в избранное + +POST http://localhost:3000/offers/69c98315edbb02aef834bfd1/favorite HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM + +### + +## Удалить из избранного + +DELETE http://localhost:3000/offers/69c98315edbb02aef834bfd1/favorite HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM + +### + +## Получить избранные объявления + +GET http://localhost:3000/offers/favorites HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM + +### diff --git a/src/shared/modules/offer/rdo/offer.rdo.ts b/src/shared/modules/offer/rdo/offer.rdo.ts index 104d17b..fa611af 100644 --- a/src/shared/modules/offer/rdo/offer.rdo.ts +++ b/src/shared/modules/offer/rdo/offer.rdo.ts @@ -5,6 +5,7 @@ import { CoordinatesRdo } from './coordinates.rdo.js'; export class OfferRdo { @Expose() + @Transform(({ obj }) => obj.id ?? obj._id?.toString()) public id: string; @Expose() From 50a204a462a82056dca2e227f15ff22a0c05984f Mon Sep 17 00:00:00 2001 From: Adonariz Date: Mon, 4 May 2026 15:37:56 +0300 Subject: [PATCH 21/21] fixes generate and import commands --- mocks/mock-data.tsv | 2 -- mocks/test-data.tsv | 10 ++++++++++ src/cli/commands/import.command.ts | 4 ++-- src/shared/helpers/offer.ts | 4 ---- .../offer-generator/tsv-offer-generator.ts | 18 +++++++++--------- .../modules/offer/dto/create-offer.dto.ts | 9 --------- .../modules/offer/dto/create-offer.messages.ts | 5 ----- src/shared/modules/offer/offers.http | 13 ++++++------- src/shared/types/offer.type.ts | 7 +++++-- 9 files changed, 32 insertions(+), 40 deletions(-) delete mode 100644 mocks/mock-data.tsv create mode 100644 mocks/test-data.tsv diff --git a/mocks/mock-data.tsv b/mocks/mock-data.tsv deleted file mode 100644 index 4819ba4..0000000 --- a/mocks/mock-data.tsv +++ /dev/null @@ -1,2 +0,0 @@ -Modern apartment near Eiffel Tower Spacious and bright apartment located in the heart of Paris, close to major attractions and public transport. Perfect for couples or small families looking for комфортный отдых. 2026-02-15 Paris https://example.com/images/paris-preview.jpg https://example.com/images/paris1.jpg;https://example.com/images/paris2.jpg;https://example.com/images/paris3.jpg;https://example.com/images/paris4.jpg;https://example.com/images/paris5.jpg;https://example.com/images/paris6.jpg true false 4.8 apartment 2 4 1200 Breakfast;Air conditioning;Washer;Towels;Fridge Angela Nights angela@example.com https://example.com/images/avatar.jpg base 48.85661;2.351499 -Cozy house with garden in Hamburg Charming family house with private garden situated in a quiet district of Hamburg. Ideal for a relaxing stay with friends or family, featuring modern amenities and comfortable rooms. 2026-02-15 Hamburg https://example.com/images/hamburg-preview.jpg https://example.com/images/hamburg1.jpg;https://example.com/images/hamburg2.jpg;https://example.com/images/hamburg3.jpg;https://example.com/images/hamburg4.jpg;https://example.com/images/hamburg5.jpg;https://example.com/images/hamburg6.jpg false true 4.5 house 4 6 2500 Air conditioning;Laptop friendly workspace;Baby seat;Washer;Towels;Fridge Johnny Silverhand johnny@example.com https://example.com/images/avatar.jpg pro 53.550341;10.000654 diff --git a/mocks/test-data.tsv b/mocks/test-data.tsv new file mode 100644 index 0000000..69a1e07 --- /dev/null +++ b/mocks/test-data.tsv @@ -0,0 +1,10 @@ +Quiet room in residential district Modern interior, fast Wi-Fi, and easy access to public transport make this property ideal for short and long stays. 2026-04-30T12:32:34.314Z Hamburg preview-2.jpg offer-10.jpg;offer-11.jpg;offer-12.jpg false room 4 4 49223 Air conditioning;Laptop friendly workspace;Baby seat;Washer;Towels;Fridge Michael Brown michael.brown@example.com avatar-5.jpg base 49.837202;6.067401 +Comfortable house for family stay Enjoy peaceful surroundings and high-quality amenities in this carefully designed and comfortable accommodation. 2026-04-30T12:32:34.315Z Cologne preview-6.jpg offer-4.jpg;offer-5.jpg;offer-6.jpg;offer-7.jpg;offer-8.jpg;offer-9.jpg;offer-10.jpg;offer-11.jpg;offer-12.jpg true house 2 7 41824 Baby seat;Washer;Towels;Fridge Sophia Wilson emma.johnson@example.com avatar-2.png pro 53.294908;8.239346 +Cozy studio near metro station Bright and spacious living area with everything you need for a pleasant and relaxing stay. 2026-04-30T12:32:34.315Z Cologne preview-1.jpg offer-9.jpg;offer-10.jpg;offer-11.jpg;offer-12.jpg false hotel 8 7 60880 Air conditioning;Laptop friendly workspace;Baby seat Sophia Wilson michael.brown@example.com avatar-5.jpg pro 48.796494;4.630213 +Cozy studio near metro station Ideal choice for business trips or vacations, offering comfort, privacy, and excellent facilities. 2026-04-28T12:32:34.315Z Cologne preview-3.jpg offer-11.jpg;offer-12.jpg false hotel 3 3 4586 Air conditioning;Laptop friendly workspace;Baby seat;Washer Emma Johnson emma.johnson@example.com avatar-4.png pro 50.109832;2.546524 +Bright apartment with balcony Perfect location for exploring the city, with shops, restaurants, and attractions within walking distance. 2026-04-28T12:32:34.315Z Paris preview-2.jpg offer-9.jpg;offer-10.jpg;offer-11.jpg;offer-12.jpg true hotel 5 6 71384 Towels;Fridge Emma Johnson michael.brown@example.com avatar-2.png pro 50.759415;10.787393 +Quiet room in residential district Perfect location for exploring the city, with shops, restaurants, and attractions within walking distance. 2026-04-28T12:32:34.315Z Hamburg preview-3.jpg offer-10.jpg;offer-11.jpg;offer-12.jpg true apartment 7 1 88006 Baby seat;Washer;Towels;Fridge Emma Johnson daniel.miller@example.com avatar-5.jpg pro 50.366498;10.419582 +Luxury hotel room with river view Perfect location for exploring the city, with shops, restaurants, and attractions within walking distance. 2026-05-02T12:32:34.316Z Dusseldorf preview-4.jpg offer-3.jpg;offer-4.jpg;offer-5.jpg;offer-6.jpg;offer-7.jpg;offer-8.jpg;offer-9.jpg;offer-10.jpg;offer-11.jpg true house 1 2 51326 Air conditioning;Laptop friendly workspace;Baby seat Sophia Wilson emma.johnson@example.com avatar-4.png base 48.660732;2.247895 +Bright apartment with balcony Perfect location for exploring the city, with shops, restaurants, and attractions within walking distance. 2026-05-01T12:32:34.316Z Cologne preview-3.jpg offer-9.jpg;offer-10.jpg;offer-11.jpg;offer-12.jpg true hotel 3 6 11163 Washer;Towels;Fridge Emma Johnson olivia.davis@example.com avatar-2.png pro 50.531817;4.426451 +Charming room close to downtown A perfect combination of style, comfort, and convenience for any type of traveler. 2026-04-28T12:32:34.316Z Cologne preview-5.jpg offer-4.jpg;offer-5.jpg;offer-6.jpg;offer-7.jpg;offer-8.jpg false apartment 8 2 40212 Towels;Fridge John Smith daniel.miller@example.com avatar-5.jpg pro 51.225049;4.385628 +Bright apartment with balcony Enjoy peaceful surroundings and high-quality amenities in this carefully designed and comfortable accommodation. 2026-04-29T12:32:34.316Z Dusseldorf preview-4.jpg offer-3.jpg;offer-4.jpg;offer-5.jpg;offer-6.jpg;offer-7.jpg;offer-8.jpg;offer-9.jpg;offer-10.jpg;offer-11.jpg true house 1 9 36044 Towels;Fridge Sophia Wilson sophia.wilson@example.com avatar-3.jpg base 53.861462;4.117904 diff --git a/src/cli/commands/import.command.ts b/src/cli/commands/import.command.ts index 085c793..719f6cd 100644 --- a/src/cli/commands/import.command.ts +++ b/src/cli/commands/import.command.ts @@ -24,6 +24,7 @@ import { ConsoleLogger, Logger } from '../../shared/libs/logger/index.js'; import { Offer } from '../../shared/types/index.js'; import { DEFAULT_DB_PORT, DEFAULT_USER_PASSWORD } from './command.constant.js'; import { CommentModel } from '../../shared/modules/comment/index.js'; +import { FavoriteModel } from '../../shared/modules/favorite/index.js'; export class ImportCommand implements Command { private userService: UserService; @@ -41,6 +42,7 @@ export class ImportCommand implements Command { this.logger, OfferModel, CommentModel, + FavoriteModel, ); this.userService = new DefaultUserService(this.logger, UserModel); this.databaseClient = new MongoDatabaseClient(this.logger); @@ -66,8 +68,6 @@ export class ImportCommand implements Command { previewImage: offer.previewImage, images: offer.images, isPremium: offer.isPremium, - isFavorite: offer.isFavorite, - rating: offer.rating, type: offer.type, rooms: offer.rooms, guests: offer.guests, diff --git a/src/shared/helpers/offer.ts b/src/shared/helpers/offer.ts index 44b44a7..34410fe 100644 --- a/src/shared/helpers/offer.ts +++ b/src/shared/helpers/offer.ts @@ -16,8 +16,6 @@ export function createOffer(offerData: string): Offer { previewImage, images, isPremium, - isFavorite, - rating, type, rooms, guests, @@ -39,8 +37,6 @@ export function createOffer(offerData: string): Offer { previewImage, images: images.split(';'), isPremium: booleanFromString(isPremium), - isFavorite: booleanFromString(isFavorite), - rating: parseFloat(rating), type: parseHousing(type), rooms: parseInt(rooms, 10), guests: parseInt(guests, 10), diff --git a/src/shared/libs/offer-generator/tsv-offer-generator.ts b/src/shared/libs/offer-generator/tsv-offer-generator.ts index c50afa4..43e96d1 100644 --- a/src/shared/libs/offer-generator/tsv-offer-generator.ts +++ b/src/shared/libs/offer-generator/tsv-offer-generator.ts @@ -11,7 +11,6 @@ import { OfferGenerator } from './offer-generator.interface.js'; const PRICE = { min: 100, max: 100000 }; const GUESTS = { min: 1, max: 10 }; const ROOMS = { min: 1, max: 8 }; -const RATING = { min: 0, max: 5 }; const FIRST_WEEK_DAY = 1; const LAST_WEEK_DAY = 7; const LONGITUDE = { min: 2, max: 11 }; @@ -30,17 +29,17 @@ export class TSVOfferGenerator implements OfferGenerator { const previewImage = getRandomItem(this.mockData.previewImages); const images = getRandomItems(this.mockData.offerImages).join(';'); const isPremium = generateRandomBoolean(); - const isFavorite = generateRandomBoolean(); - const rating = generateRandomValue(RATING.min, RATING.max, 1).toString(); const type = getRandomItem(this.mockData.housingTypes); - const rooms = generateRandomValue(ROOMS.min, ROOMS.max, 1).toString(); - const guests = generateRandomValue(GUESTS.min, GUESTS.max, 1).toString(); + const rooms = generateRandomValue(ROOMS.min, ROOMS.max).toString(); + const guests = generateRandomValue(GUESTS.min, GUESTS.max).toString(); const price = generateRandomValue(PRICE.min, PRICE.max).toString(); const facilities = getRandomItems(this.mockData.facilities).join( ';', ); - const name = getRandomItem(this.mockData.users); + const [firstname, lastname] = getRandomItem(this.mockData.users).split(' '); const email = getRandomItem(this.mockData.emails); + const avatarPath = getRandomItem(this.mockData.avatars); + const userType = generateRandomBoolean() ? 'pro' : 'base'; const location = [ generateRandomValue(LATITUDE.min, LATITUDE.max, 6), generateRandomValue(LONGITUDE.min, LONGITUDE.max, 6), @@ -54,15 +53,16 @@ export class TSVOfferGenerator implements OfferGenerator { previewImage, images, isPremium, - isFavorite, - rating, type, rooms, guests, price, facilities, - name, + firstname, + lastname, email, + avatarPath, + userType, location, ].join('\t'); } diff --git a/src/shared/modules/offer/dto/create-offer.dto.ts b/src/shared/modules/offer/dto/create-offer.dto.ts index 1dbb43f..011ed7e 100644 --- a/src/shared/modules/offer/dto/create-offer.dto.ts +++ b/src/shared/modules/offer/dto/create-offer.dto.ts @@ -8,7 +8,6 @@ import { IsEnum, IsIn, IsInt, - IsNumber, IsUrl, Max, MaxLength, @@ -67,14 +66,6 @@ export class CreateOfferDTO { @IsBoolean({ message: CreateOfferValidationMessage.isPremium.invalidFormat }) isPremium: boolean; - @IsNumber( - { maxDecimalPlaces: 1 }, - { message: CreateOfferValidationMessage.rating.invalidFormat }, - ) - @Min(0, { message: CreateOfferValidationMessage.rating.minValue }) - @Max(5, { message: CreateOfferValidationMessage.rating.maxValue }) - rating: number; - @IsIn(HousingValues, { message: CreateOfferValidationMessage.type.invalid }) type: Housing; diff --git a/src/shared/modules/offer/dto/create-offer.messages.ts b/src/shared/modules/offer/dto/create-offer.messages.ts index bdf4a35..08306e3 100644 --- a/src/shared/modules/offer/dto/create-offer.messages.ts +++ b/src/shared/modules/offer/dto/create-offer.messages.ts @@ -23,11 +23,6 @@ export const CreateOfferValidationMessage = { isPremium: { invalidFormat: 'isPremium must be a boolean', }, - rating: { - invalidFormat: 'rating must be a number with max 1 decimal place', - minValue: 'Minimum rating is 0', - maxValue: 'Maximum rating is 5', - }, type: { invalid: 'type must be one of Housing', }, diff --git a/src/shared/modules/offer/offers.http b/src/shared/modules/offer/offers.http index e1d3c7a..0747110 100644 --- a/src/shared/modules/offer/offers.http +++ b/src/shared/modules/offer/offers.http @@ -1,19 +1,19 @@ # Получить объявление по идентификатору GET http://localhost:3000/offers/69c98315edbb02aef834bfd1 HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3ODk4MDMyLCJleHAiOjE3NzgwNzA4MzJ9.ZJqG9wZG5xLb3gyaJ3R5nWLgiVABfWiWbMpqd2-sILE ### ## Получить список объявлений GET http://localhost:3000/offers?limit=10 HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3ODk4MDMyLCJleHAiOjE3NzgwNzA4MzJ9.ZJqG9wZG5xLb3gyaJ3R5nWLgiVABfWiWbMpqd2-sILE ### ## Создать новое объявление POST http://localhost:3000/offers HTTP/1.1 Content-Type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3ODk4MDMyLCJleHAiOjE3NzgwNzA4MzJ9.ZJqG9wZG5xLb3gyaJ3R5nWLgiVABfWiWbMpqd2-sILE { "title": "Modern apartment in city center", @@ -30,7 +30,6 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZ "https://example.com/images/brussels6.jpg" ], "isPremium": true, - "rating": 4.7, "type": "apartment", "rooms": 2, "guests": 3, @@ -83,20 +82,20 @@ GET http://localhost:3000/offers/premium?city=Paris&limit=10 HTTP/1.1 ## Добавить в избранное POST http://localhost:3000/offers/69c98315edbb02aef834bfd1/favorite HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3ODk4MDMyLCJleHAiOjE3NzgwNzA4MzJ9.ZJqG9wZG5xLb3gyaJ3R5nWLgiVABfWiWbMpqd2-sILE ### ## Удалить из избранного DELETE http://localhost:3000/offers/69c98315edbb02aef834bfd1/favorite HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3ODk4MDMyLCJleHAiOjE3NzgwNzA4MzJ9.ZJqG9wZG5xLb3gyaJ3R5nWLgiVABfWiWbMpqd2-sILE ### ## Получить избранные объявления GET http://localhost:3000/offers/favorites HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3ODk4MDMyLCJleHAiOjE3NzgwNzA4MzJ9.ZJqG9wZG5xLb3gyaJ3R5nWLgiVABfWiWbMpqd2-sILE ### diff --git a/src/shared/types/offer.type.ts b/src/shared/types/offer.type.ts index ab6608f..b807aaa 100644 --- a/src/shared/types/offer.type.ts +++ b/src/shared/types/offer.type.ts @@ -12,8 +12,6 @@ export type Offer = { previewImage: string; images: string[]; // всегда 6 isPremium: boolean; - isFavorite: boolean; - rating: number; // 0–5 (1 знак после запятой) type: Housing; rooms: number; // 1–8 guests: number; // 1–10 @@ -22,3 +20,8 @@ export type Offer = { host: User; location: Coordinates; }; + +export type OfferWithCalculatedValues = Offer & { + isFavorite: boolean; + rating: number; // 0–5 (1 знак после запятой) +};