diff --git a/Workflow.md b/Workflow.md index 1968850..e0c53c4 100644 --- a/Workflow.md +++ b/Workflow.md @@ -85,6 +85,9 @@ npm start `PORT` -- Порт HTTP-сервера. По умолчанию - `3000` `SALT` -- Соль для хеширования паролей +`ME_USER` -- имя пользователя для входа в интерфейс Mongo Express +`ME_PASSWORD` -- пароль для входа в интерфейс Mongo Express. +`MONGO_EXPRESS_PORT` -- порт, на котором доступен Mongo Express `DB_USER` -- Пользователь для подключения к БД `DB_PASSWORD` -- Пароль пользователя БД `DB_HOST` -- Адрес сервера базы данных diff --git a/docker-compose.yaml b/docker-compose.yaml index 4145b7a..bfcf153 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -25,7 +25,7 @@ services: ME_CONFIG_BASICAUTH_PASSWORD: ${ME_PASSWORD} ME_CONFIG_MONGODB_ADMINUSERNAME: ${DB_USER} ME_CONFIG_MONGODB_ADMINPASSWORD: ${DB_PASSWORD} - ME_CONFIG_MONGODB_URL: mongodb://${DB_USER}:${DB_PASSWORD}@db:27017/ + ME_CONFIG_MONGODB_URL: mongodb://${DB_USER}:${DB_PASSWORD}@db:27017/admin?authSource=admin depends_on: - db networks: diff --git a/package.json b/package.json index 3b1e621..665a9fb 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,10 @@ "dev:run": "node dist/main.rest.js", "compile": "tsc -p tsconfig.json", "clean": "rimraf dist", - "cli": "node --loader ts-node/esm ./src/cli.ts", - "cli:generate": "node --loader ts-node/esm ./src/cli.ts --generate", - "cli:import": "node --loader ts-node/esm ./src/cli.ts --import", - "cli:version": "node --loader ts-node/esm ./src/cli.ts --version", + "cli": "node --no-warnings --loader ts-node/esm ./src/cli.ts", + "cli:generate": "node --no-warnings --loader ts-node/esm ./src/cli.ts --generate", + "cli:import": "node --no-warnings --loader ts-node/esm ./src/cli.ts --import", + "cli:version": "node --no-warnings --loader ts-node/esm ./src/cli.ts --version", "ts": "ts-node", "mock:server": "json-server ./src/mock/mock-server-data.json --port 3123", "prepare": "husky" diff --git a/specification/specification.yml b/specification/specification.yml index d2c3ab6..b1d91dd 100644 --- a/specification/specification.yml +++ b/specification/specification.yml @@ -5,6 +5,8 @@ info: license: name: MIT url: https://opensource.org/licenses/MIT +servers: + - url: http://localhost:3000 tags: - name: Users description: Пользователи @@ -17,6 +19,8 @@ paths: /users/register: post: summary: Создать пользователя + operationId: createUser + security: [] tags: [Users] requestBody: content: @@ -26,6 +30,7 @@ paths: required: true responses: '201': + description: Пользователь успешно создан $ref: '#/components/responses/UserResponse' '409': @@ -33,7 +38,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ComflictError' '400': description: Некорректные данные @@ -47,11 +52,13 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ServerError' /users/login: post: summary: Авторизация пользователя + operationId: loginUser + security: [] tags: [Users] requestBody: content: @@ -79,10 +86,11 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ServerError' get: summary: Получить информацию о текущем пользователе + operationId: getCurrentUser tags: [Users] security: - bearerAuth: [] @@ -98,17 +106,28 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/UnauthorizedError' + '500': + description: Произошла ошибка на сервере + content: + application/json: + schema: + $ref: '#/components/schemas/ServerError' /users/{userId}/favorite: get: summary: Получить список избранных предложений + operationId: getUserFavoriteOffers parameters: - in: path name: userId required: true + schema: + type: string + example: '5f9b2e6c7b7b7b7b7b7b7b7b' tags: [Users] - bearerAuth: [] + security: + - bearerAuth: [] responses: '200': description: Предложения успешно получены @@ -121,32 +140,39 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/UnauthorizedError' '404': description: Пользователь не найден content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/NotFoundError' '403': description: Пользователь не является владельцем списка избранного content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ForbiddenError' '500': description: Произошла ошибка на сервере content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ServerError' + /users/{userId}/avatar: post: summary: Загрузка аватара + operationId: uploadAvatar + security: + - bearerAuth: [] parameters: - - in: path - name: userId - required: true + - in: path + name: userId + required: true + schema: + type: string + example: '5f9b2e6c7b7b7b7b7b7b7b7b' tags: [Users] requestBody: content: @@ -161,19 +187,32 @@ paths: description: Аватар успешно загружен content: application/json: - type: string - description: Путь к загруженному файлу - example: 'https://example.com/avatar.jpg' + schema: + $ref: '#/components/schemas/UserResponse' + '401': + description: Пользователь не авторизован + content: + application/json: + schema: + $ref: '#/components/schemas/UnauthorizedError' '500': description: Произошла ошибка на сервере content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ServerError' + '400': + description: Некорректные данные + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' /offers: get: summary: Получить список предложений + operationId: getOffers + security: [] tags: [Offers] responses: '200': @@ -187,10 +226,12 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ServerError' post: summary: Добавление нового предложения - bearerAuth: [] + operationId: createOffer + security: + - bearerAuth: [] tags: [Offers] requestBody: content: @@ -199,7 +240,7 @@ paths: $ref: '#/components/schemas/CreateOffer' responses: '201': - description: Заказ успешно добавлен + description: Предложение успешно создано content: application/json: schema: @@ -215,16 +256,20 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ServerError' /offers/{offerId}: get: summary: Получение предложения + operationId: getOffer + security: [] tags: [Offers] parameters: - in: path name: offerId required: true + schema: + type: string responses: '200': description: Предложение успешно получено @@ -232,28 +277,37 @@ paths: application/json: schema: $ref: '#/components/schemas/OfferRdo' + '400': + description: Некорректные данные + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' '404': description: Предложение не найдено content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/NotFoundError' '500': description: Произошла ошибка на сервере content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ServerError' put: summary: Редактирование предложения + operationId: updateOffer tags: [Offers] - bearerAuth: [] + security: + - bearerAuth: [] parameters: - in: path name: offerId required: true schema: type: string + example: '5f9b2e6c7b7b7b7b7b7b7b7b' requestBody: content: application/json: @@ -278,7 +332,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/NotFoundError' '400': description: Некорректные данные @@ -292,10 +346,12 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ServerError' delete: summary: Удаление предложения - bearerAuth: [] + operationId: deleteOffer + security: + - bearerAuth: [] tags: [Offers] parameters: - in: path @@ -306,27 +362,44 @@ paths: responses: '204': description: Предложение успешно удалено + '400': + description: Некорректные данные + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidIdError' + '403': + description: Пользователь не является владельцем предложения + content: + application/json: + schema: + $ref: '#/components/schemas/ForbiddenError' '404': - description: Ресурс не найден или доступ запрещён + description: Предложение не найдено content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/NotFoundError' '500': description: Произошла ошибка на сервере content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' - /offers/{offerId}/favorite + $ref: '#/components/schemas/ServerError' + + /offers/{offerId}/favorite: post: summary: Добавление предложения в избранное - bearerAuth: [] + operationId: addFavorite + security: + - bearerAuth: [] parameters: - in: path name: offerId required: true - tags: [Users] + schema: + type: string + tags: [Offers] responses: '200': description: Предложение успешно добавлено в избранное @@ -334,32 +407,42 @@ paths: application/json: schema: $ref: '#/components/schemas/UserResponse' + '400': + description: Некорректные данные + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' '404': description: Предложение не найдено content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/NotFoundError' '401': description: Пользователь не авторизован content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/UnauthorizedError' '500': description: Произошла ошибка на сервере content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ServerError' delete: summary: Удаление предложения из избранного - bearerAuth: [] + operationId: deleteFavorite + security: + - bearerAuth: [] parameters: - in: path name: offerId required: true - tags: [Users] + schema: + type: string + tags: [Offers] responses: '200': description: Предложение успешно удалено из избранного @@ -367,28 +450,36 @@ paths: application/json: schema: $ref: '#/components/schemas/UserResponse' - '404': + '400': + description: Некорректные данные + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '404': description: Предложение не найдено content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/NotFoundError' '401': description: Пользователь не авторизован content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/UnauthorizedError' '500': description: Произошла ошибка на сервере content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ServerError' /offers/premium/{city}: get: summary: Получить список премиум предложений + operationId: getPremiumOffers + security: [] tags: [Offers] parameters: - in: path @@ -408,10 +499,12 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' - /offers/{offerId}/comments/: + $ref: '#/components/schemas/ServerError' + /offers/{offerId}/comments: get: summary: Получить список комментариев + operationId: getComments + security: [] tags: [Comments] parameters: - in: path @@ -426,24 +519,30 @@ paths: application/json: schema: $ref: '#/components/schemas/Comments' + '400': + description: Некорректные данные + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' '500': description: Произошла ошибка на сервере content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' - - /comments/{offerId}: + $ref: '#/components/schemas/ServerError' post: summary: Добавление нового комментария - tags: [Comments] - parameters: + operationId: createComment + parameters: - in: path name: offerId + required: true schema: type: string - example: '6dcfd4433f8d3f6d4f3d4f3d4f3d4f3d' - required: true + security: + - bearerAuth: [] + tags: [Comments] requestBody: content: application/json: @@ -468,7 +567,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ServerError' components: securitySchemes: bearerAuth: @@ -482,7 +581,54 @@ components: properties: error: type: string - example: 'offer with id 507f1f77bcf86cd799439011 not found' + example: 'invalid id' + + ServerError: + type: object + required: [error] + properties: + error: + type: string + example: 'Server error' + + NotFoundError: + type: object + required: [error] + properties: + error: + type: string + example: 'Not found' + + UnauthorizedError: + type: object + required: [error] + properties: + error: + type: string + example: 'Unauthorized' + ForbiddenError: + type: object + required: [error] + properties: + error: + type: string + example: 'Access denied' + + InvalidIdError: + type: object + required: [error] + properties: + error: + type: string + example: 'invalid id' + ComflictError: + type: object + required: [error] + properties: + error: + type: string + example: 'User already exists' + ValidationErrorItem: type: object properties: @@ -777,6 +923,9 @@ components: OfferRdo: type: object properties: + _id: + type: string + example: '6dcfd4433f8d3f6d4f3d4f3d4f3d4f3d' title: type: string example: 'Apartment in the center of the city' @@ -796,9 +945,10 @@ components: example: 'https://example.com/preview.jpg' images: type: array + minLength: 6 items: type: string - example: 'https://example.com/image.jpg' + example: ['https://example.com/image1.jpg', 'https://example.com/image2.jpg', 'https://example.com/image3.jpg', 'https://example.com/image4.jpg', 'https://example.com/image5.jpg', 'https://example.com/image6.jpg'] isPremium: type: boolean example: true @@ -814,12 +964,33 @@ components: type: string enum: [apartment, house, room, hotel] example: 'apartment' + bedrooms: + type: integer + example: 2 + maxAdults: + type: integer + example: 4 price: type: integer example: 5000 + goods: + type: array + items: + type: string + enum: [Breakfast, Air conditioning, Laptop friendly workspace, Baby seat, Washer, Towels, Fridge] + example: ['Breakfast', 'Air conditioning'] + host: + type: string + example: '6dcfd4433f8d3f6d4f3d4f3d4f3d4f3d' commentsCount: type: integer example: 12 + location: + $ref: '#/components/schemas/Location' + example: { + lat: 50.4501, + lng: 30.5234 + } Offers: type: array @@ -851,18 +1022,18 @@ components: text: type: string example: 'Comment text' - author: + date: type: string - example: '6dcfd4433f8d3f6d4f3d4f3d4f3d4f3d' + format: date-time + example: '2022-01-01T00:00:00.000Z' rating: type: number minimum: 1 maximum: 5 example: 5 - date: + author: type: string - format: date-time - example: '2022-01-01T00:00:00.000Z' + example: '6dcfd4433f8d3f6d4f3d4f3d4f3d4f3d' UserFavoriteOffer: type: object diff --git a/src/cli/import.command.ts b/src/cli/import.command.ts index 3bdfda0..430eb22 100644 --- a/src/cli/import.command.ts +++ b/src/cli/import.command.ts @@ -1,7 +1,7 @@ import { ModelType } from '@typegoose/typegoose/lib/types.js'; import { getErrorMessage } from '../shared/helpers/common.js'; import { getMongoUri } from '../shared/helpers/database-client.js'; -import { createOffer } from '../shared/helpers/offer.js'; +import { create } from '../shared/helpers/offer.js'; import { DatabaseClient, MongoDatabaseClient, @@ -38,7 +38,7 @@ export class ImportCommand implements Command { this.databaseClient = new MongoDatabaseClient(this.logger); } - public getName(): string { + getName(): string { return '--import'; } @@ -57,8 +57,8 @@ export class ImportCommand implements Command { } private async onImportedLine(line: string, resolve: () => void) { - const offer = createOffer(line); - await this.saveOffer(offer); + const offer = create(line); + await this.save(offer); resolve(); } @@ -66,8 +66,8 @@ export class ImportCommand implements Command { this.databaseClient.disconnect(); } - private async saveOffer(offer: Offer) { - const { id: _id, host, ...offerData } = offer; + private async save(offer: Offer) { + const { host, ...offerData } = offer; const user = await this.userService.register( { ...host, password: DEFAULT_USER_PASSWORD }, this.salt, diff --git a/src/mock/mock-test.tsv b/src/mock/mock-test.tsv new file mode 100644 index 0000000..0dcf684 --- /dev/null +++ b/src/mock/mock-test.tsv @@ -0,0 +1,10 @@ +1 Student room Бюджетная комната для студентов рядом с университетом 2026-01-19 Brussels https://example.com/preview4.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 true 4.9 house 4 6 76844 Air conditioning|Baby seat|Towels 5 Emma Dubois ahmed@example.com https://example.com/avatar4.jpg john-password false 50.846557 4.351697 8 +2 Family house Современный номер в отеле с видом на канал 2026-01-22 Amsterdam 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 true true 3.8 room 6 5 68749 Air conditioning|Baby seat|Towels 2 Ahmed Ali maria@example.com https://example.com/avatar5.jpg maria-password true 52.370216 4.895168 8 +3 Family house Современный номер в отеле с видом на канал 2026-01-15 Amsterdam 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 false true 4.2 house 2 5 33531 Breakfast|Air conditioning|Laptop friendly workspace 5 John Doe maria@example.com https://example.com/avatar5.jpg sophie-password true 52.370216 4.895168 5 +4 Hotel suite Премиум вилла с бассейном и садом для больших компаний 2026-01-22 Amsterdam https://example.com/preview3.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 true 3.8 room 4 10 98219 Laptop friendly workspace 1 Sophie Müller sophie@example.com https://example.com/avatar2.jpg maria-password true 52.370216 4.895168 5 +3 Student room Уютная квартира в центре с прекрасным видом на город 2026-01-22 Paris https://example.com/preview1.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 true false 4.9 house 5 9 83065 Breakfast|Baby seat|Washer|Fridge 2 Ahmed Ali sophie@example.com https://example.com/avatar2.jpg emma-password false 48.85661 2.351499 12 +3 Luxury villa Премиум вилла с бассейном и садом для больших компаний 2026-01-19 Paris https://example.com/preview4.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 true true 3.8 house 4 9 81366 Laptop friendly workspace 3 Maria Schmidt sophie@example.com https://example.com/avatar4.jpg maria-password false 48.85661 2.351499 8 +3 Family house Бюджетная комната для студентов рядом с университетом 2026-01-18 Brussels 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 false false 4.9 house 4 10 72471 Breakfast|Washer|Towels|Fridge 4 Emma Dubois maria@example.com https://example.com/avatar1.jpg sophie-password false 50.846557 4.351697 23 +1 Student room Премиум вилла с бассейном и садом для больших компаний 2026-01-22 Amsterdam https://example.com/preview1.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 false true 4.2 room 4 7 55450 Laptop friendly workspace 4 Ahmed Ali emma@example.com https://example.com/avatar3.jpg maria-password true 52.370216 4.895168 5 +3 Student room Семейный дом в тихом районе с детской комнатой 2026-01-15 Cologne https://example.com/preview5.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 true true 4.5 hotel 7 3 72601 Air conditioning|Baby seat|Towels 4 Maria Schmidt john@example.com https://example.com/avatar2.jpg maria-password false 50.938361 6.959974 12 +3 Hotel suite Уютная квартира в центре с прекрасным видом на город 2026-01-18 Hamburg https://example.com/preview4.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 apartment 5 7 65258 Breakfast|Washer|Towels|Fridge 2 Ahmed Ali maria@example.com https://example.com/avatar5.jpg emma-password true 53.550341 10.000654 23 diff --git a/src/mock/mock.tsv b/src/mock/mock.tsv index 65c1310..41a121d 100644 --- a/src/mock/mock.tsv +++ b/src/mock/mock.tsv @@ -2,3 +2,13 @@ 2 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 3 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 4 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 +3 Student room Премиум вилла с бассейном и садом для больших компаний 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|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|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|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 hotel 5 5 77977 Breakfast|Baby seat|Washer|Fridge 2 Emma Dubois ahmed@example.com https://example.com/avatar4.jpg sophie-password false 50.9375 4.3517 5 +2 Luxury villa Премиум вилла с бассейном и садом для больших компаний 2026-01-22 Brussels https://example.com/preview2.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|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.7 apartment 3 10 12999 Breakfast|Washer|Towels|Fridge|Laptop friendly workspace|Air conditioning|Baby seat|Towels|Breakfast|Baby seat|Washer|Fridge 4 Emma Dubois maria@example.com https://example.com/avatar3.jpg ahmed-password true 48.8566 9.9937 23 +3 Luxury villa Современный номер в отеле с видом на канал 2026-01-15 Hamburg https://example.com/preview3.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|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 true 4.7 hotel 3 6 21803 Air conditioning|Baby seat|Towels|Breakfast|Baby seat|Washer|Fridge 5 Maria Schmidt maria@example.com https://example.com/avatar4.jpg maria-password true 53.5511 2.3522 12 +2 Hotel suite Современный номер в отеле с видом на канал 2026-01-15 Paris 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|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.9 house 6 4 2860 Breakfast|Baby seat|Washer|Fridge 2 Maria Schmidt maria@example.com https://example.com/avatar1.jpg maria-password true 52.3702 9.9937 5 +2 Luxury villa Премиум вилла с бассейном и садом для больших компаний 2026-01-15 Hamburg https://example.com/preview4.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|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|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.9 room 1 7 22681 Breakfast|Washer|Towels|Fridge|Laptop friendly workspace|Air conditioning|Baby seat|Towels|Breakfast|Baby seat|Washer|Fridge 3 Emma Dubois maria@example.com https://example.com/avatar2.jpg maria-password false 50.8503 9.9937 23 +3 Student room Современный номер в отеле с видом на канал 2026-01-15 Amsterdam 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|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 false true 4.9 house 4 8 77352 Laptop friendly workspace|Air conditioning|Baby seat|Towels 4 Sophie Müller emma@example.com https://example.com/avatar2.jpg ahmed-password false 52.3702 9.9937 5 +2 Hotel suite Бюджетная комната для студентов рядом с университетом 2026-01-15 Cologne https://example.com/preview2.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 false true 4.9 room 6 8 75404 Breakfast|Washer|Towels|Fridge|Laptop friendly workspace 1 John Doe john@example.com https://example.com/avatar2.jpg ahmed-password true 50.9375 4.3517 8 +3 Hotel suite Современный номер в отеле с видом на канал 2026-01-22 Hamburg 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|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|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 false true 4.2 room 3 1 49042 Breakfast|Washer|Towels|Fridge|Laptop friendly workspace 5 Sophie Müller sophie@example.com https://example.com/avatar2.jpg sophie-password true 52.3702 6.9603 5 +3 Luxury villa Современный номер в отеле с видом на канал 2026-01-22 Cologne https://example.com/preview2.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|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|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|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 false 3.8 hotel 7 9 72128 Breakfast|Air conditioning|Laptop friendly workspace|Breakfast|Washer|Towels|Fridge|Laptop friendly workspace|Air conditioning|Baby seat|Towels 4 Emma Dubois ahmed@example.com https://example.com/avatar4.jpg maria-password true 50.8503 4.3517 8 +2 Hotel suite Премиум вилла с бассейном и садом для больших компаний 2026-01-15 Paris https://example.com/preview3.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|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|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|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|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 true 4.7 hotel 1 3 64984 Breakfast|Baby seat|Washer|Fridge 3 Ahmed Ali emma@example.com https://example.com/avatar2.jpg sophie-password true 52.3702 6.9603 23 diff --git a/src/rest/rest.application.ts b/src/rest/rest.application.ts index 0ffe00e..3582ec7 100644 --- a/src/rest/rest.application.ts +++ b/src/rest/rest.application.ts @@ -25,8 +25,6 @@ export class RestApplication { private readonly userController: Controller, @inject(Component.ExceptionFilter) private readonly exceptionFilter: ExceptionFilter, - @inject(Component.CommentController) - private readonly commentController: Controller, @inject(Component.AuthExceptionFilter) private readonly authExceptionFilter: ExceptionFilter, ) { @@ -77,7 +75,6 @@ export class RestApplication { private async _initControllers() { this.server.use('/offers', this.offerController.router); this.server.use('/users', this.userController.router); - this.server.use('/comments', this.commentController.router); } private async _initServer() { @@ -105,12 +102,12 @@ export class RestApplication { await this.__initExceptionFilters(); - this.logger.info( - `🚀 Server started on ${getFullServerPath(this.config.get('HOST'), this.config.get('PORT'))}`, - ); - this.logger.info('Database initialization'); await this._initDB(); + + this.logger.info( + `Server started on ${getFullServerPath(this.config.get('HOST'), this.config.get('PORT'))}`, + ); this.logger.info('Init database completed'); } } diff --git a/src/shared/helpers/offer.ts b/src/shared/helpers/offer.ts index 9d99368..638bf4f 100644 --- a/src/shared/helpers/offer.ts +++ b/src/shared/helpers/offer.ts @@ -1,6 +1,6 @@ import { Amenity, City, HousingType, Offer } from '../types/offer.js'; -export const createOffer = (offerData: string): Offer => { +export const create = (offerData: string): Offer => { const [ id, title, diff --git a/src/shared/libs/offer-generator/offer-generator.ts b/src/shared/libs/offer-generator/offer-generator.ts index 2258e77..93a7035 100644 --- a/src/shared/libs/offer-generator/offer-generator.ts +++ b/src/shared/libs/offer-generator/offer-generator.ts @@ -1,9 +1,6 @@ -import { - generateRandomValue, - getRandomItem, - getRandomItems, -} from '../../helpers/common.js'; +import { generateRandomValue, getRandomItem } from '../../helpers/common.js'; import { MockServerData } from '../../types/mock-server.js'; +import { CITY_COORDINATES, CityCoordinates } from '../../types/offer.js'; import { OfferGenerator } from './offer-generator.interface.js'; const MAX_PRICE = 100000; @@ -25,7 +22,7 @@ export class TSVOfferGenerator implements OfferGenerator { const postDate = getRandomItem(this.mockData.postDate); const city = getRandomItem(this.mockData.city); const previewImage = getRandomItem(this.mockData.previewImage); - const images = getRandomItems(this.mockData.images); + const images = getRandomItem(this.mockData.images); const isPremium = getRandomItem(this.mockData.isPremium); const isFavorite = getRandomItem(this.mockData.isFavorite); const rating = getRandomItem(this.mockData.rating); @@ -33,15 +30,16 @@ export class TSVOfferGenerator implements OfferGenerator { const bedrooms = generateRandomValue(MIN_BEDROOMS, MAX_BEDROOMS); const maxAdults = generateRandomValue(MIN_ADULTS, MAX_ADULTS); const price = generateRandomValue(MIN_PRICE, MAX_PRICE); - const goods = getRandomItems(this.mockData.goods); + const goods = getRandomItem(this.mockData.goods); const hostId = getRandomItem(this.mockData.hostId); const hostName = getRandomItem(this.mockData.hostName); const hostEmail = getRandomItem(this.mockData.hostEmail); const hostAvatar = getRandomItem(this.mockData.hostAvatar); const hostPassword = getRandomItem(this.mockData.hostPassword); const hostIsPro = getRandomItem(this.mockData.hostIsPro); - const locationLat = getRandomItem(this.mockData.locationLat); - const locationLng = getRandomItem(this.mockData.locationLng); + const coordies = (CITY_COORDINATES as CityCoordinates)[city]; + const locationLat = coordies.lat; + const locationLng = coordies.lng; const commentsCount = getRandomItem(this.mockData.commentsCount); return [ @@ -51,7 +49,7 @@ export class TSVOfferGenerator implements OfferGenerator { postDate, city, previewImage, - images.join('|'), + images, isPremium, isFavorite, rating, @@ -59,7 +57,7 @@ export class TSVOfferGenerator implements OfferGenerator { bedrooms, maxAdults, price, - goods.join('|'), + goods, hostId, hostName, hostEmail, diff --git a/src/shared/libs/rest/controller/base-controller.abstract.ts b/src/shared/libs/rest/controller/base-controller.abstract.ts index 427f8f1..5223c41 100644 --- a/src/shared/libs/rest/controller/base-controller.abstract.ts +++ b/src/shared/libs/rest/controller/base-controller.abstract.ts @@ -41,7 +41,7 @@ export abstract class BaseController implements Controller { ); } - public send(res: Response, statusCode: number, data: T) { + public send(res: Response, statusCode: number, data?: T) { const modifiedData = this.pathTransformer.execute( data as Record, ); @@ -52,11 +52,11 @@ export abstract class BaseController implements Controller { this.send(res, StatusCodes.CREATED, data); } - public ok(res: Response, data: T) { + public ok(res: Response, data?: T) { this.send(res, StatusCodes.OK, data); } - public noContent(res: Response, data: T): void { - this.send(res, StatusCodes.NO_CONTENT, data); + public noContent(res: Response): void { + this.send(res, StatusCodes.NO_CONTENT); } } diff --git a/src/shared/libs/rest/exception-filter/app-exception-filter.ts b/src/shared/libs/rest/exception-filter/app-exception-filter.ts index c320a1e..5df5ece 100644 --- a/src/shared/libs/rest/exception-filter/app-exception-filter.ts +++ b/src/shared/libs/rest/exception-filter/app-exception-filter.ts @@ -41,8 +41,6 @@ export class AppExceptionFilter implements ExceptionFilter { } catch(err: Error, req: Request, res: Response, next: NextFunction): void { - this.logger.error(err.message, err); - if (err instanceof HttpError) { return this.handleHttpError(err, req, res, next); } diff --git a/src/shared/modules/comment/comment-controller.ts b/src/shared/modules/comment/comment-controller.ts deleted file mode 100644 index 77816d9..0000000 --- a/src/shared/modules/comment/comment-controller.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { inject } from 'inversify'; -import { BaseController } from '../../libs/rest/controller/base-controller.abstract.js'; -import { Component } from '../../types/container.js'; -import { Logger } from '../../libs/logger/index.js'; -import { HttpMethod } from '../../libs/rest/index.js'; -import { CommentService } from './comment-service.js'; -import { fillDTO } from '../../helpers/common.js'; -import { Request, Response } from 'express'; -import { CommentRDO } from './rdo/comment-rdo.js'; -import { CommentDto } from './dto/comment-dto.js'; -import { ValidateDTOMiddleware } from '../../libs/rest/middleware/validate-object.middleware.js'; -import { PrivateRouteMiddleware } from '../../libs/rest/middleware/private-route.middleware.js'; - -export class CommentController extends BaseController { - constructor( - @inject(Component.Logger) protected readonly logger: Logger, - @inject(Component.CommentService) - private readonly commentService: CommentService, - ) { - super(logger); - - this.addRoute({ - path: '/:offerId', - method: HttpMethod.Post, - handler: this.create, - middlewares: [ - new ValidateDTOMiddleware(CommentDto), - new PrivateRouteMiddleware(), - ], - }); - } - - public async create(req: Request, res: Response) { - const { offerId } = req.params; - - const result = await this.commentService.create( - { ...req.body, author: req.tokenPayload.id }, - offerId as string, - ); - this.logger.info(`Comment created: ${req.body.text}`); - - this.created(res, fillDTO(CommentRDO, result)); - } -} diff --git a/src/shared/modules/comment/comment.container.ts b/src/shared/modules/comment/comment.container.ts index 05a23a7..018cf86 100644 --- a/src/shared/modules/comment/comment.container.ts +++ b/src/shared/modules/comment/comment.container.ts @@ -4,8 +4,6 @@ import { Component } from '../../types/container.js'; import { types } from '@typegoose/typegoose'; import { CommentServiceInterface } from './comment-service.interface.js'; import { CommentService } from './comment-service.js'; -import { Controller } from '../../libs/rest/controller/controller.interface.js'; -import { CommentController } from './comment-controller.js'; export function createCommentContainer() { const commentContainer = new Container(); @@ -17,10 +15,6 @@ export function createCommentContainer() { commentContainer .bind>(Component.CommentModel) .toConstantValue(CommentModel); - commentContainer - .bind(Component.CommentController) - .to(CommentController) - .inSingletonScope(); return commentContainer; } diff --git a/src/shared/modules/comment/dto/comment-dto.messages.ts b/src/shared/modules/comment/dto/comment-dto.messages.ts index a4e90f7..6f6ff14 100644 --- a/src/shared/modules/comment/dto/comment-dto.messages.ts +++ b/src/shared/modules/comment/dto/comment-dto.messages.ts @@ -1,5 +1,6 @@ export const CommentDTOValidationMessage = { text: { + isString: 'Comment text must be a string', min: 'Comment text must be at least 5 characters long', max: 'Comment text must be at most 1024 characters long', }, diff --git a/src/shared/modules/comment/dto/comment-dto.ts b/src/shared/modules/comment/dto/comment-dto.ts index dc7f747..3c4cd93 100644 --- a/src/shared/modules/comment/dto/comment-dto.ts +++ b/src/shared/modules/comment/dto/comment-dto.ts @@ -1,7 +1,15 @@ -import { IsDateString, Max, MaxLength, Min, MinLength } from 'class-validator'; +import { + IsDateString, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; import { CommentDTOValidationMessage } from './comment-dto.messages.js'; export class CommentDto { + @IsString({ message: CommentDTOValidationMessage.text.isString }) @MinLength(5, { message: CommentDTOValidationMessage.text.min }) @MaxLength(1024, { message: CommentDTOValidationMessage.text.max }) text!: string; diff --git a/src/shared/modules/offer/dto/offer-dto.messages.ts b/src/shared/modules/offer/dto/offer-dto.messages.ts index a23e205..9007a56 100644 --- a/src/shared/modules/offer/dto/offer-dto.messages.ts +++ b/src/shared/modules/offer/dto/offer-dto.messages.ts @@ -58,6 +58,7 @@ export const OfferDTOValidationMessage = { goods: { isArray: 'Goods must be an array.', + isEnum: 'Goods must be an array.', }, host: { diff --git a/src/shared/modules/offer/dto/offer-dto.ts b/src/shared/modules/offer/dto/offer-dto.ts index aab8667..8e235d9 100644 --- a/src/shared/modules/offer/dto/offer-dto.ts +++ b/src/shared/modules/offer/dto/offer-dto.ts @@ -1,4 +1,11 @@ -import { Amenity, City, HousingType } from '../../../types/offer.js'; +import { + Amenities, + Amenity, + CITIES, + City, + HousingType, + HousingTypes, +} from '../../../types/offer.js'; import { IsArray, IsBoolean, @@ -30,10 +37,7 @@ export class CreateOfferDto { @IsDateString({}, { message: OfferDTOValidationMessage.date.isDate }) date!: Date; - @IsEnum( - ['Paris', 'Cologne', 'Brussels', 'Amsterdam', 'Hamburg', 'Dusseldorf'], - { message: OfferDTOValidationMessage.city.isEnum }, - ) + @IsEnum(CITIES, { message: OfferDTOValidationMessage.city.isEnum }) city!: City; @IsString({ message: OfferDTOValidationMessage.imagePreview.isString }) @@ -52,7 +56,7 @@ export class CreateOfferDto { @Max(5, { message: OfferDTOValidationMessage.rating.max }) rating!: number; - @IsEnum(['apartment', 'house', 'room', 'hotel'], { + @IsEnum(HousingTypes, { message: OfferDTOValidationMessage.type.isEnum, }) type!: HousingType; @@ -70,6 +74,10 @@ export class CreateOfferDto { price!: number; @IsArray({ message: OfferDTOValidationMessage.goods.isArray }) + @IsEnum(Amenities, { + each: true, + message: OfferDTOValidationMessage.goods.isEnum, + }) goods!: Amenity[]; @IsString({ message: OfferDTOValidationMessage.host.isMongoId }) diff --git a/src/shared/modules/offer/dto/update-dto.ts b/src/shared/modules/offer/dto/update-dto.ts index ffaa034..db0b67a 100644 --- a/src/shared/modules/offer/dto/update-dto.ts +++ b/src/shared/modules/offer/dto/update-dto.ts @@ -6,6 +6,7 @@ import { IsInt, IsMongoId, IsObject, + IsOptional, IsString, Max, MaxLength, @@ -13,45 +14,23 @@ import { MinLength, } from 'class-validator'; import { OfferDTOValidationMessage } from './offer-dto.messages.js'; - -export type User = { - id: number; - name: string; - email: string; - avatar: string; - password: string; - isPro: boolean; -}; - -export type Location = { - latitude: number; - longitude: number; -}; - -export type City = - | 'Paris' - | 'Cologne' - | 'Brussels' - | 'Amsterdam' - | 'Hamburg' - | 'Dusseldorf'; - -export type HousingType = 'apartment' | 'house' | 'room' | 'hotel'; - -export type Amenity = - | 'Breakfast' - | 'Air conditioning' - | 'Laptop friendly workspace' - | 'Baby seat' - | 'Washer' - | 'Towels' - | 'Fridge'; +import { + Amenities, + Amenity, + CITIES, + City, + HousingType, + HousingTypes, + Location, +} from '../../../types/offer.js'; export class UpdateOfferDto { + @IsOptional() @MinLength(10, { message: OfferDTOValidationMessage.title.minLength }) @MaxLength(100, { message: OfferDTOValidationMessage.title.maxLength }) title?: string; + @IsOptional() @MinLength(20, { message: OfferDTOValidationMessage.description.minLength, }) @@ -60,57 +39,69 @@ export class UpdateOfferDto { }) description?: string; + @IsOptional() @IsDateString({}, { message: OfferDTOValidationMessage.date.isDate }) date?: Date; - @IsEnum( - ['Paris', 'Cologne', 'Brussels', 'Amsterdam', 'Hamburg', 'Dusseldorf'], - { message: OfferDTOValidationMessage.city.isEnum }, - ) + @IsOptional() + @IsEnum(CITIES, { message: OfferDTOValidationMessage.city.isEnum }) city?: City; + @IsOptional() @IsString({ message: OfferDTOValidationMessage.imagePreview.isString }) imagePreview?: string; + @IsOptional() @IsArray({ message: OfferDTOValidationMessage.images.isArray }) images?: string[]; + @IsOptional() @IsBoolean({ message: OfferDTOValidationMessage.isPremium.isBoolean }) isPremium?: boolean; + @IsOptional() @IsBoolean({ message: OfferDTOValidationMessage.isFavorite.isBoolean }) isFavorite?: boolean; + @IsOptional() @Min(1, { message: OfferDTOValidationMessage.rating.min }) @Max(5, { message: OfferDTOValidationMessage.rating.max }) rating?: number; - @IsEnum(['apartment', 'house', 'room', 'hotel'], { + @IsOptional() + @IsEnum(HousingTypes, { message: OfferDTOValidationMessage.type.isEnum, }) type?: HousingType; + @IsOptional() @Min(1, { message: OfferDTOValidationMessage.bedrooms.min }) @Max(8, { message: OfferDTOValidationMessage.bedrooms.max }) bedrooms?: number; + @IsOptional() @Min(1, { message: OfferDTOValidationMessage.maxAdults.min }) @Max(10, { message: OfferDTOValidationMessage.maxAdults.max }) maxAdults?: number; + @IsOptional() @Min(100, { message: OfferDTOValidationMessage.price.min }) @Max(100000, { message: OfferDTOValidationMessage.price.max }) price?: number; - @IsArray({ message: OfferDTOValidationMessage.goods.isArray }) + @IsOptional() + @IsEnum(Amenities, { message: OfferDTOValidationMessage.goods.isEnum }) goods?: Amenity[]; + @IsOptional() @IsMongoId({ message: OfferDTOValidationMessage.host.isMongoId }) host?: string; + @IsOptional() @IsInt({ message: OfferDTOValidationMessage.commentsCount.isInt }) commentsCount?: number; + @IsOptional() @IsObject({ message: OfferDTOValidationMessage.location.isObject }) - location?: { latitude: number; longitude: number }; + location?: Location; } diff --git a/src/shared/modules/offer/offer-controller.ts b/src/shared/modules/offer/offer-controller.ts index cd7b6f8..5c40059 100644 --- a/src/shared/modules/offer/offer-controller.ts +++ b/src/shared/modules/offer/offer-controller.ts @@ -70,14 +70,14 @@ export class OfferController extends BaseController { ], }); this.addRoute({ - path: '/premium/:city', + path: '/premium', method: HttpMethod.Get, - handler: this.showPremiumOffers, + handler: this.showPremium, }); this.addRoute({ path: '/:offerId', method: HttpMethod.Get, - handler: this.getDetailOffers, + handler: this.getDetail, middlewares: [ new ValidateObjectIdMiddleware('offerId'), new DocumentExistsMiddleware('offerId', 'offer', this.offerService), @@ -93,21 +93,50 @@ export class OfferController extends BaseController { ], }); + this.addRoute({ + path: '/:offerId/comments', + method: HttpMethod.Post, + handler: this.createComment, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateObjectIdMiddleware('offerId'), + new DocumentExistsMiddleware('offerId', 'offer', this.offerService), + ], + }); + this.addRoute({ path: '/:offerId/favorite', method: HttpMethod.Post, - handler: this.addFavoriteOffers, - middlewares: [new PrivateRouteMiddleware()], + handler: this.addFavorite, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateObjectIdMiddleware('offerId'), + new DocumentExistsMiddleware('offerId', 'offer', this.offerService), + ], }); this.addRoute({ path: '/:offerId/favorite', method: HttpMethod.Delete, - handler: this.deleteFavoriteOffers, - middlewares: [new PrivateRouteMiddleware()], + handler: this.deleteFavorite, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateObjectIdMiddleware('offerId'), + new DocumentExistsMiddleware('offerId', 'offer', this.offerService), + ], }); } + public async createComment(req: Request, res: Response) { + const { offerId } = req.params; + + const comment = await this.commentService.create( + { ...req.body, author: req.tokenPayload.id }, + offerId as string, + ); + this.created(res, fillDTO(CommentRDO, comment)); + } + public async index(req: Request, res: Response) { const limit = Number(req.query.limit) || MAX_DISPLAY_OFFERS_COUNT; const offers = await this.offerService.find(limit); @@ -137,7 +166,7 @@ export class OfferController extends BaseController { this.created(res, fillDTO(OfferRDO, result)); } - public async getDetailOffers(req: Request, res: Response) { + public async getDetail(req: Request, res: Response) { const offer = await this.offerService.findById( req.params.offerId as string, ); @@ -162,7 +191,7 @@ export class OfferController extends BaseController { this.ok(res, fillDTO(OfferRDO, updateOffer)); } - public async addFavoriteOffers(req: Request, res: Response) { + public async addFavorite(req: Request, res: Response) { const { offerId } = req.params; const user = await this.userService.findById(req.tokenPayload.id); if (!user) { @@ -184,7 +213,7 @@ export class OfferController extends BaseController { this.ok(res, fillDTO(UserRDO, response)); } - async deleteFavoriteOffers(req: Request, res: Response) { + async deleteFavorite(req: Request, res: Response) { const { offerId } = req.params; const user = await this.userService.findById(req.tokenPayload.id); if (!user) { @@ -209,6 +238,7 @@ export class OfferController extends BaseController { public async delete(req: Request, res: Response) { const { offerId } = req.params; + const offer = await this.offerService.findById(offerId as string); if (!offer) { @@ -217,15 +247,15 @@ export class OfferController extends BaseController { return this.logger.error(error.message, error); } - const result = await this.offerService.deleteById(offer.id); + await this.offerService.deleteById(offer.id); await this.commentService.deleteById(offer.id); - this.send(res, StatusCodes.NO_CONTENT, result); + this.noContent(res); } - public async showPremiumOffers(req: Request, res: Response) { - const { city } = req.params; - const premiumOffers = await this.offerService.findPremiumOffersByCity( + public async showPremium(req: Request, res: Response) { + const { city } = req.query; + const premiumOffers = await this.offerService.findPremiumByCity( city as City, ); diff --git a/src/shared/modules/offer/offer-service.interface.ts b/src/shared/modules/offer/offer-service.interface.ts index 073e09b..dad735c 100644 --- a/src/shared/modules/offer/offer-service.interface.ts +++ b/src/shared/modules/offer/offer-service.interface.ts @@ -1,8 +1,9 @@ import { DocumentType } from '@typegoose/typegoose'; import { CreateOfferDto } from './dto/offer-dto.js'; import { OfferEntity } from './offer.entity.js'; -import { City, UpdateOfferDto } from './dto/update-dto.js'; +import { UpdateOfferDto } from './dto/update-dto.js'; import { UserEntity } from '../user/index.js'; +import { City } from '../../types/offer.js'; export interface OfferServiceInterface { create(dto: CreateOfferDto): Promise>; @@ -15,7 +16,7 @@ export interface OfferServiceInterface { deleteById(offerId: string): Promise | null>; incCommentCount(offerId: string): Promise | null>; decCommentCount(offerId: string): Promise | null>; - findPremiumOffersByCity(city: City): Promise[]>; + findPremiumByCity(city: City): Promise[]>; deleteFavorite( offerId: string, userId: string, diff --git a/src/shared/modules/offer/offer-service.ts b/src/shared/modules/offer/offer-service.ts index d85d36d..d6f056b 100644 --- a/src/shared/modules/offer/offer-service.ts +++ b/src/shared/modules/offer/offer-service.ts @@ -4,13 +4,14 @@ import { OfferServiceInterface } from './offer-service.interface.js'; import { OfferEntity } from './offer.entity.js'; import { Component } from '../../types/container.js'; import { Logger } from '../../libs/logger/index.js'; -import { City, UpdateOfferDto } from './dto/update-dto.js'; +import { UpdateOfferDto } from './dto/update-dto.js'; import { DocumentType, mongoose, types } from '@typegoose/typegoose'; import { CommentEntity } from '../comment/comment.entity.js'; import { Types } from 'mongoose'; import { DocumentExists } from '../../libs/rest/types/document-exists.interface.js'; import { DISPLAY_PREMIUM_OFFERS_COUNT } from './offer.const.js'; import { UserEntity } from '../user/index.js'; +import { City } from '../../types/offer.js'; @injectable() export class OfferService implements OfferServiceInterface, DocumentExists { @@ -68,17 +69,10 @@ export class OfferService implements OfferServiceInterface, DocumentExists { } async find(limit: number): Promise[]> { - return this.offerModel - .find() - .populate('host') - .sort({ date: -1 }) - .limit(limit) - .exec(); + return this.offerModel.find().sort({ date: -1 }).limit(limit).exec(); } - async findPremiumOffersByCity( - city: City, - ): Promise[]> { + async findPremiumByCity(city: City): Promise[]> { return this.offerModel .find({ city, isPremium: true }) .sort({ date: -1 }) diff --git a/src/shared/modules/offer/offer.entity.ts b/src/shared/modules/offer/offer.entity.ts index dad1dc0..5386b62 100644 --- a/src/shared/modules/offer/offer.entity.ts +++ b/src/shared/modules/offer/offer.entity.ts @@ -5,7 +5,15 @@ import { prop, Ref, } from '@typegoose/typegoose'; -import { Amenity, City, HousingType, Location } from '../../types/offer.js'; +import { + Amenities, + Amenity, + CITIES, + City, + HousingType, + HousingTypes, + Location, +} from '../../types/offer.js'; import { UserEntity } from '../user/user.entity.js'; // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging @@ -41,14 +49,7 @@ export class OfferEntity extends defaultClasses.TimeStamps { @prop({ required: true, - enum: [ - 'Paris', - 'Cologne', - 'Brussels', - 'Amsterdam', - 'Hamburg', - 'Dusseldorf', - ], + enum: CITIES, }) public city!: City; @@ -67,7 +68,7 @@ export class OfferEntity extends defaultClasses.TimeStamps { @prop({ required: true, min: 0, max: 5 }) public rating!: number; - @prop({ required: true, enum: ['apartment', 'house', 'room', 'hotel'] }) + @prop({ required: true, enum: HousingTypes }) public type!: HousingType; @prop({ required: true, min: 1, max: 8 }) @@ -82,15 +83,7 @@ export class OfferEntity extends defaultClasses.TimeStamps { @prop({ type: () => [String], required: true, - enum: [ - 'Breakfast', - 'Air conditioning', - 'Laptop friendly workspace', - 'Baby seat', - 'Washer', - 'Towels', - 'Fridge', - ], + enum: Amenities, }) public goods!: Amenity[]; diff --git a/src/shared/modules/offer/rdo/offer.rdo.ts b/src/shared/modules/offer/rdo/offer.rdo.ts index be83267..3259497 100644 --- a/src/shared/modules/offer/rdo/offer.rdo.ts +++ b/src/shared/modules/offer/rdo/offer.rdo.ts @@ -1,6 +1,10 @@ import { Expose } from 'class-transformer'; +import { Amenity } from '../../../types/offer.js'; export class OfferRDO { + @Expose() + _id: string; + @Expose() title: string; @@ -31,9 +35,24 @@ export class OfferRDO { @Expose() type: string; + @Expose() + bedrooms: number; + + @Expose() + maxAdults: number; + @Expose() price: number; + @Expose() + goods: Amenity[]; + + @Expose() + host: string; + @Expose() commentsCount: number; + + @Expose() + location: { latitude: number; longitude: number }; } diff --git a/src/shared/types/container.ts b/src/shared/types/container.ts index 2abbcfa..03e5ef4 100644 --- a/src/shared/types/container.ts +++ b/src/shared/types/container.ts @@ -12,7 +12,6 @@ export const Component = { OfferController: Symbol.for('OfferController'), UserController: Symbol.for('UserController'), ExceptionFilter: Symbol.for('ExceptionFilter'), - CommentController: Symbol.for('CommentController'), AuthService: Symbol.for('AuthService'), AuthExceptionFilter: Symbol.for('AuthExceptionFilter'), PathTransformer: Symbol.for('PathTransformer'), diff --git a/src/shared/types/offer.ts b/src/shared/types/offer.ts index 80602ca..84bf558 100644 --- a/src/shared/types/offer.ts +++ b/src/shared/types/offer.ts @@ -12,24 +12,43 @@ export class Location { public longitude: number; } -export type City = - | 'Paris' - | 'Cologne' - | 'Brussels' - | 'Amsterdam' - | 'Hamburg' - | 'Dusseldorf'; - -export type HousingType = 'apartment' | 'house' | 'room' | 'hotel'; - -export type Amenity = - | 'Breakfast' - | 'Air conditioning' - | 'Laptop friendly workspace' - | 'Baby seat' - | 'Washer' - | 'Towels' - | 'Fridge'; +export const CITIES = [ + 'Paris', + 'Cologne', + 'Brussels', + 'Amsterdam', + 'Hamburg', + 'Dusseldorf', +] as const; + +export type City = (typeof CITIES)[number]; + +export const Amenities = [ + 'Breakfast', + 'Air conditioning', + 'Laptop friendly workspace', + 'Baby seat', + 'Washer', + 'Towels', + 'Fridge', +] as const; + +export type CityCoordinates = Record; + +export const CITY_COORDINATES: CityCoordinates = { + Paris: { lat: 48.85661, lng: 2.351499 }, + Amsterdam: { lat: 52.370216, lng: 4.895168 }, + Cologne: { lat: 50.938361, lng: 6.959974 }, + Brussels: { lat: 50.846557, lng: 4.351697 }, + Hamburg: { lat: 53.550341, lng: 10.000654 }, + Dusseldorf: { lat: 51.225402, lng: 6.776314 }, +} as const; + +export type Amenity = (typeof Amenities)[number]; + +export const HousingTypes = ['apartment', 'house', 'room', 'hotel'] as const; + +export type HousingType = (typeof HousingTypes)[number]; export interface Offer { id: string; diff --git a/src/uploads/25932d6e-504e-4628-be2c-aa27a258258a.false b/src/uploads/25932d6e-504e-4628-be2c-aa27a258258a.false deleted file mode 100644 index ba90d3c..0000000 Binary files a/src/uploads/25932d6e-504e-4628-be2c-aa27a258258a.false and /dev/null differ diff --git a/src/uploads/58d20245-f7f0-4b1e-9099-2b07015a7bd9.false b/src/uploads/58d20245-f7f0-4b1e-9099-2b07015a7bd9.false deleted file mode 100644 index 28da12e..0000000 Binary files a/src/uploads/58d20245-f7f0-4b1e-9099-2b07015a7bd9.false and /dev/null differ diff --git a/src/uploads/778b8ff8-6fdf-4ca6-a218-b4e80d4baaac.false b/src/uploads/778b8ff8-6fdf-4ca6-a218-b4e80d4baaac.false deleted file mode 100644 index 28da12e..0000000 Binary files a/src/uploads/778b8ff8-6fdf-4ca6-a218-b4e80d4baaac.false and /dev/null differ diff --git a/src/uploads/7d9c9c0c-1884-44cb-8bb0-7f284245508e.false b/src/uploads/7d9c9c0c-1884-44cb-8bb0-7f284245508e.false deleted file mode 100644 index f6af37c..0000000 Binary files a/src/uploads/7d9c9c0c-1884-44cb-8bb0-7f284245508e.false and /dev/null differ diff --git a/src/uploads/81688aa7-9440-4776-884c-c09cfe8bfd5c.false b/src/uploads/81688aa7-9440-4776-884c-c09cfe8bfd5c.false deleted file mode 100644 index 28da12e..0000000 Binary files a/src/uploads/81688aa7-9440-4776-884c-c09cfe8bfd5c.false and /dev/null differ