From f20afceb7d25aff18d34c2749cb2985ded116208 Mon Sep 17 00:00:00 2001 From: Ilya Kolmakov Date: Mon, 4 Nov 2024 22:06:18 +0500 Subject: [PATCH] module8-task1: add auth logic --- .env-example | 1 + custom.d.ts | 7 + package-lock.json | 15 +++ package.json | 1 + src/main.rest.ts | 2 + src/rest/rest.application.ts | 6 + src/shared/constants/component.constant.ts | 2 + src/shared/entities/user.entity.ts | 5 + .../libs/config/rest.schema.interface.ts | 1 + src/shared/libs/config/rest.schema.ts | 6 + src/shared/libs/rest/index.ts | 2 + .../rest/middleware/parse-token.middleware.ts | 49 +++++++ .../middleware/private-route.middleware.ts | 20 +++ .../modules/auth/auth-service.interface.ts | 7 + src/shared/modules/auth/auth.constant.ts | 2 + src/shared/modules/auth/auth.container.ts | 15 +++ .../modules/auth/auth.exception-filter.ts | 29 ++++ .../modules/auth/default-auth.service.ts | 56 ++++++++ .../auth/errors/base-user.exception.ts | 7 + src/shared/modules/auth/errors/index.ts | 3 + .../auth/errors/user-not-found.exception.ts | 9 ++ .../user-password-incorrect.exception.ts | 9 ++ src/shared/modules/auth/index.ts | 4 + src/shared/modules/auth/types/tokenPayload.ts | 5 + .../modules/comment/comment.controller.ts | 32 ++--- src/shared/modules/comment/comment.http | 4 +- .../modules/comment/dto/create-comment.dto.ts | 2 +- .../modules/offer/default-offer.service.ts | 28 ++-- .../modules/offer/dto/create-offer.dto.ts | 27 +++- .../modules/offer/dto/update-offer.dto.ts | 27 +++- .../modules/offer/offer-service.interface.ts | 4 +- src/shared/modules/offer/offer.aggregation.ts | 18 +++ src/shared/modules/offer/offer.constant.ts | 6 +- src/shared/modules/offer/offer.controller.ts | 86 +++++++----- src/shared/modules/offer/offer.http | 13 +- .../modules/offer/rdo/short-offer.rdo.ts | 2 +- .../create-offer-request.type.ts | 0 .../offer/types/request-premium-query.type.ts | 5 + .../modules/user/create-user-request.type.ts | 6 - .../modules/user/dto/add-favorite.dto.ts | 7 + src/shared/modules/user/index.ts | 1 + .../modules/user/login-user-request.type.ts | 6 - .../modules/user/rdo/logged-user.rdo.ts | 9 ++ .../user/types/add-favorite-request.type.ts | 5 + .../user/types/create-user-request.type.ts | 5 + .../user/types/login-user-request.type.ts | 5 + src/shared/modules/user/user.controller.ts | 127 ++++++++---------- src/shared/modules/user/user.http | 25 ++-- src/shared/types/index.ts | 1 + .../type => types}/param-offerid.type.ts | 0 tsconfig.json | 8 +- 51 files changed, 551 insertions(+), 171 deletions(-) create mode 100644 custom.d.ts create mode 100644 src/shared/libs/rest/middleware/parse-token.middleware.ts create mode 100644 src/shared/libs/rest/middleware/private-route.middleware.ts 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/auth.container.ts create mode 100644 src/shared/modules/auth/auth.exception-filter.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/modules/auth/index.ts create mode 100644 src/shared/modules/auth/types/tokenPayload.ts rename src/shared/modules/offer/{type => types}/create-offer-request.type.ts (100%) create mode 100644 src/shared/modules/offer/types/request-premium-query.type.ts delete mode 100644 src/shared/modules/user/create-user-request.type.ts create mode 100644 src/shared/modules/user/dto/add-favorite.dto.ts delete mode 100644 src/shared/modules/user/login-user-request.type.ts create mode 100644 src/shared/modules/user/rdo/logged-user.rdo.ts create mode 100644 src/shared/modules/user/types/add-favorite-request.type.ts create mode 100644 src/shared/modules/user/types/create-user-request.type.ts create mode 100644 src/shared/modules/user/types/login-user-request.type.ts rename src/shared/{modules/offer/type => types}/param-offerid.type.ts (100%) diff --git a/.env-example b/.env-example index e292c87..fecf4bf 100644 --- a/.env-example +++ b/.env-example @@ -6,3 +6,4 @@ DB_PASSWORD=<Пароль для базы данных> DB_PORT=<Порт для подключения к базе данных> DB_NAME=<Имя базы данных> UPLOAD_DIRECTORY=<Путь для хранения файлов> +JWT_SECRET=<Секрет для аутентификации> diff --git a/custom.d.ts b/custom.d.ts new file mode 100644 index 0000000..dd23633 --- /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; + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3f3302b..6a4eca8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "express-async-handler": "1.2.0", "got": "^14.2.1", "inversify": "^6.0.2", + "jose": "5.4.0", "mime-types": "2.1.35", "mongoose": "8.3.4", "multer": "1.4.5-lts.1", @@ -4515,6 +4516,15 @@ "dev": true, "license": "MIT" }, + "node_modules/jose": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.4.0.tgz", + "integrity": "sha512-6rpxTHPAQyWMb9A35BroFl1Sp0ST3DpPcm5EVIxZxdH+e0Hv9fwhyB3XLKFUcHNpdSDnETmBfuPPTTlYz5+USw==", + "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", @@ -10872,6 +10882,11 @@ "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "dev": true }, + "jose": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.4.0.tgz", + "integrity": "sha512-6rpxTHPAQyWMb9A35BroFl1Sp0ST3DpPcm5EVIxZxdH+e0Hv9fwhyB3XLKFUcHNpdSDnETmBfuPPTTlYz5+USw==" + }, "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 6774a57..0a8151e 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "express-async-handler": "1.2.0", "got": "^14.2.1", "inversify": "^6.0.2", + "jose": "5.4.0", "mime-types": "2.1.35", "mongoose": "8.3.4", "multer": "1.4.5-lts.1", diff --git a/src/main.rest.ts b/src/main.rest.ts index b4da8e1..1665deb 100644 --- a/src/main.rest.ts +++ b/src/main.rest.ts @@ -5,6 +5,7 @@ import { createRestApplicationContainer } from './rest/rest.container.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'; import { RestApplication } from './rest/index.js'; import { COMPONENT } from './shared/constants/index.js'; @@ -15,6 +16,7 @@ async function bootstrap() { createUserContainer(), createOfferContainer(), createCommentContainer(), + createAuthContainer(), ); const application = appContainer.get(COMPONENT.REST_APPLICATION); diff --git a/src/rest/rest.application.ts b/src/rest/rest.application.ts index 67f71ef..c854e74 100644 --- a/src/rest/rest.application.ts +++ b/src/rest/rest.application.ts @@ -7,6 +7,7 @@ import { COMPONENT } from '../shared/constants/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 { @@ -20,6 +21,7 @@ export class RestApplication { @inject(COMPONENT.EXCEPTION_FILTER) private readonly appExceptionFilter: ExceptionFilter, @inject(COMPONENT.USER_CONTROLLER) private readonly userController: Controller, @inject(COMPONENT.COMMENT_CONTROLLER) private readonly commentController: Controller, + @inject(COMPONENT.AUTH_EXCEPTION_FILTER) private readonly authExceptionFilter: ExceptionFilter, ) {} private async initDb() { @@ -46,14 +48,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() { + this.server.use(this.authExceptionFilter.catch.bind(this.authExceptionFilter)); this.server.use(this.appExceptionFilter.catch.bind(this.appExceptionFilter)); } diff --git a/src/shared/constants/component.constant.ts b/src/shared/constants/component.constant.ts index 4ce1171..6015a09 100644 --- a/src/shared/constants/component.constant.ts +++ b/src/shared/constants/component.constant.ts @@ -13,4 +13,6 @@ export const COMPONENT = { EXCEPTION_FILTER: Symbol.for('kExceptionFilter'), USER_CONTROLLER: Symbol.for('kUserController'), COMMENT_CONTROLLER: Symbol.for('kCommentController'), + AUTH_SERVICE: Symbol.for('kAuthService'), + AUTH_EXCEPTION_FILTER: Symbol.for('kAuthExceptionFilter'), } as const; diff --git a/src/shared/entities/user.entity.ts b/src/shared/entities/user.entity.ts index 4707a2a..ae49637 100644 --- a/src/shared/entities/user.entity.ts +++ b/src/shared/entities/user.entity.ts @@ -49,6 +49,11 @@ public setPassword(password: string, salt: string) { this.password = createSHA256(password, salt); } +public verifyPassword(password: string, salt: string) { + const hashPassword = createSHA256(password, salt); + return hashPassword === this.password; +} + public getPassword() { return this.password; } diff --git a/src/shared/libs/config/rest.schema.interface.ts b/src/shared/libs/config/rest.schema.interface.ts index 4fb1a11..c30043a 100644 --- a/src/shared/libs/config/rest.schema.interface.ts +++ b/src/shared/libs/config/rest.schema.interface.ts @@ -7,4 +7,5 @@ export interface IRestSchema { DB_PORT: string; DB_NAME: string; UPLOAD_DIRECTORY: string; + JWT_SECRET: string; } diff --git a/src/shared/libs/config/rest.schema.ts b/src/shared/libs/config/rest.schema.ts index 0ba5ff9..432b587 100644 --- a/src/shared/libs/config/rest.schema.ts +++ b/src/shared/libs/config/rest.schema.ts @@ -53,4 +53,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/libs/rest/index.ts b/src/shared/libs/rest/index.ts index 27f2c5e..09a824c 100644 --- a/src/shared/libs/rest/index.ts +++ b/src/shared/libs/rest/index.ts @@ -17,3 +17,5 @@ export { ValidateObjectIdQueryMiddleware } from './middleware/validate-objectid- export { DocumentQueryExistsMiddleware } from './middleware/document-query-exists.middleware.js'; export { DocumentBodyExistsMiddleware } from './middleware/document-body-exists.middleware.js'; export { ValidateObjectIdBodyMiddleware } from './middleware/validate-objectid-body.middleware.js'; +export { ParseTokenMiddleware } from './middleware/parse-token.middleware.js'; +export { PrivateRouteMiddleware } from './middleware/private-route.middleware.js'; 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..d96f439 --- /dev/null +++ b/src/shared/libs/rest/middleware/parse-token.middleware.ts @@ -0,0 +1,49 @@ +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') && + ('userName' in payload && typeof payload.userName === '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(String(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/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..b668e35 --- /dev/null +++ b/src/shared/libs/rest/middleware/private-route.middleware.ts @@ -0,0 +1,20 @@ + +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, _: Response, next: NextFunction): Promise { + if (! tokenPayload) { + throw new HttpError( + StatusCodes.UNAUTHORIZED, + 'Unauthorized', + 'PrivateRouteMiddleware' + ); + } + + return next(); + } +} 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..d5d0998 --- /dev/null +++ b/src/shared/modules/auth/auth-service.interface.ts @@ -0,0 +1,7 @@ +import { UserEntity } from '../../entities/index.js'; +import { 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..269747d --- /dev/null +++ b/src/shared/modules/auth/auth.constant.ts @@ -0,0 +1,2 @@ +export const JWT_ALGORITHM = 'HS256'; +export const JWT_EXPIRED = '2d'; diff --git a/src/shared/modules/auth/auth.container.ts b/src/shared/modules/auth/auth.container.ts new file mode 100644 index 0000000..a3cb75f --- /dev/null +++ b/src/shared/modules/auth/auth.container.ts @@ -0,0 +1,15 @@ +import { Container } from 'inversify'; + +import { AuthService } from './auth-service.interface.js'; +import { DefaultAuthService } from './default-auth.service.js'; +import { ExceptionFilter } from '../../libs/rest/index.js'; +import { AuthExceptionFilter } from './auth.exception-filter.js'; +import { COMPONENT } from '../../constants/component.constant.js'; + +export function createAuthContainer() { + const authContainer = new Container(); + authContainer.bind(COMPONENT.AUTH_SERVICE).to(DefaultAuthService).inSingletonScope(); + authContainer.bind(COMPONENT.AUTH_EXCEPTION_FILTER).to(AuthExceptionFilter).inSingletonScope(); + + return authContainer; +} 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..b3874c5 --- /dev/null +++ b/src/shared/modules/auth/auth.exception-filter.ts @@ -0,0 +1,29 @@ +import { inject, injectable } from 'inversify'; +import { NextFunction, Request, Response } from 'express'; + +import { ExceptionFilter } from '../../libs/rest/index.js'; +import { Logger } from '../../libs/logger/index.js'; +import { BaseUserException } from './errors/index.js'; +import { COMPONENT } from '../../constants/component.constant.js'; + +@injectable() +export class AuthExceptionFilter implements ExceptionFilter { + constructor( + @inject(COMPONENT.LOGGER) private readonly logger: Logger + ) { + this.logger.info('Register AuthExceptionFilter'); + } + + public catch(error: unknown, _: 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 new file mode 100644 index 0000000..dd5ed59 --- /dev/null +++ b/src/shared/modules/auth/default-auth.service.ts @@ -0,0 +1,56 @@ +import { inject, injectable } from 'inversify'; +import * as crypto from 'node:crypto'; +import { SignJWT } from 'jose'; + +import { AuthService } from './auth-service.interface.js'; +import { Logger } from '../../libs/logger/index.js'; +import { LoginUserDto, UserService } from '../user/index.js'; +import { TokenPayload } from './types/tokenPayload.js'; +import { Config, IRestSchema } from '../../libs/config/index.js'; +import { UserNotFoundException, UserPasswordIncorrectException } from './errors/index.js'; +import { JWT_ALGORITHM, JWT_EXPIRED } from './auth.constant.js'; +import { COMPONENT } from '../../constants/component.constant.js'; +import { UserEntity } from '../../entities/index.js'; + +@injectable() +export class DefaultAuthService implements AuthService { + constructor( + @inject(COMPONENT.LOGGER) private readonly logger: Logger, + @inject(COMPONENT.USER_SERVICE) 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 tokenPayload: TokenPayload = { + email: user.email, + userName: user.userName, + id: user.id, + }; + + this.logger.info(`Create token for ${user.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..c002b50 --- /dev/null +++ b/src/shared/modules/auth/errors/index.ts @@ -0,0 +1,3 @@ +export { BaseUserException } from './base-user.exception.js'; +export { UserNotFoundException } from './user-not-found.exception.js'; +export { UserPasswordIncorrectException } 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..4df21f3 --- /dev/null +++ b/src/shared/modules/auth/errors/user-not-found.exception.ts @@ -0,0 +1,9 @@ +import { StatusCodes } from 'http-status-codes'; + +import { BaseUserException } from './base-user.exception.js'; + +export class UserNotFoundException extends BaseUserException { + constructor() { + super(StatusCodes.NOT_FOUND, 'User not 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..4650cc7 --- /dev/null +++ b/src/shared/modules/auth/errors/user-password-incorrect.exception.ts @@ -0,0 +1,9 @@ +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/auth/index.ts b/src/shared/modules/auth/index.ts new file mode 100644 index 0000000..7e6c569 --- /dev/null +++ b/src/shared/modules/auth/index.ts @@ -0,0 +1,4 @@ +export { AuthService } from './auth-service.interface.js'; +export { TokenPayload } from './types/tokenPayload.js'; +export { createAuthContainer } from './auth.container.js'; +export { DefaultAuthService } from './default-auth.service.js'; diff --git a/src/shared/modules/auth/types/tokenPayload.ts b/src/shared/modules/auth/types/tokenPayload.ts new file mode 100644 index 0000000..e36d6e9 --- /dev/null +++ b/src/shared/modules/auth/types/tokenPayload.ts @@ -0,0 +1,5 @@ +export type TokenPayload = { + email: string; + userName: string; + id: string; +}; diff --git a/src/shared/modules/comment/comment.controller.ts b/src/shared/modules/comment/comment.controller.ts index d35450f..5f76c65 100644 --- a/src/shared/modules/comment/comment.controller.ts +++ b/src/shared/modules/comment/comment.controller.ts @@ -1,8 +1,7 @@ import { inject, injectable } from 'inversify'; import { Request, Response } from 'express'; -import { StatusCodes } from 'http-status-codes'; -import { BaseController, DocumentBodyExistsMiddleware, DocumentQueryExistsMiddleware, HttpError, HttpMethod, ValidateDtoMiddleware, ValidateObjectIdQueryMiddleware } from '../../libs/rest/index.js'; +import { BaseController, DocumentBodyExistsMiddleware, DocumentQueryExistsMiddleware, HttpMethod, PrivateRouteMiddleware, ValidateDtoMiddleware, ValidateObjectIdQueryMiddleware } from '../../libs/rest/index.js'; import { Logger } from '../../libs/logger/index.js'; import { CommentService } from './comment-service.interface.js'; import { OfferService } from '../offer/index.js'; @@ -28,16 +27,22 @@ export default class CommentController extends BaseController { method: HttpMethod.Post, handler: this.create, middlewares: [ + new PrivateRouteMiddleware(), new ValidateDtoMiddleware(CreateCommentDto), // ?- Спросить необходимость инжектировать сервис для проверки в контроллере new DocumentBodyExistsMiddleware(this.offerService, 'Offer', 'offerId') ] }); - this.addRoute({ path: '/', method: HttpMethod.Get, handler: this.index, middlewares: [ - new ValidateObjectIdQueryMiddleware('offerId'), - // ?- Спросить необходимость инжектировать сервис для проверки в контроллере - new DocumentQueryExistsMiddleware(this.offerService, 'Offer', 'offerId') - ] }); + this.addRoute({ + path: '/', method: + HttpMethod.Get, + handler: this.index, + middlewares: [ + new ValidateObjectIdQueryMiddleware('offerId'), + // ?- Спросить необходимость инжектировать сервис для проверки в контроллере + new DocumentQueryExistsMiddleware(this.offerService, 'Offer', 'offerId') + ] + }); } public async index({ query }: Request, res: Response): Promise { @@ -48,19 +53,10 @@ export default class CommentController extends BaseController { } public async create( - { body }: CreateCommentRequest, + { body, tokenPayload }: CreateCommentRequest, res: Response ): Promise { - - if (! await this.offerService.exists(body.offerId)) { - throw new HttpError( - StatusCodes.NOT_FOUND, - `Offer with id ${body.offerId} not found.`, - 'CommentController' - ); - } - - const comment = await this.commentService.create(body); + const comment = await this.commentService.create({ ...body, userId: tokenPayload.id }); await this.offerService.incCommentCount(body.offerId); diff --git a/src/shared/modules/comment/comment.http b/src/shared/modules/comment/comment.http index 1b121ef..607f564 100644 --- a/src/shared/modules/comment/comment.http +++ b/src/shared/modules/comment/comment.http @@ -3,12 +3,12 @@ POST http://localhost:6000/comments HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6ImlseWEua29sbWFrb3YuaUBtYWlsLnJ1IiwidXNlck5hbWUiOiJJbHlhSyIsImlkIjoiNjcyODc5YzZhZmNiZmFmOGI3YzQxM2FiIiwiaWF0IjoxNzMwNzA2Mjk0LCJleHAiOjE3MzA4NzkwOTR9.HryFJPl7jJPSn1iRFLlUBLxO8nh6u8RT5kwXwRMwhVU { "text": "Всё отлично", "rating": 5, - "offerId": "6727abe4326ef99e212e42f5", - "userId": "67152f430ace5d6726f44745" + "offerId": "6727abe4326ef99e212e42f5" } ### diff --git a/src/shared/modules/comment/dto/create-comment.dto.ts b/src/shared/modules/comment/dto/create-comment.dto.ts index f983e88..626fa32 100644 --- a/src/shared/modules/comment/dto/create-comment.dto.ts +++ b/src/shared/modules/comment/dto/create-comment.dto.ts @@ -14,6 +14,6 @@ export class CreateCommentDto { @IsMongoId() public offerId!: string; - @IsMongoId() + // -? А не стоит ли убрать параметр, если мы из токена получаем userId public userId!: string; } diff --git a/src/shared/modules/offer/default-offer.service.ts b/src/shared/modules/offer/default-offer.service.ts index a54f37d..56813ec 100644 --- a/src/shared/modules/offer/default-offer.service.ts +++ b/src/shared/modules/offer/default-offer.service.ts @@ -6,11 +6,12 @@ import { Logger } from '../../libs/logger/index.js'; import { CreateOfferDto } from './dto/create-offer.dto.js'; import { COMPONENT } from '../../constants/component.constant.js'; import { UpdateOfferDto } from './dto/update-offer.dto.js'; -import { DEFAULT_PREMIUM_OFFER_COUNT } from './offer.constant.js'; +import { MAX_PREMIUM_OFFER_COUNT } from './offer.constant.js'; import { SortType } from '../../types/sort-type.enum.js'; import { Types } from 'mongoose'; -import { authorAggregation } from './offer.aggregation.js'; +import { authorAggregation, favoriteAggregation } from './offer.aggregation.js'; import { OfferEntity } from '../../entities/index.js'; +import { City } from '../../types/city.enum.js'; // import { CommentService } from '../comment/index.js'; // import { UserService } from '../user/user-service.interface.js'; @@ -30,6 +31,7 @@ export class DefaultOfferService implements OfferService { return this.offerModel .aggregate([ ...authorAggregation, + // ...favoriteAggregation(userId), { $limit: limit }, { $sort: { createdAt: SortType.DESC }} ]) @@ -59,7 +61,6 @@ export class DefaultOfferService implements OfferService { } // TODO: Удалять вместо с предложением комментарии авторматически - // TODO: Закрыть от неавторизированных пользователей public async deleteById(offerId: string): Promise | null> { return this.offerModel .findByIdAndDelete(offerId) @@ -80,11 +81,16 @@ export class DefaultOfferService implements OfferService { // .populate(['author']) } - // TODO: Проверить метод - public async findByPremium(): Promise[]> { - return this.offerModel - .find({ isPremium: true }, {}, { limit: DEFAULT_PREMIUM_OFFER_COUNT }) - .sort({ publicationDate: SortType.DESC }); + // TODO: isFavorite, comments + public async findByPremium(city: City): Promise[]> { + return this.offerModel.aggregate([ + { $match: { + city, + isPremium: true, + } }, + { $sort: { createdAt: SortType.DESC } }, + { $limit: MAX_PREMIUM_OFFER_COUNT }, + ]); } public async incCommentCount(offerId: string): Promise | null> { @@ -114,20 +120,18 @@ export class DefaultOfferService implements OfferService { public async findFavoritesByUserId(userId: string): Promise[]> { - console.log('userId', userId); return this.offerModel .aggregate([ ...authorAggregation, + ...favoriteAggregation(userId), + { $match: { isFavorite: true }} ]) .exec(); } - // console.log("user", user); - // const favoritesIds = user.favorites.map((item: Types.ObjectId) => ({ _id: item })); // const user = await this.userService.findById(userId); // const offers = user.favorites.map(() => await this.); - // console.log("user", user); // { $match: { 'author': new Types.ObjectId(userId) } }, // public async findNew(count: number): Promise[]> { diff --git a/src/shared/modules/offer/dto/create-offer.dto.ts b/src/shared/modules/offer/dto/create-offer.dto.ts index f9064f1..1b4c01c 100644 --- a/src/shared/modules/offer/dto/create-offer.dto.ts +++ b/src/shared/modules/offer/dto/create-offer.dto.ts @@ -1,4 +1,19 @@ -import { IsArray, IsBoolean, IsEnum, IsInt, IsObject, Max, MaxLength, Min, MinLength, ValidateNested } from 'class-validator'; +import { + ArrayMaxSize, + ArrayMinSize, + ArrayUnique, + IsArray, + IsBoolean, + IsEnum, + IsInt, + IsObject, + IsString, + Max, + MaxLength, + Min, + MinLength, + ValidateNested, +} from 'class-validator'; import { City, ConvenienceType, Coordinate, OfferType } from '../../../types/index.js'; import { Type } from 'class-transformer'; @@ -6,10 +21,12 @@ import { CoordinateDTO } from './coordinate.dto.js'; import { OFFER_DTO_CONSTRAINTS } from '../offer.constant.js'; export class CreateOfferDto { + @IsString() @MinLength(OFFER_DTO_CONSTRAINTS.TITLE.MIN_LENGTH) @MaxLength(OFFER_DTO_CONSTRAINTS.TITLE.MAX_LENGTH) public title!: string; + @IsString() @MinLength(OFFER_DTO_CONSTRAINTS.DESCRIPTION.MIN_LENGTH) @MaxLength(OFFER_DTO_CONSTRAINTS.DESCRIPTION.MAX_LENGTH) public description!: string; @@ -17,9 +34,12 @@ export class CreateOfferDto { @IsEnum(City) public city!: City; + @IsString() public previewImg!: string; - // TODO: 6 фотографий всегда + @IsArray() + @ArrayMinSize(OFFER_DTO_CONSTRAINTS.IMAGE.MIN_LENGTH) + @ArrayMaxSize(OFFER_DTO_CONSTRAINTS.IMAGE.MAX_LENGTH) public images!: string[]; @IsBoolean() @@ -44,7 +64,7 @@ export class CreateOfferDto { public cost!: number; @IsArray() - // @IsEnum({ each: true, message: CREATE_OFFER_VALIDATION_MESSAGE.CONVENIENCES.invalid }) + @ArrayUnique() public conveniences!: ConvenienceType[]; @ValidateNested() @@ -52,6 +72,5 @@ export class CreateOfferDto { @Type(() => CoordinateDTO) public coordinate!: Coordinate; - // @IsMongoId({ message: CREATE_OFFER_VALIDATION_MESSAGE.AUTHOR.invalidId }) public userId?: string; } diff --git a/src/shared/modules/offer/dto/update-offer.dto.ts b/src/shared/modules/offer/dto/update-offer.dto.ts index d201362..de9591c 100644 --- a/src/shared/modules/offer/dto/update-offer.dto.ts +++ b/src/shared/modules/offer/dto/update-offer.dto.ts @@ -1,4 +1,21 @@ -import { IsArray, IsEnum, IsInt, IsObject, IsOptional, Max, MaxLength, Min, MinLength, ValidateNested } from 'class-validator'; +import { + ArrayMaxSize, + ArrayMinSize, + ArrayUnique, + IsArray, + IsBoolean, + IsEnum, + IsInt, + IsObject, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, + ValidateNested, +} from 'class-validator'; + import { City, ConvenienceType, Coordinate, OfferType } from '../../../types/index.js'; import { Type } from 'class-transformer'; import { CoordinateDTO } from './coordinate.dto.js'; @@ -20,12 +37,17 @@ export class UpdateOfferDto { public city?: City; @IsOptional() + @IsString() public previewImg?: string; @IsOptional() + @IsArray() + @ArrayMinSize(OFFER_DTO_CONSTRAINTS.IMAGE.MIN_LENGTH) + @ArrayMaxSize(OFFER_DTO_CONSTRAINTS.IMAGE.MAX_LENGTH) public images?: string[]; @IsOptional() + @IsBoolean() public isPremium?: boolean; @IsOptional() @@ -51,8 +73,7 @@ export class UpdateOfferDto { public cost?: number; @IsOptional() - @IsArray() - // @IsEnum({ each: true, message: UPDATE_OFFER_VALIDATION_MESSAGE.CONVENIENCES.invalid }) + @ArrayUnique() public conveniences?: ConvenienceType[]; @IsOptional() diff --git a/src/shared/modules/offer/offer-service.interface.ts b/src/shared/modules/offer/offer-service.interface.ts index 88ead83..be3bf5d 100644 --- a/src/shared/modules/offer/offer-service.interface.ts +++ b/src/shared/modules/offer/offer-service.interface.ts @@ -3,7 +3,7 @@ import { DocumentType } from '@typegoose/typegoose'; import { CreateOfferDto } from './dto/create-offer.dto.js'; import { UpdateOfferDto } from './dto/update-offer.dto.js'; import { OfferEntity } from '../../entities/index.js'; -import { DocumentExists } from '../../types/index.js'; +import { City, DocumentExists } from '../../types/index.js'; export interface OfferService extends DocumentExists { find(count?: number): Promise[]>; @@ -11,7 +11,7 @@ export interface OfferService extends DocumentExists { findById(offerId: string): Promise | null>; deleteById(offerId: string): Promise | null>; updateById(offerId: string, dto: UpdateOfferDto): Promise | null>; - findByPremium(): Promise[]>; + findByPremium(city: City): Promise[]>; // TODO: икремент добавления количества комментария - нужен он? incCommentCount(offerId: string): Promise | null>; // -? как правильно типизировать diff --git a/src/shared/modules/offer/offer.aggregation.ts b/src/shared/modules/offer/offer.aggregation.ts index 44b8f3b..dd065ca 100644 --- a/src/shared/modules/offer/offer.aggregation.ts +++ b/src/shared/modules/offer/offer.aggregation.ts @@ -2,6 +2,24 @@ import { Types } from 'mongoose'; import { SortType } from '../../types/sort-type.enum.js'; import { DEFAULT_COMMENT_COUNT } from '../comment/comment.constant.js'; +export const favoriteAggregation = (userId: string, offerId: string = '') => ([ + { + $lookup: { + from: 'users', + pipeline: [ + { $match: { '_id': new Types.ObjectId(userId) } }, + { $project: { favorites: 1 } } + ], + as: 'user' + }, + }, + { $unwind: '$user' }, + { $addFields: { isFavorite: { + $in: [offerId ? new Types.ObjectId(offerId) : '$_id' , '$user.favorites'] + } }}, + { $unset: 'user' } +]); + export const favoritesAggregation = (userId: string) => ([ { $lookup: { diff --git a/src/shared/modules/offer/offer.constant.ts b/src/shared/modules/offer/offer.constant.ts index 3a7d4fb..1f0c740 100644 --- a/src/shared/modules/offer/offer.constant.ts +++ b/src/shared/modules/offer/offer.constant.ts @@ -1,5 +1,5 @@ export const DEFAULT_OFFER_COUNT = 60; -export const DEFAULT_PREMIUM_OFFER_COUNT = 3; +export const MAX_PREMIUM_OFFER_COUNT = 3; export const MAX_OFFER_COUNT = 300; export const OFFER_DTO_CONSTRAINTS = { @@ -22,6 +22,10 @@ export const OFFER_DTO_CONSTRAINTS = { COST: { MIN_VALUE: 100, MAX_VALUE: 100000 + }, + IMAGE: { + MIN_LENGTH: 6, + MAX_LENGTH: 6 } } as const; diff --git a/src/shared/modules/offer/offer.controller.ts b/src/shared/modules/offer/offer.controller.ts index 6d3e887..c80ff28 100644 --- a/src/shared/modules/offer/offer.controller.ts +++ b/src/shared/modules/offer/offer.controller.ts @@ -1,7 +1,7 @@ import { inject, injectable } from 'inversify'; import { Request, Response } from 'express'; -import { BaseController, DocumentExistsMiddleware, HttpError, HttpMethod, RequestQuery, ValidateDtoMiddleware, ValidateObjectIdMiddleware } from '../../libs/rest/index.js'; +import { BaseController, DocumentExistsMiddleware, HttpError, HttpMethod, PrivateRouteMiddleware, RequestQuery, ValidateDtoMiddleware, ValidateObjectIdMiddleware } from '../../libs/rest/index.js'; import { Logger } from '../../libs/logger/index.js'; import { COMPONENT } from '../../constants/component.constant.js'; import { OfferService } from './offer-service.interface.js'; @@ -11,10 +11,13 @@ import { StatusCodes } from 'http-status-codes'; import { UpdateOfferDto } from './dto/update-offer.dto.js'; import { DEFAULT_OFFER_COUNT, MAX_OFFER_COUNT } from './offer.constant.js'; import { IdOfferRdo } from './rdo/id-offer.rdo.js'; -import { ParamOfferId } from './type/param-offerid.type.js'; -import { CreateOfferRequest } from './type/create-offer-request.type.js'; +import { CreateOfferRequest } from './types/create-offer-request.type.js'; import { CommentService } from '../comment/index.js'; import { CreateOfferDto } from './dto/create-offer.dto.js'; +import { ShortOfferRdo } from './rdo/short-offer.rdo.js'; +import { RequestPremiumQuery } from './types/request-premium-query.type.js'; +import { City } from '../../types/city.enum.js'; +import { ParamOfferId } from '../../types/index.js'; @injectable() export class OfferController extends BaseController { @@ -28,11 +31,12 @@ export class OfferController extends BaseController { this.logger.info('Register routes for OfferController'); this.addRoute({ path: '/', method: HttpMethod.Get, handler: this.index }); + this.addRoute({ path: '/premium', method: HttpMethod.Get, handler: this.findPremium }); this.addRoute({ path: '/', method: HttpMethod.Post, handler: this.create, - middlewares: [new ValidateDtoMiddleware(CreateOfferDto)] + middlewares: [new PrivateRouteMiddleware(), new ValidateDtoMiddleware(CreateOfferDto)] }); this.addRoute({ path: '/:offerId', @@ -42,8 +46,26 @@ export class OfferController extends BaseController { new ValidateObjectIdMiddleware('offerId'), new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId')] }); - this.addRoute({ path: '/:offerId', method: HttpMethod.Patch, handler: this.update, middlewares: [new ValidateObjectIdMiddleware('offerId'), new ValidateDtoMiddleware(UpdateOfferDto), new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId')] }); - this.addRoute({ path: '/:offerId', method: HttpMethod.Delete, handler: this.delete, middlewares: [new ValidateObjectIdMiddleware('offerId'), new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId')] }); + this.addRoute({ + path: '/:offerId', + method: HttpMethod.Patch, + handler: this.update, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateObjectIdMiddleware('offerId'), + new ValidateDtoMiddleware(UpdateOfferDto), + new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId') + ] + }); + this.addRoute({ + path: '/:offerId', + method: HttpMethod.Delete, + handler: this.delete, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateObjectIdMiddleware('offerId'), + new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId') + ] }); } public async index({ query } : Request, res: Response): Promise { @@ -51,9 +73,6 @@ export class OfferController extends BaseController { const limitNum = Number(query?.limit); const limit = query?.limit && !Number.isNaN(limitNum) && limitNum < MAX_OFFER_COUNT ? limitNum : DEFAULT_OFFER_COUNT; - // TODO: Временно сделать const userId = ''; - // const userId = req.user.id // AFTER JWT - if (!Number.isSafeInteger(limit)) { throw new HttpError( StatusCodes.BAD_REQUEST, @@ -64,37 +83,40 @@ export class OfferController extends BaseController { const offers = await this.offerService.find(limit); - const responseData = fillDTO(FullOfferRdo, offers); + const responseData = fillDTO(ShortOfferRdo, offers); this.ok(res, responseData); } - public async create( - { body }: CreateOfferRequest, - res: Response - ): Promise { - - // -? Как проверить что данные пользователя верные? + public async findPremium({ query }: Request, res: Response): Promise { + const city = query?.city; - // const existOffer = await this.offerService.findByCategoryName(body.name); + if (!city) { + throw new HttpError( + StatusCodes.BAD_REQUEST, + 'City in query is not correct.', + 'OfferController' + ); + } - // if (existCategory) { - // const existCategoryError = new Error(`Category with name «${body.name}» exists.`); - // throw new HttpError( - // StatusCodes.UNPROCESSABLE_ENTITY, - // `Category with name «${body.name}» exists.`, - // 'CategoryController' - // ); - // this.send(res, - // StatusCodes.UNPROCESSABLE_ENTITY, - // { error: existCategoryError.message } - // ); + if (!(city in City)) { + throw new HttpError( + StatusCodes.BAD_REQUEST, + 'City in query not included in the list of available cities.', + 'OfferController' + ); + } + const offers = await this.offerService.findByPremium(city); - // return this.logger.error(existCategoryError.message, existCategoryError); - // } + const responseData = fillDTO(ShortOfferRdo, offers); + this.ok(res, responseData); + } - // TODO: доработать валидацию на 400-ую ошибку + public async create( + { 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(FullOfferRdo, offer)); } diff --git a/src/shared/modules/offer/offer.http b/src/shared/modules/offer/offer.http index cdcf795..0bed17e 100644 --- a/src/shared/modules/offer/offer.http +++ b/src/shared/modules/offer/offer.http @@ -11,9 +11,16 @@ Content-Type: application/json ### +## Получить список премиальных предложений +GET http://localhost:6000/offers/premium?city=Paris HTTP/1.1 +Content-Type: application/json + +### + ## Добавить новое предложение POST http://localhost:6000/offers HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6ImlseWEua29sbWFrb3YuaUBtYWlsLnJ1IiwidXNlck5hbWUiOiJJbHlhSyIsImlkIjoiNjcyODc5YzZhZmNiZmFmOGI3YzQxM2FiIiwiaWF0IjoxNzMwNzA2Mjk0LCJleHAiOjE3MzA4NzkwOTR9.HryFJPl7jJPSn1iRFLlUBLxO8nh6u8RT5kwXwRMwhVU { "title": "Pullman Paris Tour Eiffel", @@ -25,7 +32,7 @@ Content-Type: application/json "flatCount": 1, "guestCount": 4, "cost": 4000, - "isPremium": false, + "isPremium": true, "conveniences": ["Washer", "Towels", "Fridge"], "userId": "67152f430ace5d6726f44745", "coordinate": { @@ -44,8 +51,9 @@ Content-Type: application/json ### ## Редактирование предложения -PATCH http://localhost:6000/offers/6727b44804d5909bd0be67d9 HTTP/1.1 +PATCH http://localhost:6000/offers/6728a3badfee5f482348a4eb HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6ImlseWEua29sbWFrb3YuaUBtYWlsLnJ1IiwidXNlck5hbWUiOiJJbHlhSyIsImlkIjoiNjcyODc5YzZhZmNiZmFmOGI3YzQxM2FiIiwiaWF0IjoxNzMwNzA2Mjk0LCJleHAiOjE3MzA4NzkwOTR9.HryFJPl7jJPSn1iRFLlUBLxO8nh6u8RT5kwXwRMwhVU { "title": "Cologne Country Lodge" @@ -56,5 +64,6 @@ Content-Type: application/json ## Удаление предложения DELETE http://localhost:6000/offers/6727868448704157cb5fc46e HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6ImlseWEua29sbWFrb3YuaUBtYWlsLnJ1IiwidXNlck5hbWUiOiJJbHlhSyIsImlkIjoiNjcyODc5YzZhZmNiZmFmOGI3YzQxM2FiIiwiaWF0IjoxNzMwNzA2Mjk0LCJleHAiOjE3MzA4NzkwOTR9.HryFJPl7jJPSn1iRFLlUBLxO8nh6u8RT5kwXwRMwhVU ### \ No newline at end of file diff --git a/src/shared/modules/offer/rdo/short-offer.rdo.ts b/src/shared/modules/offer/rdo/short-offer.rdo.ts index 77bb42e..957a18e 100644 --- a/src/shared/modules/offer/rdo/short-offer.rdo.ts +++ b/src/shared/modules/offer/rdo/short-offer.rdo.ts @@ -9,7 +9,7 @@ export class ShortOfferRdo { @Expose() public title!: string; - @Expose() + @Expose({ name: 'createdAt' }) public publicationDate!: Date; @Expose() diff --git a/src/shared/modules/offer/type/create-offer-request.type.ts b/src/shared/modules/offer/types/create-offer-request.type.ts similarity index 100% rename from src/shared/modules/offer/type/create-offer-request.type.ts rename to src/shared/modules/offer/types/create-offer-request.type.ts diff --git a/src/shared/modules/offer/types/request-premium-query.type.ts b/src/shared/modules/offer/types/request-premium-query.type.ts new file mode 100644 index 0000000..a514a7e --- /dev/null +++ b/src/shared/modules/offer/types/request-premium-query.type.ts @@ -0,0 +1,5 @@ +import { City } from '../../../types/city.enum.js'; + +export type RequestPremiumQuery = { + city?: City +}; diff --git a/src/shared/modules/user/create-user-request.type.ts b/src/shared/modules/user/create-user-request.type.ts deleted file mode 100644 index 69ef7e0..0000000 --- a/src/shared/modules/user/create-user-request.type.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Request } from 'express'; - -import { RequestBody, RequestParams } from '../../libs/rest/index.js'; -import { CreateUserDto } from './dto/create-user.dto.js'; - -export type CreateUserRequest = Request; diff --git a/src/shared/modules/user/dto/add-favorite.dto.ts b/src/shared/modules/user/dto/add-favorite.dto.ts new file mode 100644 index 0000000..0ad6811 --- /dev/null +++ b/src/shared/modules/user/dto/add-favorite.dto.ts @@ -0,0 +1,7 @@ +import { IsMongoId } from 'class-validator'; + +export class AddFavoriteDto { + + @IsMongoId() + public offerId!: string; +} diff --git a/src/shared/modules/user/index.ts b/src/shared/modules/user/index.ts index 29ca734..7c4eb39 100644 --- a/src/shared/modules/user/index.ts +++ b/src/shared/modules/user/index.ts @@ -2,3 +2,4 @@ export { CreateUserDto } from './dto/create-user.dto.js'; export { createUserContainer } from './user.container.js'; export { UserService } from './user-service.interface.js'; export { UserController } from './user.controller.js'; +export { LoginUserDto } from './dto/login-user.dto.js'; diff --git a/src/shared/modules/user/login-user-request.type.ts b/src/shared/modules/user/login-user-request.type.ts deleted file mode 100644 index bd76b18..0000000 --- a/src/shared/modules/user/login-user-request.type.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Request } from 'express'; - -import { RequestBody, RequestParams } from '../../libs/rest/index.js'; -import { LoginUserDto } from './dto/login-user.dto.js'; - -export type LoginUserRequest = Request; 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..fea3a78 --- /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/types/add-favorite-request.type.ts b/src/shared/modules/user/types/add-favorite-request.type.ts new file mode 100644 index 0000000..e15973a --- /dev/null +++ b/src/shared/modules/user/types/add-favorite-request.type.ts @@ -0,0 +1,5 @@ +import { Request } from 'express'; +import { RequestBody, RequestParams } from '../../../libs/rest/index.js'; +import { AddFavoriteDto } from '../dto/add-favorite.dto.js'; + +export type AddFavoriteRequest = Request; diff --git a/src/shared/modules/user/types/create-user-request.type.ts b/src/shared/modules/user/types/create-user-request.type.ts new file mode 100644 index 0000000..73448c1 --- /dev/null +++ b/src/shared/modules/user/types/create-user-request.type.ts @@ -0,0 +1,5 @@ +import { Request } from 'express'; +import { RequestBody, RequestParams } from '../../../libs/rest/index.js'; +import { CreateUserDto } from '../dto/create-user.dto.js'; + +export type CreateUserRequest = Request; diff --git a/src/shared/modules/user/types/login-user-request.type.ts b/src/shared/modules/user/types/login-user-request.type.ts new file mode 100644 index 0000000..bc0d172 --- /dev/null +++ b/src/shared/modules/user/types/login-user-request.type.ts @@ -0,0 +1,5 @@ +import { Request } from 'express'; +import { RequestBody, RequestParams } from '../../../libs/rest/index.js'; +import { LoginUserDto } from '../dto/login-user.dto.js'; + +export type LoginUserRequest = Request; diff --git a/src/shared/modules/user/user.controller.ts b/src/shared/modules/user/user.controller.ts index f80a579..99d683c 100644 --- a/src/shared/modules/user/user.controller.ts +++ b/src/shared/modules/user/user.controller.ts @@ -1,19 +1,23 @@ import { inject, injectable } from 'inversify'; import { Request, Response } from 'express'; -import { BaseController, DocumentBodyExistsMiddleware, DocumentExistsMiddleware, HttpError, HttpMethod, UploadFileMiddleware, ValidateDtoMiddleware, ValidateObjectIdBodyMiddleware, ValidateObjectIdMiddleware } from '../../libs/rest/index.js'; +import { BaseController, DocumentBodyExistsMiddleware, DocumentExistsMiddleware, HttpError, HttpMethod, PrivateRouteMiddleware, UploadFileMiddleware, ValidateDtoMiddleware, ValidateObjectIdBodyMiddleware, ValidateObjectIdMiddleware } from '../../libs/rest/index.js'; import { Logger } from '../../libs/logger/index.js'; -import { CreateUserRequest } from './create-user-request.type.js'; +import { CreateUserRequest } from './types/create-user-request.type.js'; import { COMPONENT } from '../../constants/index.js'; import { UserService } from './user-service.interface.js'; import { Config, IRestSchema } from '../../libs/config/index.js'; import { StatusCodes } from 'http-status-codes'; import { fillDTO } from '../../helpers/index.js'; import { UserRdo } from './rdo/user.rdo.js'; -import { LoginUserRequest } from './login-user-request.type.js'; +import { LoginUserRequest } from './types/login-user-request.type.js'; import { CreateUserDto } from './dto/create-user.dto.js'; import { LoginUserDto } from './dto/login-user.dto.js'; import { OfferService, ShortOfferRdo } from '../offer/index.js'; +import { AuthService } from '../auth/index.js'; +import { LoggedUserRdo } from './rdo/logged-user.rdo.js'; +import { ParamOfferId } from '../../types/index.js'; +import { AddFavoriteRequest } from './types/add-favorite-request.type.js'; @injectable() export class UserController extends BaseController { @@ -22,19 +26,26 @@ export class UserController extends BaseController { @inject(COMPONENT.USER_SERVICE) private readonly userService: UserService, @inject(COMPONENT.OFFER_SERVICE) private readonly offerService: OfferService, @inject(COMPONENT.CONFIG) private readonly configService: Config, + @inject(COMPONENT.AUTH_SERVICE) private readonly authService: AuthService, ) { super(logger); this.logger.info('Register routes for UserController'); this.addRoute({ path: '/register', method: HttpMethod.Post, handler: this.create, middlewares: [new ValidateDtoMiddleware(CreateUserDto)] }); this.addRoute({ path: '/login', method: HttpMethod.Post, handler: this.login, middlewares: [new ValidateDtoMiddleware(LoginUserDto)] }); - this.addRoute({ path: '/login', method: HttpMethod.Get, handler: this.showStatus }); - this.addRoute({ path: '/favorites', method: HttpMethod.Get, handler: this.showUserFavorites, middlewares: [] }); + this.addRoute({ path: '/login', method: HttpMethod.Get, handler: this.checkAuthenticate }); + this.addRoute({ + path: '/favorites', + method: HttpMethod.Get, + handler: this.findFavoritesForUser, + middlewares: [new PrivateRouteMiddleware()] + }); this.addRoute({ path: '/favorites', method: HttpMethod.Post, handler: this.addFavoriteForUser, middlewares: [ + new PrivateRouteMiddleware(), new ValidateObjectIdBodyMiddleware('offerId'), new DocumentBodyExistsMiddleware(this.offerService, 'Offer', 'offerId') ] @@ -45,6 +56,7 @@ export class UserController extends BaseController { method: HttpMethod.Delete, handler: this.deleteFavoriteForUser, middlewares: [ + new PrivateRouteMiddleware(), new ValidateObjectIdMiddleware('offerId'), new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId') ] @@ -60,13 +72,18 @@ export class UserController extends BaseController { }); } - // -? как идет проверка на статус, вошел ли пользователь в систему - public async showStatus(): Promise { - throw new HttpError( - StatusCodes.NOT_IMPLEMENTED, - 'Not implemented', - 'UserController', - ); + public async checkAuthenticate({ tokenPayload: { email }}: Request, res: Response) { + const foundedUser = await this.userService.findByEmail(email); + + if (!foundedUser) { + throw new HttpError( + StatusCodes.UNAUTHORIZED, + 'Unauthorized', + 'UserController' + ); + } + + this.ok(res, fillDTO(LoggedUserRdo, foundedUser)); } public async create( @@ -89,88 +106,56 @@ export class UserController extends BaseController { public async login( { body }: LoginUserRequest, - _: Response, + 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', - ); + 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); } - // TODO: Закрыть от неавторизированных пользователей - public async showUserFavorites(_: Request, res: Response) { - // TODO: Токен берется из токена авторизации - const mockUserId = '67152f430ace5d6726f44745'; + public async findFavoritesForUser({ tokenPayload }: Request, res: Response) { + const userId = tokenPayload?.id; - const currentUser = await this.userService.findById(mockUserId); + const currentUser = await this.userService.findById(userId); if (!currentUser) { throw new HttpError( StatusCodes.NOT_FOUND, - `User with id ${mockUserId} not found.`, + `User with id ${userId} not found.`, 'UserController', ); } - const offers = await this.userService.findFavoritesForUser(mockUserId); + const offers = await this.userService.findFavoritesForUser(userId); this.ok(res, fillDTO(ShortOfferRdo, offers)); } + public async addFavoriteForUser({ body, tokenPayload }: AddFavoriteRequest, res: Response) { + const offerId = body?.offerId; + const userId = tokenPayload?.id; - // TODO: Можно написать DTO для params - // TODO: Закрыть от неавторизированных пользователей - public async addFavoriteForUser(req: Request, res: Response) { - const { offerId } = req.body; - - // TODO: Не можем добавлять одни и те же офферы в избранное - // if (favorites.map((item) => item._id.toString()).includes(params.offerId)) { - // throw new HttpError( - // StatusCodes.CONFLICT, - // `Offer ${params.offerId} is already in favorites`, - // 'UserController', - // ); - // } - - const mockUserId = '67152f430ace5d6726f44745'; + const favoriteList = await this.userService.findFavoritesForUser(userId); - if (!offerId) { + if (favoriteList.map((item) => item._id.toString()).includes(offerId)) { throw new HttpError( - StatusCodes.BAD_REQUEST, - 'OfferId is required field', - 'UserController', - ); - } - - const existsUser = await this.userService.findById(mockUserId); - - if (!existsUser) { - throw new HttpError( - StatusCodes.NOT_FOUND, - `User with id ${mockUserId} not found.`, + StatusCodes.CONFLICT, + `Offer ${offerId} is already in favorites`, 'UserController', ); } - const updatedUser = await this.userService.addFavorite(mockUserId, offerId); + const updatedUser = await this.userService.addFavorite(userId, offerId); this.noContent(res, updatedUser); } - // TODO: Закрыть от неавторизированных пользователей - public async deleteFavoriteForUser(req: Request, res: Response) { - const { offerId } = req.params; + public async deleteFavoriteForUser({ params, tokenPayload }: Request, res: Response) { + const { offerId } = params; - const mockUserId = '67152f430ace5d6726f44745'; + const userId = tokenPayload.id; if (!offerId) { throw new HttpError( @@ -180,17 +165,17 @@ export class UserController extends BaseController { ); } - const existsUser = await this.userService.findById(mockUserId); + const existsUser = await this.userService.findById(userId); if (!existsUser) { throw new HttpError( StatusCodes.NOT_FOUND, - `User with id ${mockUserId} not found.`, + `User with id ${userId} not found.`, 'UserController', ); } - const updatedUser = await this.userService.deleteFavorite(mockUserId, offerId); + const updatedUser = await this.userService.deleteFavorite(userId, offerId); this.noContent(res, updatedUser); } diff --git a/src/shared/modules/user/user.http b/src/shared/modules/user/user.http index 68a0448..35c98b1 100644 --- a/src/shared/modules/user/user.http +++ b/src/shared/modules/user/user.http @@ -5,10 +5,10 @@ POST http://localhost:6000/users/register HTTP/1.1 Content-Type: application/json { - "email": "ilkolmakov@mail.ru", - "avatarPath": "torrance.png", + "email": "ilya.kolmakov.i@mail.ru", + "avatarPath": "/Users/ilakolmakov/Downloads/user1.png", "userName": "IlyaK", - "password": "test123", + "password": "qw12345678", "type": "pro" } @@ -20,7 +20,7 @@ POST http://localhost:6000/users/login HTTP/1.1 Content-Type: application/json { - "email": "ilkolmakov@yandex.ru", + "email": "ilya.kolmakov.i@mail.ru", "password": "qw12345678" } @@ -30,6 +30,7 @@ Content-Type: application/json GET http://localhost:6000/users/favorites HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6ImlseWEua29sbWFrb3YuaUBtYWlsLnJ1IiwidXNlck5hbWUiOiJJbHlhSyIsImlkIjoiNjcyODc5YzZhZmNiZmFmOGI3YzQxM2FiIiwiaWF0IjoxNzMwNzA2Mjk0LCJleHAiOjE3MzA4NzkwOTR9.HryFJPl7jJPSn1iRFLlUBLxO8nh6u8RT5kwXwRMwhVU ### @@ -37,9 +38,10 @@ Content-Type: application/json POST http://localhost:6000/users/favorites HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6ImlseWEua29sbWFrb3YuaUBtYWlsLnJ1IiwidXNlck5hbWUiOiJJbHlhSyIsImlkIjoiNjcyODc5YzZhZmNiZmFmOGI3YzQxM2FiIiwiaWF0IjoxNzMwNzA2Mjk0LCJleHAiOjE3MzA4NzkwOTR9.HryFJPl7jJPSn1iRFLlUBLxO8nh6u8RT5kwXwRMwhVU { - "offerId": "67152f430ace5d6726f4474d" + "offerId": "6727abe4326ef99e212e42f5" } ### @@ -48,19 +50,26 @@ Content-Type: application/json DELETE http://localhost:6000/users/favorites/67152f430ace5d6726f44747 HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6ImlseWEua29sbWFrb3YuaUBtYWlsLnJ1IiwidXNlck5hbWUiOiJJbHlhSyIsImlkIjoiNjcyODc5YzZhZmNiZmFmOGI3YzQxM2FiIiwiaWF0IjoxNzMwNzA2Mjk0LCJleHAiOjE3MzA4NzkwOTR9.HryFJPl7jJPSn1iRFLlUBLxO8nh6u8RT5kwXwRMwhVU ### -## Отправить изображение +## Отправить изображение // -? Закрыть от неавторизированных пользователей POST http://localhost:6000/users/62823cb2c5a64ce9f1b50eb6/avatar HTTP/1.1 Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW ------WebKitFormBoundary7MA4YWxkTrZu0gW -Content-Disposition: form-data; name="avatar"; filename="screen.png" +Content-Disposition: form-data; name="avatar"; filename="user1.png" Content-Type: image/png -< /Users/spider_net/Desktop/screen.png +< /Users/ilakolmakov/Downloads/user1.png ------WebKitFormBoundary7MA4YWxkTrZu0gW-- +### + +## Проверить токен пользователя +GET http://localhost:6000/users/login HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6ImlseWEua29sbWFrb3YuaUBtYWlsLnJ1IiwidXNlck5hbWUiOiJJbHlhSyIsImlkIjoiNjcyODc5YzZhZmNiZmFmOGI3YzQxM2FiIiwiaWF0IjoxNzMwNzA2Mjk0LCJleHAiOjE3MzA4NzkwOTR9.HryFJPl7jJPSn1iRFLlUBLxO8nh6u8RT5kwXwRMwhVU + ### \ No newline at end of file diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index b53a315..bce55c0 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -9,3 +9,4 @@ export { City } from './city.enum.js'; export { DocumentExists } from './document-exists.interface.js'; export { MockServerData } from './mock-server-data.interface.js'; +export { ParamOfferId } from './param-offerid.type.js'; diff --git a/src/shared/modules/offer/type/param-offerid.type.ts b/src/shared/types/param-offerid.type.ts similarity index 100% rename from src/shared/modules/offer/type/param-offerid.type.ts rename to src/shared/types/param-offerid.type.ts diff --git a/tsconfig.json b/tsconfig.json index d4b0d7d..b6453e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,7 @@ ], "lib": [ "ESNext" - ] + ], }, "include": [ "src/**/*.ts" @@ -35,5 +35,9 @@ "exclude": [ "node_modules", "**/*.test.ts" - ] + ], + "ts-node": { + "esm": true + }, + "files": ["./custom.d.ts"] }