diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ce6a3bb --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +PORT=5000 +SALT=secret +DB_USER = test +DB_PASSWORD = test +DB_HOST=127.0.0.2 +DB_PORT=27017 +DB_NAME=test_db +JWT_SECRET = 'secret key' +UPLOAD_FILE_DIRECTORY='\src\uploads' +HOST='localhost' +STATIC_DIRECTORY_PATH = '\src\static' \ No newline at end of file diff --git a/Workflow.md b/Workflow.md index 8b87981..1968850 100644 --- a/Workflow.md +++ b/Workflow.md @@ -80,6 +80,20 @@ npm start Советы и инструкции по внесению изменений в учебный репозиторий. +### Переменные окружения +Проект использует переменные окружения для конфигурации. Создайте файл `.env` в корне проекта на основе `.env.example`. + +`PORT` -- Порт HTTP-сервера. По умолчанию - `3000` +`SALT` -- Соль для хеширования паролей +`DB_USER` -- Пользователь для подключения к БД +`DB_PASSWORD` -- Пароль пользователя БД +`DB_HOST` -- Адрес сервера базы данных +`DB_PORT` -- Порт базы данных +`DB_NAME` -- Имя базы данных +`JWT_SECRET` -- Секретный ключ для подписи JWT +`UPLOAD_FILE_DIRECTORY` - Путь для загрузки файлов пользователями +`HOST` -- Хост сервера +`STATIC_DIRECTORY_PATH` -- Путь к директории со статикой ### Остальное Все остальные файлы в проекте являются служебными. Пожалуйста, не удаляйте и не изменяйте их самовольно. Только если того требует задание или наставник. diff --git a/custom.d.ts b/custom.d.ts index c4b240b..8e42106 100644 --- a/custom.d.ts +++ b/custom.d.ts @@ -1,4 +1,4 @@ -import { TokenPayload } from "./src/shared/modules/auth/types/TokenPayload.js"; +import { TokenPayload } from "./src/shared/modules/auth/types/token-payload.ts"; declare module "express-serve-static-core" { export interface Request { diff --git a/docker-compose.yaml b/docker-compose.yaml index 12cd7c6..f119e27 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,29 +1,36 @@ version: '3.9' + services: db: image: mongo:4.2 restart: always container_name: six-cities-mongodb environment: - MONGO_INITDB_ROOT_USERNAME: admin - MONGO_INITDB_ROOT_PASSWORD: test + MONGO_INITDB_ROOT_USERNAME: ${DB_USER:-admin} + MONGO_INITDB_ROOT_PASSWORD: ${DB_PASSWORD:-test} ports: - - 27017:27017 + - "${DB_PORT:-27017}:27017" volumes: - six-cities_data:/data/db + networks: + - app-network db_ui: image: mongo-express restart: always container_name: six-cities-mongo-express ports: - - 8081:8081 + - "${MONGO_EXPRESS_PORT:-8081}:8081" environment: - ME_CONFIG_BASICAUTH_USERNAME: admin - ME_CONFIG_BASICAUTH_PASSWORD: test - ME_CONFIG_MONGODB_ADMINUSERNAME: admin - ME_CONFIG_MONGODB_ADMINPASSWORD: test - ME_CONFIG_MONGODB_URL: mongodb://admin:test@db:27017/ + ME_CONFIG_BASICAUTH_USERNAME: ${ME_USER:-admin} + ME_CONFIG_BASICAUTH_PASSWORD: ${ME_PASSWORD:-test} + ME_CONFIG_MONGODB_ADMINUSERNAME: ${DB_USER:-admin} + ME_CONFIG_MONGODB_ADMINPASSWORD: ${DB_PASSWORD:-test} + ME_CONFIG_MONGODB_URL: mongodb://${DB_USER:-admin}:${DB_PASSWORD:-test}@db:27017/ + depends_on: + - db + networks: + - app-network volumes: six-cities_data: diff --git a/queries.http b/queries.http deleted file mode 100644 index 7b71334..0000000 --- a/queries.http +++ /dev/null @@ -1 +0,0 @@ -GET http://localhost:3123/api HTTP/1.1 \ No newline at end of file diff --git a/src/specification/specification.yml b/specification/specification.yml similarity index 100% rename from src/specification/specification.yml rename to specification/specification.yml diff --git a/src/cli.ts b/src/cli.ts index 2c32b67..012f2ba 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ import 'reflect-metadata'; import { CliApplication } from './cli/cli-application.js'; -import { Command } from './cli/command.interfaсe.js'; +import { Command } from './cli/command.interface.js'; import { GenerateCommand } from './cli/generate.command.js'; import { HelpCommand } from './cli/help.command.js'; import { ImportCommand } from './cli/import.command.js'; diff --git a/src/cli/cli-application.ts b/src/cli/cli-application.ts index 9c15570..ba44ebf 100644 --- a/src/cli/cli-application.ts +++ b/src/cli/cli-application.ts @@ -1,5 +1,5 @@ import { CommandParser } from './command-parser.js'; -import { Command } from './command.interfaсe.js'; +import { Command } from './command.interface.js'; type CollectionCommand = Record; diff --git "a/src/cli/command.interfa\321\201e.ts" b/src/cli/command.interface.ts similarity index 100% rename from "src/cli/command.interfa\321\201e.ts" rename to src/cli/command.interface.ts diff --git a/src/cli/generate.command.ts b/src/cli/generate.command.ts index 3ac1050..4c20e5e 100644 --- a/src/cli/generate.command.ts +++ b/src/cli/generate.command.ts @@ -1,6 +1,6 @@ import got from 'got'; import { MockServerData } from '../shared/types/mock-server.js'; -import { Command } from './command.interfaсe.js'; +import { Command } from './command.interface.js'; import { appendFile } from 'node:fs/promises'; import { TSVOfferGenerator } from '../shared/libs/offer-generator/offer-generator.js'; @@ -29,7 +29,6 @@ export class GenerateCommand implements Command { } async execute(...parameters: string[]): Promise { - console.log(parameters); const [count, path, url] = parameters; const offerCount = parseInt(count, 10); diff --git a/src/cli/help.command.ts b/src/cli/help.command.ts index a859e9b..e975289 100644 --- a/src/cli/help.command.ts +++ b/src/cli/help.command.ts @@ -1,4 +1,4 @@ -import { Command } from './command.interfaсe.js'; +import { Command } from './command.interface.js'; export class HelpCommand implements Command { getName(): string { diff --git a/src/cli/import.command.ts b/src/cli/import.command.ts index d9e54d3..348a647 100644 --- a/src/cli/import.command.ts +++ b/src/cli/import.command.ts @@ -11,11 +11,17 @@ import { ConsoleLogger } from '../shared/libs/logger/console.logger.js'; import { OfferService } from '../shared/modules/offer/offer-service.js'; import { UserService } from '../shared/modules/user/user-service.js'; import { Offer } from '../shared/types/offer.js'; -import { Command } from './command.interfaсe.js'; import { DEFAULT_DB_PORT, DEFAULT_USER_PASSWORD } from './const.cli.js'; -import { OfferEntity } from '../shared/modules/offer/offer.entity.js'; -import { UserEntity } from '../shared/modules/user/user.entity.js'; -import { CommentEntity } from '../shared/modules/comment/comment.entity.js'; +import { + OfferEntity, + OfferModel, +} from '../shared/modules/offer/offer.entity.js'; +import { UserEntity, UserModel } from '../shared/modules/user/user.entity.js'; +import { + CommentEntity, + CommentModel, +} from '../shared/modules/comment/comment.entity.js'; +import { Command } from './command.interface.js'; export class ImportCommand implements Command { private salt: string; @@ -29,17 +35,24 @@ export class ImportCommand implements Command { constructor() { this.logger = new ConsoleLogger(); + this.databaseClient = new MongoDatabaseClient(this.logger); + } + + public getName(): string { + return '--import'; + } + + private initModels() { + this.userModel = UserModel; + this.offerModel = OfferModel; + this.commentModel = CommentModel; + this.userService = new UserService(this.logger, this.userModel); this.offerService = new OfferService( this.logger, this.offerModel, this.commentModel, ); - this.databaseClient = new MongoDatabaseClient(this.logger); - } - - public getName(): string { - return '--import'; } private async onImportedLine(line: string, resolve: () => void) { @@ -48,17 +61,17 @@ export class ImportCommand implements Command { resolve(); } - private onCompletedImport(count: number) { - console.log(`Imported ${count} offers`); + private onCompletedImport() { this.databaseClient.disconnect(); } private async saveOffer(offer: Offer) { + const { id: _id, host, ...offerData } = offer; const user = await this.userService.register( - { ...offer.host, password: DEFAULT_USER_PASSWORD }, + { ...host, password: DEFAULT_USER_PASSWORD }, this.salt, ); - await this.offerService.createOffer({ ...offer, host: user.id }); + await this.offerService.createOffer({ ...offerData, host: user.id }); } public async execute( @@ -74,6 +87,8 @@ export class ImportCommand implements Command { await this.databaseClient.connect(dbURI); + this.initModels(); + const fileReader = new TSVFileReader(filename.trim()); fileReader.on('line', this.onImportedLine.bind(this)); diff --git a/src/cli/version.command.ts b/src/cli/version.command.ts index f6364b7..c4bcda9 100644 --- a/src/cli/version.command.ts +++ b/src/cli/version.command.ts @@ -1,4 +1,4 @@ -import { Command } from './command.interfaсe.js'; +import { Command } from './command.interface.js'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; @@ -6,12 +6,11 @@ type PackageJsonConfig = { version: string; }; -const isPackageJsonConfig = (value: unknown): value is PackageJsonConfig => ( +const isPackageJsonConfig = (value: unknown): value is PackageJsonConfig => typeof value === 'object' && - value !== null && - !Array.isArray(value) && - Object.hasOwn(value, 'version') -); + value !== null && + !Array.isArray(value) && + Object.hasOwn(value, 'version'); export class VersionCommand implements Command { constructor(private filePath: string = './package.json') {} diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/mock/mock.tsv b/src/mock/mock.tsv index 9acf1b7..e92201f 100644 --- a/src/mock/mock.tsv +++ b/src/mock/mock.tsv @@ -1,6 +1,5 @@ -id title description postDate city previewImage images isPremium isFavorite rating type bedrooms maxAdults price goods hostId hostName hostEmail hostAvatar hostPassword hostIsPro locationLat locationLng commentsCount -1 Cozy apartment Уютная квартира в центре с прекрасным видом на город 2026-01-20 Paris https://example.com/preview1.jpg https://example.com/img1.jpg|https://example.com/img2.jpg|https://example.com/img3.jpg|https://example.com/img4.jpg|https://example.com/img5.jpg|https://example.com/img6.jpg true false 4.7 apartment 2 4 5000 Breakfast|Air conditioning|Laptop friendly workspace 1 John Doe john@example.com https://example.com/avatar1.jpg john-password true 48.8566 2.3522 12 -2 Luxury villa Премиум вилла с бассейном и садом для больших компаний 2026-01-18 Amsterdam https://example.com/preview2.jpg https://example.com/img7.jpg|https://example.com/img8.jpg|https://example.com/img9.jpg|https://example.com/img10.jpg|https://example.com/img11.jpg|https://example.com/img12.jpg true true 4.9 house 4 8 25000 Breakfast|Washer|Towels|Fridge 2 Maria Schmidt maria@example.com https://example.com/avatar2.jpg maria-password false 52.3702 4.8952 8 -3 Student room Бюджетная комната для студентов рядом с университетом 2026-01-22 Cologne https://example.com/preview3.jpg https://example.com/img13.jpg|https://example.com/img14.jpg|https://example.com/img15.jpg|https://example.com/img16.jpg|https://example.com/img17.jpg|https://example.com/img18.jpg false false 3.8 room 1 2 1200 Laptop friendly workspace 3 Ahmed Ali ahmed@example.com https://example.com/avatar3.jpg ahmed-password true 50.9375 6.9603 5 -4 Hotel suite Современный номер в отеле с видом на канал 2026-01-15 Brussels https://example.com/preview4.jpg https://example.com/img19.jpg|https://example.com/img20.jpg|https://example.com/img21.jpg|https://example.com/img22.jpg|https://example.com/img23.jpg|https://example.com/img24.jpg false true 4.2 hotel 1 3 8000 Air conditioning|Baby seat|Towels 4 Emma Dubois emma@example.com https://example.com/avatar4.jpg emma-password true 50.8503 4.3517 23 -5 Family house Семейный дом в тихом районе с детской комнатой 2026-01-19 Hamburg https://example.com/preview5.jpg https://example.com/img25.jpg|https://example.com/img26.jpg|https://example.com/img27.jpg|https://example.com/img28.jpg|https://example.com/img29.jpg|https://example.com/img30.jpg true false 4.5 house 3 6 15000 Breakfast|Baby seat|Washer|Fridge 5 Sophie Müller sophie@example.com https://example.com/avatar5.jpg sophie-password false 53.5511 9.9937 17 \ No newline at end of file +1 Cozy apartment Уютная квартира в центре с прекрасным видом на город и удобной планировкой 2026-01-20 Paris https://example.com/preview1.jpg https://example.com/img1.jpg|https://example.com/img2.jpg|https://example.com/img3.jpg|https://example.com/img4.jpg|https://example.com/img5.jpg|https://example.com/img6.jpg true false 4.7 apartment 2 4 5000 Breakfast|Air conditioning|Laptop friendly workspace 1 John Doe john@example.com https://example.com/avatar1.jpg john12 true 48.8566 2.3522 12 +2 Luxury villa Премиум вилла с бассейном и садом для больших компаний и длительного отдыха 2026-01-18 Amsterdam https://example.com/preview2.jpg https://example.com/img7.jpg|https://example.com/img8.jpg|https://example.com/img9.jpg|https://example.com/img10.jpg|https://example.com/img11.jpg|https://example.com/img12.jpg true true 4.9 house 4 8 25000 Breakfast|Washer|Towels|Fridge 2 Maria Schmidt maria@example.com https://example.com/avatar2.jpg maria12 false 52.3702 4.8952 8 +3 Student room Бюджетная комната для студентов рядом с университетом и общественным транспортом 2026-01-22 Cologne https://example.com/preview3.jpg https://example.com/img13.jpg|https://example.com/img14.jpg|https://example.com/img15.jpg|https://example.com/img16.jpg|https://example.com/img17.jpg|https://example.com/img18.jpg false false 4.0 room 1 2 1200 Laptop friendly workspace 3 Ahmed Ali ahmed@example.com https://example.com/avatar3.jpg ahmed12 true 50.9375 6.9603 5 +4 Hotel suite Современный номер в отеле с видом на канал и завтраком по расписанию 2026-01-15 Brussels https://example.com/preview4.jpg https://example.com/img19.jpg|https://example.com/img20.jpg|https://example.com/img21.jpg|https://example.com/img22.jpg|https://example.com/img23.jpg|https://example.com/img24.jpg false true 4.2 hotel 1 3 8000 Air conditioning|Baby seat|Towels 4 Emma Dubois emma@example.com https://example.com/avatar4.jpg emma12 true 50.8503 4.3517 23 +5 Family house Семейный дом в тихом районе с детской комнатой и просторной кухней 2026-01-19 Hamburg https://example.com/preview5.jpg https://example.com/img25.jpg|https://example.com/img26.jpg|https://example.com/img27.jpg|https://example.com/img28.jpg|https://example.com/img29.jpg|https://example.com/img30.jpg true false 4.5 house 3 6 15000 Breakfast|Baby seat|Washer|Fridge 5 Sophie Müller sophie@example.com https://example.com/avatar5.jpg soph12 false 53.5511 9.9937 17 diff --git a/src/shared/helpers/hash.ts b/src/shared/helpers/hash.ts index 031fd7e..9173764 100644 --- a/src/shared/helpers/hash.ts +++ b/src/shared/helpers/hash.ts @@ -1,6 +1,6 @@ import * as crypto from 'node:crypto'; -export const createSha256 = (line: string, solt: string) => { - const shaHasher = crypto.createHmac('sha256', solt); +export const createSha256 = (line: string, salt: string) => { + const shaHasher = crypto.createHmac('sha256', salt); return shaHasher.update(line).digest('hex'); }; diff --git a/src/shared/helpers/offer.ts b/src/shared/helpers/offer.ts index fb433ff..9d99368 100644 --- a/src/shared/helpers/offer.ts +++ b/src/shared/helpers/offer.ts @@ -2,7 +2,7 @@ import { Amenity, City, HousingType, Offer } from '../types/offer.js'; export const createOffer = (offerData: string): Offer => { const [ - // id, + id, title, description, postDate, @@ -29,6 +29,7 @@ export const createOffer = (offerData: string): Offer => { ] = offerData.replace('\n', '').split('\t'); return { + id: id, title: title, description: description, date: new Date(postDate), diff --git a/src/shared/libs/logger/logger.ts b/src/shared/libs/logger/logger.ts index b24962a..748817a 100644 --- a/src/shared/libs/logger/logger.ts +++ b/src/shared/libs/logger/logger.ts @@ -4,14 +4,15 @@ import { Logger } from './logger.interface.js'; import { Logger as PinoInstance, pino, transport } from 'pino'; import { injectable } from 'inversify'; +const LOG_FILE_PATH = 'logs/rest.log'; + @injectable() export class PinoLogger implements Logger { private readonly logger: PinoInstance; constructor() { const modulePath = getCurrentModulePath(); - const logFilePath = 'logs/rest.log'; - const destination = resolve(modulePath,'../../../', logFilePath); + const destination = resolve(modulePath, '../../../', LOG_FILE_PATH); const multiTransport = transport({ targets: [ @@ -23,7 +24,7 @@ export class PinoLogger implements Logger { }, { target: 'pino-pretty', - } + }, ], }); @@ -31,18 +32,18 @@ export class PinoLogger implements Logger { } info(message: string, ...args: unknown[]): void { - this.logger.info({meta: args}, message); + this.logger.info({ meta: args }, message); } debug(message: string, ...args: unknown[]): void { - this.logger.debug({meta: args}, message); + this.logger.debug({ meta: args }, message); } warn(message: string, ...args: unknown[]): void { - this.logger.warn({meta: args}, message); + this.logger.warn({ meta: args }, message); } error(message: string, ...args: unknown[]): void { - this.logger.error({meta: args}, message); + this.logger.error({ meta: args }, message); } } diff --git a/src/shared/libs/rest/middleware/offer-owner.middleware.ts b/src/shared/libs/rest/middleware/offer-owner.middleware.ts new file mode 100644 index 0000000..5f412f6 --- /dev/null +++ b/src/shared/libs/rest/middleware/offer-owner.middleware.ts @@ -0,0 +1,52 @@ +import { Middleware } from './middleware.interface.js'; +import { OfferServiceInterface } from '../../../modules/offer/offer-service.interface.js'; +import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { HttpError } from '../errors/index.js'; + +export class IsOfferOwnerMiddleware implements Middleware { + constructor(private readonly service: OfferServiceInterface) {} + + async execute( + req: Request, + _res: Response, + next: NextFunction, + ): Promise { + const userId = req.tokenPayload.id; + const { offerId } = req.params; + + const offer = await this.service.findOfferById(offerId as string); + + if (!offer) { + return next( + new HttpError( + StatusCodes.NOT_FOUND, + 'Offer not found', + 'IsOfferOwnerMiddleware', + ), + ); + } + + if (!userId) { + return next( + new HttpError( + StatusCodes.UNAUTHORIZED, + 'Unauthorized', + 'IsOfferOwnerMiddleware', + ), + ); + } + + if (offer.host.toString() !== userId) { + return next( + new HttpError( + StatusCodes.FORBIDDEN, + 'You are not the owner of this offer', + 'IsOfferOwnerMiddleware', + ), + ); + } + + return next(); + } +} diff --git a/src/shared/libs/rest/middleware/parse-token.middleware.ts b/src/shared/libs/rest/middleware/parse-token.middleware.ts index e71f643..1026338 100644 --- a/src/shared/libs/rest/middleware/parse-token.middleware.ts +++ b/src/shared/libs/rest/middleware/parse-token.middleware.ts @@ -4,7 +4,7 @@ import crypto from 'node:crypto'; import { jwtVerify } from 'jose'; import { HttpError } from '../errors/index.js'; import { StatusCodes } from 'http-status-codes'; -import { TokenPayload } from '../../../modules/auth/types/TokenPayload.js'; +import { TokenPayload } from '../../../modules/auth/types/token-payload.js'; function isTokenPayload(payload: unknown): payload is TokenPayload { return ( @@ -27,17 +27,17 @@ export class ParseTokenMiddleware implements Middleware { _res: Response, next: NextFunction, ): Promise { - const authorizationHeader = req.headers?.authorization?.split(' '); + const authorizationHeaders = req.headers?.authorization?.split(' '); if ( - !authorizationHeader || - authorizationHeader?.[0] !== 'Bearer' || - !authorizationHeader?.[1] + !authorizationHeaders || + authorizationHeaders?.[0] !== 'Bearer' || + !authorizationHeaders?.[1] ) { return next(); } - const [, token] = authorizationHeader; + const [, token] = authorizationHeaders; try { const { payload } = await jwtVerify( token, diff --git a/src/shared/libs/transform/path-transformer.ts b/src/shared/libs/transform/path-transformer.ts index 2452170..2106aa6 100644 --- a/src/shared/libs/transform/path-transformer.ts +++ b/src/shared/libs/transform/path-transformer.ts @@ -4,7 +4,9 @@ import { Component } from '../../types/container.js'; import { Config, RestSchema } from '../config/index.js'; import { DEFAULT_STATIC_IMAGES, + STATIC_PATH, STATIC_RESOURCE_FIELDS, + UPLOAD_PATH, } from './transformer.const.js'; import { getFullServerPath } from '../../helpers/common.js'; @@ -43,13 +45,11 @@ export class PathTransformer { } if (this.isStaticProperty(key) && typeof value === 'string') { - const staticPath = '/static'; - const uploadPath = '/upload'; const serverHost = this.config.get('HOST'); const serverPort = this.config.get('PORT'); const rootPath = this.hasDefaultImage(value) - ? staticPath - : uploadPath; + ? STATIC_PATH + : UPLOAD_PATH; current[key] = `${getFullServerPath(serverHost, serverPort)}${rootPath}/${value}`; } diff --git a/src/shared/libs/transform/transformer.const.ts b/src/shared/libs/transform/transformer.const.ts index b113565..bcdaed5 100644 --- a/src/shared/libs/transform/transformer.const.ts +++ b/src/shared/libs/transform/transformer.const.ts @@ -1,3 +1,6 @@ export const DEFAULT_STATIC_IMAGES = ['default-avatar.jpg']; export const STATIC_RESOURCE_FIELDS = ['avatar', 'imagePreview', 'images']; + +export const STATIC_PATH = '/static'; +export const UPLOAD_PATH = '/upload'; diff --git a/src/shared/modules/auth/auth-service.ts b/src/shared/modules/auth/auth-service.ts index dae5c63..a5d9f51 100644 --- a/src/shared/modules/auth/auth-service.ts +++ b/src/shared/modules/auth/auth-service.ts @@ -6,7 +6,7 @@ import { Logger } from '../../libs/logger/index.js'; import { UserService } from '../user/user-service.js'; import { Config, RestSchema } from '../../libs/config/index.js'; import * as crypto from 'node:crypto'; -import { TokenPayload } from './types/TokenPayload.js'; +import { TokenPayload } from './types/token-payload.js'; import { SignJWT } from 'jose'; import { JWT_ALGORITHM, JWT_EXPIRED } from './auth.constant.js'; import { diff --git a/src/shared/modules/auth/types/TokenPayload.ts b/src/shared/modules/auth/types/token-payload.ts similarity index 100% rename from src/shared/modules/auth/types/TokenPayload.ts rename to src/shared/modules/auth/types/token-payload.ts diff --git a/src/shared/modules/offer/offer-controller.ts b/src/shared/modules/offer/offer-controller.ts index 174c26d..8a43e22 100644 --- a/src/shared/modules/offer/offer-controller.ts +++ b/src/shared/modules/offer/offer-controller.ts @@ -20,6 +20,7 @@ import { PrivateRouteMiddleware } from '../../libs/rest/middleware/private-route import { UserServiceInterface } from '../user/user-service.interface.js'; import { CommentRDO } from '../comment/rdo/comment-rdo.js'; import { MAX_DISPLAY_OFFERS_COUNT } from './offer.const.js'; +import { IsOfferOwnerMiddleware } from '../../libs/rest/middleware/offer-owner.middleware.js'; export class OfferController extends BaseController { constructor( @@ -49,6 +50,7 @@ export class OfferController extends BaseController { handler: this.update, middlewares: [ new PrivateRouteMiddleware(), + new IsOfferOwnerMiddleware(this.offerService), new ValidateObjectIdMiddleware('offerId'), new ValidateDTOMiddleware(UpdateOfferDto), new DocumentExistsMiddleware('offerId', 'offer', this.offerService), @@ -60,12 +62,13 @@ export class OfferController extends BaseController { handler: this.delete, middlewares: [ new PrivateRouteMiddleware(), + new IsOfferOwnerMiddleware(this.offerService), new ValidateObjectIdMiddleware('offerId'), new DocumentExistsMiddleware('offerId', 'offer', this.offerService), ], }); this.addRoute({ - path: '/premium', + path: '/premium/:city', method: HttpMethod.Get, handler: this.showPremiumOffers, }); @@ -135,8 +138,9 @@ export class OfferController extends BaseController { } public async update(req: Request, res: Response) { + const { offerId } = req.params; const updateOffer = await this.offerService.updateById( - req.params.id as string, + offerId as string, req.body, ); @@ -144,29 +148,32 @@ export class OfferController extends BaseController { } public async delete(req: Request, res: Response) { - const offer = await this.offerService.findOfferById( - req.params.id as string, - ); + const { offerId } = req.params; + const offer = await this.offerService.findOfferById(offerId as string); if (!offer) { - const error = new Error(`Offer with id ${req.params.id} not found`); + const error = new Error(`Offer with id ${offerId} not found`); this.send(res, StatusCodes.NOT_FOUND, { error: error.message }); return this.logger.error(error.message, error); } const result = await this.offerService.deleteById(offer.id); + await this.commentService.deleteByOfferId(offer.id); + this.noContent(res, fillDTO(OfferRDO, result)); } public async showPremiumOffers(req: Request, res: Response) { - const city = req.params.city as City; - const premiumOffers = await this.offerService.findPremiumOffersByCity(city); + const { city } = req.params; + const premiumOffers = await this.offerService.findPremiumOffersByCity( + city as City, + ); this.ok(res, fillDTO(OfferRDO, premiumOffers)); } public async getComments(req: Request, res: Response) { - const offerId = req.params.offerId; + const { offerId } = req.params; const comments = await this.commentService.findByOfferId(offerId as string); diff --git a/src/shared/modules/offer/offer-service.ts b/src/shared/modules/offer/offer-service.ts index 6fda718..e6d2a63 100644 --- a/src/shared/modules/offer/offer-service.ts +++ b/src/shared/modules/offer/offer-service.ts @@ -75,7 +75,6 @@ export class OfferService implements OfferServiceInterface, DocumentExists { .find({ city, isPremium: true }) .sort({ date: -1 }) .limit(DISPLAY_PREMIUM_OFFERS_COUNT) - .populate('host') .exec(); } diff --git a/src/shared/modules/offer/offer.entity.ts b/src/shared/modules/offer/offer.entity.ts index c397e12..dad1dc0 100644 --- a/src/shared/modules/offer/offer.entity.ts +++ b/src/shared/modules/offer/offer.entity.ts @@ -89,7 +89,7 @@ export class OfferEntity extends defaultClasses.TimeStamps { 'Baby seat', 'Washer', 'Towels', - 'Towels', + 'Fridge', ], }) public goods!: Amenity[]; diff --git a/src/shared/modules/offer/rdo/offer.rdo.ts b/src/shared/modules/offer/rdo/offer.rdo.ts index a5e99a1..be83267 100644 --- a/src/shared/modules/offer/rdo/offer.rdo.ts +++ b/src/shared/modules/offer/rdo/offer.rdo.ts @@ -31,24 +31,9 @@ export class OfferRDO { @Expose() type: string; - @Expose() - bedrooms: number; - - @Expose() - maxAdults: number; - @Expose() price: number; - @Expose() - goods: string[]; - - @Expose() - host: string; - @Expose() commentsCount: number; - - @Expose() - location: { latitude: number; longitude: number }; } diff --git a/src/shared/modules/user/user-controller.ts b/src/shared/modules/user/user-controller.ts index febc222..aaee936 100644 --- a/src/shared/modules/user/user-controller.ts +++ b/src/shared/modules/user/user-controller.ts @@ -19,10 +19,13 @@ import { CreateUserDto, LoginUserDTO } from './dto/user-dto.js'; import { AuthServiceInterface, UserNotFoundException } from '../auth/index.js'; import { PrivateRouteMiddleware } from '../../libs/rest/middleware/private-route.middleware.js'; import { UploadFileMiddleware } from '../../libs/rest/middleware/upload-file.middleware.js'; +import { OfferServiceInterface } from '../offer/offer-service.interface.js'; export class UserController extends BaseController { constructor( @inject(Component.Logger) protected readonly logger: Logger, + @inject(Component.OfferService) + private readonly offerService: OfferServiceInterface, @inject(Component.UserService) private readonly userService: UserServiceInterface, @inject(Component.Config) private readonly config: Config, @@ -69,6 +72,20 @@ export class UserController extends BaseController { handler: this.checkAuthenticate, middlewares: [new PrivateRouteMiddleware()], }); + + this.addRoute({ + path: '/:offerId/favorite', + method: HttpMethod.Post, + handler: this.addFavoriteOffers, + middlewares: [new PrivateRouteMiddleware()], + }); + + this.addRoute({ + path: '/:offerId/favorite', + method: HttpMethod.Delete, + handler: this.deleteFavoriteOffers, + middlewares: [new PrivateRouteMiddleware()], + }); } public async create(req: Request, res: Response) { @@ -134,4 +151,49 @@ export class UserController extends BaseController { } this.ok(res, fillDTO(LoggedUserRDO, user)); } + + public async addFavoriteOffers(req: Request, res: Response) { + const { offerId } = req.params; + const user = await this.userService.findById(req.tokenPayload.id); + if (!user) { + throw new HttpError( + StatusCodes.UNAUTHORIZED, + 'Unauthorized', + 'UserController', + ); + } + const offer = await this.offerService.findOfferById(offerId as string); + if (!offer) { + throw new HttpError( + StatusCodes.NOT_FOUND, + 'Offer not found', + 'UserController', + ); + } + const response = await this.userService.addFavoriteOffer(offer.id, user.id); + this.ok(res, response); + } + + async deleteFavoriteOffers(req: Request, res: Response) { + const { offerId } = req.params; + const user = await this.userService.findById(req.tokenPayload.id); + if (!user) { + throw new HttpError( + StatusCodes.UNAUTHORIZED, + 'Unauthorized', + 'UserController', + ); + } + const offer = await this.offerService.findOfferById(offerId as string); + if (!offer) { + throw new HttpError( + StatusCodes.NOT_FOUND, + 'Offer not found', + 'UserController', + ); + } + await this.userService.deleteFavoriteOffer(offer.id, user.id); + const updatedUser = await this.userService.findById(user.id); + this.ok(res, updatedUser); + } } diff --git a/src/shared/modules/user/user-service.interface.ts b/src/shared/modules/user/user-service.interface.ts index 9cb64c4..83b3fea 100644 --- a/src/shared/modules/user/user-service.interface.ts +++ b/src/shared/modules/user/user-service.interface.ts @@ -8,5 +8,10 @@ export interface UserServiceInterface { findByEmail(email: string): Promise | null>; findById(id: string): Promise | null>; findFavoriteOffers(userId: number): Promise[]>; + deleteFavoriteOffer( + offerId: string, + userId: string, + ): Promise | null>; + addFavoriteOffer(offerId: string, userId: string): Promise; toggleFavoriteOffer(offerId: string, userId: number): Promise; } diff --git a/src/shared/modules/user/user-service.ts b/src/shared/modules/user/user-service.ts index df5ec79..17e7415 100644 --- a/src/shared/modules/user/user-service.ts +++ b/src/shared/modules/user/user-service.ts @@ -7,6 +7,7 @@ import { Component } from '../../types/container.js'; import { Logger } from '../../libs/logger/index.js'; import { OfferEntity } from '../offer/offer.entity.js'; import { DEFAULT_AVATAR_FILE_NAME } from './user.constant.js'; +import mongoose from 'mongoose'; @injectable() export class UserService implements UserServiceInterface { @@ -62,4 +63,23 @@ export class UserService implements UserServiceInterface { }); } } + + async addFavoriteOffer(offerId: string, userId: string): Promise { + await UserModel.findByIdAndUpdate(userId, { + $addToSet: { favorites: new mongoose.Types.ObjectId(offerId) }, + }); + } + + async deleteFavoriteOffer( + offerId: string, + userId: string, + ): Promise | null> { + const result = await UserModel.findByIdAndUpdate( + userId, + { $pull: { favorites: new mongoose.Types.ObjectId(offerId) } }, + { new: true }, + ).exec(); + + return result; + } } diff --git a/src/shared/types/offer.ts b/src/shared/types/offer.ts index ee333bf..e26ebdc 100644 --- a/src/shared/types/offer.ts +++ b/src/shared/types/offer.ts @@ -32,6 +32,7 @@ export type Amenity = | 'Fridge'; export interface Offer { + id: string; title: string; description: string; date: Date;