diff --git a/.env-example b/.env.example similarity index 77% rename from .env-example rename to .env.example index 5d2969c..bfe22c5 100644 --- a/.env-example +++ b/.env.example @@ -10,5 +10,7 @@ DB_PASSWORD=password DB_NAME=six-cities-2 DB_PORT=27017 UPLOAD_DIRECTORY=upload +STATIC_DIRECTORY_PATH=static JWT_SECRET=secret +DEFAULT_USER_PASSWORD = password diff --git a/Workflow.md b/Workflow.md index 95bd6e2..b671042 100644 --- a/Workflow.md +++ b/Workflow.md @@ -10,27 +10,52 @@ npm install Команда запустит процесс установки зависимостей проекта из **npm**. -### Сценарии +## Запуск проекта -В `package.json` предопределено несколько сценариев. +Ниже приведён рекомендуемый порядок локального запуска проекта. -#### Скомпилировать проект +### 1. Установить зависимости + +Если зависимости ещё не установлены, выполните: ```bash -npm run compile +npm install ``` -Создаст директорию `dist` и скомпилирует проект. +### 2. Подготовить файл `.env` + +Приложение читает настройки из файла `.env`. Создайте его в корне проекта и укажите основные переменные окружения: + +```env +PORT=3000 +SALT=iamsalt +HOST=27.0.0.1 +DB_HOST=localhost +DB_USER=admin +DB_PASSWORD=test +DB_PORT=27017 +DB_NAME=six-cities-2 +UPLOAD_DIRECTORY=upload +STATIC_DIRECTORY_PATH=static +JWT_SECRET=secret +``` -#### Удалить скомпилированный проект +Назначение переменных: -```bash -npm run clean -``` +- `PORT` - порт, на котором будет запущен REST API. +- `SALT` - соль для хеширования паролей. +- `HOST` - хост приложения. +- `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_PORT`, `DB_NAME` - параметры подключения к MongoDB. +- `UPLOAD_DIRECTORY` - директория для загружаемых файлов. +- `STATIC_DIRECTORY_PATH` - директория со статическими файлами. +- `JWT_SECRET` - секрет для подписи JWT. +- `DEFAULT_USER_PASSWORD` - пароль по умолчанию для создания новых пользователей при выполнении импорта. + +### 3. Запустить MongoDB -Удаляет директорию `dist`. Используется перед компиляцией. +Перед стартом приложения убедитесь, что сервер MongoDB запущен и доступен по параметрам из `.env`. Если база не будет доступна, REST API не сможет завершить инициализацию. -#### Собрать проект +### 4. Собрать проект ```bash npm run build @@ -38,41 +63,75 @@ npm run build Выполняет сборку проекта: удаляет ранее скомпилированный проект и компилирует заново. -#### Проверить линтером +### 5. Запустить REST API + +Для обычного запуска используйте: ```bash -npm run lint +npm start ``` -Запуск проверки проекта статическим анализатором кода **ESLint**. +Команда выполнит сборку и запустит сервер из `dist/main.rest.js`. -Линтер проверяет файлы только внутри директории `src`. +### 6. Запустить проект в режиме разработки -**Обратите внимание**, при запуске данной команды, ошибки выводятся в терминал. - -#### Запустить ts-модуль без компиляции +Во время разработки удобнее использовать: ```bash -npm run ts -- <Путь к модулю с ts-кодом> +npm run start:dev ``` -Пакет `ts-node` позволяет выполнить TS-код в Node.js без предварительной компиляции. Используется только на этапе разработки. +Этот сценарий также пересобирает проект перед запуском, а затем поднимает сервер через `nodemon` и выводит форматированные логи через `pino-pretty`. + +### 7. При необходимости поднять мок-сервер -#### Запустить проект +Если нужен локальный источник тестовых данных, выполните: ```bash -npm start +npm run mock:server ``` -В процессе запуска проекта будет выполнен процесс «Сборки проекта» и запуска результирующего кода. +После этого `json-server` поднимет мок-сервер на порту `3123`. -#### Запустить моковый сервер +## Сценарии -```bash -npm run mock:server -``` +В `package.json` предопределены следующие сценарии: + +### `npm start` + +Собирает проект и запускает REST API из `dist/main.rest.js`. Основной сценарий для локального запуска приложения. + +### `npm run start:dev` + +Собирает проект и запускает REST API в режиме разработки через `nodemon`. Подходит для разработки, когда важно автоматически перезапускать сервер и видеть читаемые логи. + +### `npm run build` + +Полная сборка проекта: сначала удаляет старую директорию `dist`, затем компилирует TypeScript-код заново. + +### `npm run lint` + +Проверяет код в директории `src` линтером **ESLint**. Используется после изменений и перед сдачей проекта, чтобы убедиться, что код соответствует правилам. + +### `npm run compile` + +Компилирует TypeScript в JavaScript по конфигурации из `tsconfig.json`. Удобно использовать, если очистка `dist` не требуется. + +### `npm run clean` + +Удаляет директорию `dist` с результатом предыдущей сборки. Обычно используется как подготовительный шаг перед новой компиляцией. + +### `npm run ts -- <путь-к-файлу>` + +Запускает TypeScript-файл напрямую через `ts-node` без предварительной сборки. Полезно для отладки отдельных модулей и запуска служебных скриптов во время разработки. + +### `npm run cli -- <аргументы>` + +Собирает проект и запускает CLI-приложение из `dist/main.cli.js`. Используется для работы с консольными командами проекта. + +### `npm run mock:server` -Используется для работы команды `--generate` для генерации тестовых данных. +Запускает `json-server` на порту `3123` с данными из `mocks/mock-server-data.json`. Используется для работы команды `--generate` для генерации тестовых данных. ## Структура проекта diff --git a/package.json b/package.json index 5ddbee6..9257db0 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "compile": "tsc -p tsconfig.json", "clean": "rimraf dist", "ts": "node --loader ts-node/esm", + "cli": "npm run build && node ./dist/main.cli.js", "mock:server": "json-server ./mocks/mock-server-data.json --port 3123" }, "devDependencies": { diff --git a/specification/project.spec.yml b/specification/project.spec.yml index aa785a4..78347cc 100644 --- a/specification/project.spec.yml +++ b/specification/project.spec.yml @@ -9,12 +9,12 @@ info: version: 2.0.0 tags: + - name: users + description: Действия с пользователем. - name: offers description: Действия с объявлениями. - name: comments description: Действия с комментариями. - - name: users - description: Действия с пользователем. paths: /users/register: @@ -35,10 +35,10 @@ paths: application/json: schema: $ref: '#/components/schemas/User' + '400': + description: Ошибка валидации '409': description: Пользователь уже существует - '422': - $ref: '#/components/responses/ValidationError' /users/login: post: @@ -57,21 +57,25 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/AuthToken' + $ref: '#/components/schemas/LoggedUser' '401': description: Неверный логин или пароль + '404': + description: Пользователь не найден get: tags: [users] summary: Проверка авторизации description: Возвращает информацию по авторизованному пользователю + security: + - bearerAuth: [] responses: '200': description: Пользователь авторизован content: application/json: schema: - $ref: '#/components/schemas/User' + $ref: '#/components/schemas/LoggedUser' '401': $ref: '#/components/responses/Unauthorized' @@ -79,21 +83,36 @@ paths: post: tags: [users] summary: Загрузка аватара - description: Загружает изображение аватара пользователя. Изображение - аватара должно быть в формате `png` или `jpg`. + description: Загружает jpg/png аватар пользователя + security: + - bearerAuth: [] parameters: - $ref: '#/components/parameters/userId' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - avatar + properties: + avatar: + type: string + format: binary responses: - '200': - description: Аватар загружен + '201': + description: Аватар успешно загружен content: application/json: schema: - $ref: '#/components/schemas/User' + $ref: '#/components/schemas/UploadUserAvatar' '400': description: Неверный формат файла '401': $ref: '#/components/responses/Unauthorized' + '403': + description: Доступ запрещён /offers: get: @@ -112,11 +131,13 @@ paths: schema: type: array items: - $ref: '#/components/schemas/OfferPreview' + $ref: '#/components/schemas/Offer' post: tags: [offers] summary: Создать предложение + security: + - bearerAuth: [] requestBody: required: true content: @@ -130,10 +151,10 @@ paths: application/json: schema: $ref: '#/components/schemas/Offer' + '400': + $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/Unauthorized' - '422': - $ref: '#/components/responses/ValidationError' /offers/{offerId}: get: @@ -156,6 +177,8 @@ paths: tags: [offers] summary: Обновить предложение description: Обновляет информацию о предложении + security: + - bearerAuth: [] parameters: - $ref: '#/components/parameters/offerId' requestBody: @@ -181,6 +204,8 @@ paths: delete: tags: [offers] summary: Удалить предложение + security: + - bearerAuth: [] parameters: - $ref: '#/components/parameters/offerId' responses: @@ -211,30 +236,6 @@ paths: items: $ref: '#/components/schemas/Comment' - post: - tags: [comments] - summary: Добавить комментарий - description: Добавляет новый комментарий к предложению - parameters: - - $ref: '#/components/parameters/offerId' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateComment' - responses: - '201': - description: Комментарий создан - content: - application/json: - schema: - $ref: '#/components/schemas/Comment' - '401': - $ref: '#/components/responses/Unauthorized' - '422': - $ref: '#/components/responses/ValidationError' - /offers/premium: get: tags: [offers] @@ -253,13 +254,17 @@ paths: type: array maxItems: 3 items: - $ref: '#/components/schemas/OfferPreview' + $ref: '#/components/schemas/Offer' + '400': + description: Неверно выбран город /offers/favorites: get: tags: [offers] summary: Избранные предложения description: Возвращает список предложений, добавленных в избранное + security: + - bearerAuth: [] responses: '200': description: Список избранных предложений @@ -268,7 +273,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/OfferPreview' + $ref: '#/components/schemas/Offer' '401': $ref: '#/components/responses/Unauthorized' @@ -277,15 +282,13 @@ paths: tags: [offers] summary: Добавить предложение в избранное description: Добавляет предложение в список избранных + security: + - bearerAuth: [] parameters: - $ref: '#/components/parameters/offerId' responses: - '200': + '204': description: Добавлено в избранное - content: - application/json: - schema: - $ref: '#/components/schemas/Offer' '401': $ref: '#/components/responses/Unauthorized' '404': @@ -295,21 +298,50 @@ paths: tags: [offers] summary: Удалить предложение из избранного description: Удаляет предложение из списка избранных + security: + - bearerAuth: [] parameters: - $ref: '#/components/parameters/offerId' responses: - '200': + '204': description: Удалено из избранного + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + /comments: + post: + tags: [comments] + summary: Добавить комментарий + description: Добавляет новый комментарий к предложению + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComment' + responses: + '201': + description: Комментарий создан content: application/json: schema: - $ref: '#/components/schemas/Offer' + $ref: '#/components/schemas/Comment' + '400': + $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: offerId: name: offerId @@ -337,7 +369,7 @@ components: in: query required: true schema: - $ref: '#/components/schemas/CityName' + $ref: '#/components/schemas/City' responses: Unauthorized: @@ -350,22 +382,36 @@ components: description: Ошибка валидации данных schemas: - CityName: + City: type: string enum: [Paris, Cologne, Brussels, Amsterdam, Hamburg, Dusseldorf] + Housing: + type: string + enum: [apartment, house, room, hotel] + UserBase: type: object + required: + - email + - firstname + - lastname + - type properties: email: type: string format: email + firstname: type: string + minLength: 1 + maxLength: 15 + lastname: type: string - avatarUrl: - type: string + minLength: 1 + maxLength: 15 + type: type: string enum: [pro, base] @@ -373,15 +419,17 @@ components: User: allOf: - type: object - required: [id] + required: [id, avatarPath] properties: id: type: string + avatarPath: + type: string - $ref: '#/components/schemas/UserBase' CreateUser: type: object - required: [email, firstname, password, type] + required: [email, firstname, lastname, password, type] properties: email: type: string @@ -402,6 +450,16 @@ components: type: string enum: [pro, base] + LoggedUser: + allOf: + - $ref: '#/components/schemas/UserBase' + - type: object + required: + - token + properties: + token: + type: string + LoginUser: type: object required: [email, password] @@ -412,10 +470,12 @@ components: password: type: string - AuthToken: + UploadUserAvatar: type: object + required: + - filepath properties: - token: + filepath: type: string Coordinates: @@ -427,18 +487,9 @@ components: longitude: type: number - City: - type: object - required: [name, location] - properties: - name: - $ref: '#/components/schemas/CityName' - location: - $ref: '#/components/schemas/Coordinates' - Comment: type: object - required: [id, text, date, rating, user] + required: [id, text, postDate, rating, user] properties: id: type: string @@ -446,28 +497,32 @@ components: type: string minLength: 5 maxLength: 1024 - date: - type: string - format: date-time rating: - type: integer + type: number + format: float minimum: 1 maximum: 5 + postDate: + type: string + format: date-time user: $ref: '#/components/schemas/User' CreateComment: type: object - required: [text, rating] + required: [text, rating, offerId] properties: text: type: string minLength: 5 maxLength: 1024 rating: - type: integer + type: number + format: float minimum: 1 maximum: 5 + offerId: + type: string OfferBase: type: object @@ -488,11 +543,8 @@ components: type: boolean isFavorite: type: boolean - rating: - type: number type: - type: string - enum: [apartment, house, room, hotel] + $ref: '#/components/schemas/Housing' rooms: type: integer guests: @@ -508,8 +560,9 @@ components: Offer: allOf: + - $ref: '#/components/schemas/OfferBase' - type: object - required: [id, publishDate, host, commentsCount] + required: [id, publishDate, host, rating, commentsCount] properties: id: type: string @@ -520,39 +573,9 @@ components: format: date-time commentsCount: type: integer - - $ref: '#/components/schemas/OfferBase' - - OfferPreview: - allOf: - - type: object - required: [id, publishDate, commentsCount] - properties: - id: - type: string - publishDate: - type: string - format: date-time - commentsCount: - type: integer - - type: object - properties: - title: - type: string - type: - type: string - enum: [apartment, house, room, hotel] - price: - type: integer - city: - $ref: '#/components/schemas/City' - previewImage: - type: string - isPremium: - type: boolean - isFavorite: - type: boolean rating: type: number + format: float CreateOffer: allOf: @@ -573,5 +596,36 @@ components: - location UpdateOffer: - allOf: - - $ref: '#/components/schemas/OfferBase' + type: object + additionalProperties: false + properties: + title: + type: string + description: + type: string + city: + $ref: '#/components/schemas/City' + previewImage: + type: string + images: + type: array + items: + type: string + isPremium: + type: boolean + isFavorite: + type: boolean + type: + $ref: '#/components/schemas/Housing' + rooms: + type: integer + guests: + type: integer + price: + type: integer + facilities: + type: array + items: + type: string + location: + $ref: '#/components/schemas/Coordinates' diff --git a/src/cli/commands/command.constant.ts b/src/cli/commands/command.constant.ts deleted file mode 100644 index c6c2d83..0000000 --- a/src/cli/commands/command.constant.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const DEFAULT_DB_PORT = '27017'; -export const DEFAULT_USER_PASSWORD = 'password'; diff --git a/src/cli/commands/help.command.ts b/src/cli/commands/help.command.ts index 88248e5..8051326 100644 --- a/src/cli/commands/help.command.ts +++ b/src/cli/commands/help.command.ts @@ -12,10 +12,10 @@ export class HelpCommand implements Command { Пример: cli.js -- [--arguments] Команды: - --version: # выводит номер версии - --help: # печатает этот текст - --import : # импортирует данные из TSV - --generate # генерирует произвольное количество тестовых данных + --version: # выводит номер версии + --help: # печатает этот текст + --import : # импортирует данные из TSV + --generate # генерирует произвольное количество тестовых данных `); } } diff --git a/src/cli/commands/import.command.ts b/src/cli/commands/import.command.ts index 719f6cd..8e882fb 100644 --- a/src/cli/commands/import.command.ts +++ b/src/cli/commands/import.command.ts @@ -22,9 +22,13 @@ import { } from '../../shared/libs/database-client/index.js'; import { ConsoleLogger, Logger } from '../../shared/libs/logger/index.js'; import { Offer } from '../../shared/types/index.js'; -import { DEFAULT_DB_PORT, DEFAULT_USER_PASSWORD } from './command.constant.js'; import { CommentModel } from '../../shared/modules/comment/index.js'; import { FavoriteModel } from '../../shared/modules/favorite/index.js'; +import { + Config, + RestConfig, + RestSchema, +} from '../../shared/libs/config/index.js'; export class ImportCommand implements Command { private userService: UserService; @@ -32,6 +36,7 @@ export class ImportCommand implements Command { private databaseClient: DatabaseClient; private logger: Logger; private salt: string; + private config: Config; constructor() { this.onImportedLine = this.onImportedLine.bind(this); @@ -46,6 +51,7 @@ export class ImportCommand implements Command { ); this.userService = new DefaultUserService(this.logger, UserModel); this.databaseClient = new MongoDatabaseClient(this.logger); + this.config = new RestConfig(this.logger); } public getName(): string { @@ -54,7 +60,7 @@ export class ImportCommand implements Command { private async saveOffer(offer: Offer) { const user = await this.userService.findOrCreate( - { ...offer.host, password: DEFAULT_USER_PASSWORD }, + { ...offer.host, password: this.config.get('DEFAULT_USER_PASSWORD') }, this.salt, ); @@ -97,7 +103,13 @@ export class ImportCommand implements Command { ): Promise { this.logger.info('Starting import...'); - const uri = getMongoURI(login, password, host, DEFAULT_DB_PORT, dbname); + const uri = getMongoURI( + login, + password, + host, + this.config.get('DB_PORT'), + dbname, + ); this.salt = salt; await this.databaseClient.connect(uri); diff --git a/src/shared/helpers/offer.ts b/src/shared/helpers/offer.ts index 34410fe..44a6bde 100644 --- a/src/shared/helpers/offer.ts +++ b/src/shared/helpers/offer.ts @@ -5,7 +5,6 @@ import { parseHousing, UserType, } from '../types/index.js'; -import { booleanFromString } from './types-helpers.js'; export function createOffer(offerData: string): Offer { const [ @@ -36,7 +35,7 @@ export function createOffer(offerData: string): Offer { city: City[city as keyof typeof City], previewImage, images: images.split(';'), - isPremium: booleanFromString(isPremium), + isPremium: isPremium.toLowerCase() === 'true', type: parseHousing(type), rooms: parseInt(rooms, 10), guests: parseInt(guests, 10), diff --git a/src/shared/helpers/types-helpers.ts b/src/shared/helpers/types-helpers.ts index a23c556..f60ba5e 100644 --- a/src/shared/helpers/types-helpers.ts +++ b/src/shared/helpers/types-helpers.ts @@ -1,11 +1,3 @@ -export const booleanFromString = (str: string) => str.toLowerCase() === 'true'; - -export function createStringUnionGuard(values: T) { - const set = new Set(values); - - return (value: string): value is T[number] => set.has(value as T[number]); -} - export function createStringUnionParser( values: T, ) { diff --git a/src/shared/libs/config/rest.schema.ts b/src/shared/libs/config/rest.schema.ts index 74c52db..afa542b 100644 --- a/src/shared/libs/config/rest.schema.ts +++ b/src/shared/libs/config/rest.schema.ts @@ -29,6 +29,7 @@ export type RestSchema = { JWT_SECRET: string; HOST: string; STATIC_DIRECTORY_PATH: string; + DEFAULT_USER_PASSWORD: string; }; export const configRestSchema = convict({ @@ -98,4 +99,10 @@ export const configRestSchema = convict({ env: 'STATIC_DIRECTORY_PATH', default: 'static', }, + DEFAULT_USER_PASSWORD: { + doc: 'Default user password for import', + format: String, + env: 'DEFAULT_USER_PASSWORD', + default: 'password', + }, }); diff --git a/src/shared/libs/file-reader/tsv-file-reader.ts b/src/shared/libs/file-reader/tsv-file-reader.ts index 297bf88..a2ffaa6 100644 --- a/src/shared/libs/file-reader/tsv-file-reader.ts +++ b/src/shared/libs/file-reader/tsv-file-reader.ts @@ -22,14 +22,20 @@ export class TSVFileReader extends EventEmitter implements FileReader { for await (const chunk of readStream) { remainingData += chunk.toString(); - while ((nextLinePosition = remainingData.indexOf('\n')) >= 0) { + nextLinePosition = remainingData.indexOf('\n'); + + while (nextLinePosition >= 0) { const completeRow = remainingData.slice(0, nextLinePosition + 1); - remainingData = remainingData.slice(++nextLinePosition); + + remainingData = remainingData.slice(nextLinePosition + 1); + importedRowCount++; await new Promise((resolve) => { this.emit('line', completeRow, resolve); }); + + nextLinePosition = remainingData.indexOf('\n'); } } diff --git a/src/shared/libs/offer-generator/tsv-offer-generator.ts b/src/shared/libs/offer-generator/tsv-offer-generator.ts index 43e96d1..f52c417 100644 --- a/src/shared/libs/offer-generator/tsv-offer-generator.ts +++ b/src/shared/libs/offer-generator/tsv-offer-generator.ts @@ -8,13 +8,32 @@ import { import { MockServerData } from '../../types/mock-server-data.type.js'; import { OfferGenerator } from './offer-generator.interface.js'; -const PRICE = { min: 100, max: 100000 }; -const GUESTS = { min: 1, max: 10 }; -const ROOMS = { min: 1, max: 8 }; -const FIRST_WEEK_DAY = 1; -const LAST_WEEK_DAY = 7; -const LONGITUDE = { min: 2, max: 11 }; -const LATITUDE = { min: 48, max: 54 }; +const OfferGenerationRange = { + Price: { + Min: 100, + Max: 100000, + }, + Guests: { + Min: 1, + Max: 10, + }, + Rooms: { + Min: 1, + Max: 8, + }, + Longitude: { + Min: 2, + Max: 11, + }, + Latitude: { + Min: 48, + Max: 54, + }, + PublishDays: { + Min: 1, + Max: 7, + }, +} as const; export class TSVOfferGenerator implements OfferGenerator { constructor(private readonly mockData: MockServerData) {} @@ -23,16 +42,31 @@ export class TSVOfferGenerator implements OfferGenerator { const title = getRandomItem(this.mockData.titles); const description = getRandomItem(this.mockData.descriptions); const publishDate = dayjs() - .subtract(generateRandomValue(FIRST_WEEK_DAY, LAST_WEEK_DAY), 'day') + .subtract( + generateRandomValue( + OfferGenerationRange.PublishDays.Min, + OfferGenerationRange.PublishDays.Max, + ), + 'day', + ) .toISOString(); const city = getRandomItem(this.mockData.cities); const previewImage = getRandomItem(this.mockData.previewImages); const images = getRandomItems(this.mockData.offerImages).join(';'); const isPremium = generateRandomBoolean(); const type = getRandomItem(this.mockData.housingTypes); - const rooms = generateRandomValue(ROOMS.min, ROOMS.max).toString(); - const guests = generateRandomValue(GUESTS.min, GUESTS.max).toString(); - const price = generateRandomValue(PRICE.min, PRICE.max).toString(); + const rooms = generateRandomValue( + OfferGenerationRange.Rooms.Min, + OfferGenerationRange.Rooms.Max, + ).toString(); + const guests = generateRandomValue( + OfferGenerationRange.Guests.Min, + OfferGenerationRange.Rooms.Max, + ).toString(); + const price = generateRandomValue( + OfferGenerationRange.Price.Min, + OfferGenerationRange.Price.Max, + ).toString(); const facilities = getRandomItems(this.mockData.facilities).join( ';', ); @@ -40,9 +74,17 @@ export class TSVOfferGenerator implements OfferGenerator { const email = getRandomItem(this.mockData.emails); const avatarPath = getRandomItem(this.mockData.avatars); const userType = generateRandomBoolean() ? 'pro' : 'base'; - const location = [ - generateRandomValue(LATITUDE.min, LATITUDE.max, 6), - generateRandomValue(LONGITUDE.min, LONGITUDE.max, 6), + const coordinates = [ + generateRandomValue( + OfferGenerationRange.Latitude.Min, + OfferGenerationRange.Latitude.Max, + 6, + ), + generateRandomValue( + OfferGenerationRange.Longitude.Min, + OfferGenerationRange.Longitude.Max, + 6, + ), ].join(';'); return [ @@ -63,7 +105,7 @@ export class TSVOfferGenerator implements OfferGenerator { email, avatarPath, userType, - location, + coordinates, ].join('\t'); } } diff --git a/src/shared/libs/rest/exception-filter/http.exception-filter.ts b/src/shared/libs/rest/exception-filter/http.exception-filter.ts index eadf442..cbd25ff 100644 --- a/src/shared/libs/rest/exception-filter/http.exception-filter.ts +++ b/src/shared/libs/rest/exception-filter/http.exception-filter.ts @@ -1,5 +1,4 @@ import { Request, Response, NextFunction } from 'express'; -import { StatusCodes } from 'http-status-codes'; import { inject, injectable } from 'inversify'; import { ExceptionFilter } from './exception-filter.interface.js'; import { Component } from '../../../types/index.js'; @@ -30,7 +29,7 @@ export class HttpErrorExceptionFilter implements ExceptionFilter { ); res - .status(StatusCodes.BAD_REQUEST) + .status(error.httpStatusCode) .json(createErrorObject(ApplicationError.CommonError, error.message)); } } diff --git a/src/shared/libs/rest/middleware/parse-token.middleware.ts b/src/shared/libs/rest/middleware/parse-token.middleware.ts index 798aef5..dd81065 100644 --- a/src/shared/libs/rest/middleware/parse-token.middleware.ts +++ b/src/shared/libs/rest/middleware/parse-token.middleware.ts @@ -43,12 +43,18 @@ export class ParseTokenMiddleware implements Middleware { createSecretKey(this.jwtSecret, 'utf-8'), ); - if (isTokenPayload(payload)) { - req.tokenPayload = { ...payload }; - return next(); - } else { - throw new Error('Bad token'); + if (!isTokenPayload(payload)) { + return next( + new HttpError( + StatusCodes.UNAUTHORIZED, + 'Invalid token payload', + 'AuthenticateMiddleware', + ), + ); } + + req.tokenPayload = { ...payload }; + return next(); } catch { return next( new HttpError( diff --git a/src/shared/libs/rest/transform/path-transformer.ts b/src/shared/libs/rest/transform/path-transformer.ts index a9d483c..7f32d86 100644 --- a/src/shared/libs/rest/transform/path-transformer.ts +++ b/src/shared/libs/rest/transform/path-transformer.ts @@ -9,7 +9,7 @@ import { import { getFullServerPath } from '../../../helpers/index.js'; import { STATIC_FILES_ROUTE, STATIC_UPLOAD_ROUTE } from '../rest.constant.js'; -function isObject(value: unknown): value is Record { +function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null; } @@ -30,18 +30,33 @@ export class PathTransformer { return STATIC_RESOURCE_FIELDS.includes(property); } - public execute(data: Record): Record { - const stack = [data]; + public execute(data: T): T { + if (!isObject(data)) { + return data; + } + + const stack: Record[] = [data]; + const visited = new WeakSet(); + + visited.add(data); while (stack.length > 0) { const current = stack.pop(); + if (!current) { + continue; + } + for (const key in current) { if (Object.hasOwn(current, key)) { const value = current[key]; if (isObject(value)) { - stack.push(value); + if (!visited.has(value)) { + visited.add(value); + stack.push(value); + } + continue; } @@ -54,6 +69,7 @@ export class PathTransformer { const rootPath = this.hasDefaultImage(value) ? staticPath : uploadPath; + current[key] = `${getFullServerPath(serverHost, serverPort)}${rootPath}/${value}`; } diff --git a/src/shared/modules/auth/default-auth.service.ts b/src/shared/modules/auth/default-auth.service.ts index a5a1cb9..52775eb 100644 --- a/src/shared/modules/auth/default-auth.service.ts +++ b/src/shared/modules/auth/default-auth.service.ts @@ -5,7 +5,7 @@ import { Logger } from '../../libs/logger/index.js'; import { Component } from '../../types/index.js'; import { LoginUserDto, UserEntity, UserService } from '../user/index.js'; import { Config, RestSchema } from '../../libs/config/index.js'; -import { TokenPayload } from './types/TokenPayload.js'; +import { TokenPayload } from './types/token-payload.type.js'; import { SignJWT } from 'jose'; import { JWT_ALGORITHM, JWT_EXPIRED } from './auth.constant.js'; import { diff --git a/src/shared/modules/auth/index.ts b/src/shared/modules/auth/index.ts index ea59cdb..ebd528e 100644 --- a/src/shared/modules/auth/index.ts +++ b/src/shared/modules/auth/index.ts @@ -1,4 +1,4 @@ export * from './auth-service.interface.js'; -export * from './types/TokenPayload.js'; +export * from './types/token-payload.type.js'; export * from './default-auth.service.js'; export * from './auth.container.js'; diff --git a/src/shared/modules/auth/types/TokenPayload.ts b/src/shared/modules/auth/types/token-payload.type.ts similarity index 100% rename from src/shared/modules/auth/types/TokenPayload.ts rename to src/shared/modules/auth/types/token-payload.type.ts diff --git a/src/shared/modules/comment/comment-service.interface.ts b/src/shared/modules/comment/comment-service.interface.ts index 782079c..426259a 100644 --- a/src/shared/modules/comment/comment-service.interface.ts +++ b/src/shared/modules/comment/comment-service.interface.ts @@ -4,6 +4,9 @@ import { CommentEntity } from './comment.entity.js'; export interface CommentService { create(dto: CreateCommentDTO): Promise>; - findByOfferId(offerId: string): Promise[]>; + findByOfferId( + offerId: string, + count?: number, + ): Promise[]>; deleteByOfferId(offerId: string): Promise; } diff --git a/src/shared/modules/comment/comment.constant.ts b/src/shared/modules/comment/comment.constant.ts new file mode 100644 index 0000000..36df0a1 --- /dev/null +++ b/src/shared/modules/comment/comment.constant.ts @@ -0,0 +1,10 @@ +export const CommentValidation = { + Text: { + MinLength: 5, + MaxLength: 1024, + }, + Rating: { + Min: 0, + Max: 5, + }, +} as const; diff --git a/src/shared/modules/comment/comment.controller.ts b/src/shared/modules/comment/comment.controller.ts index 750442f..0e08179 100644 --- a/src/shared/modules/comment/comment.controller.ts +++ b/src/shared/modules/comment/comment.controller.ts @@ -5,7 +5,6 @@ import { HttpMethod, ValidateDtoMiddleware, PrivateRouteMiddleware, - ValidateObjectIdMiddleware, } from '../../libs/rest/index.js'; import { Component } from '../../types/index.js'; import { Logger } from '../../libs/logger/index.js'; @@ -26,17 +25,7 @@ export default class CommentController extends BaseController { ) { super(logger); - this.logger.info('Register routes for CommentController...'); - this.addRoute({ - path: '/', - method: HttpMethod.Post, - handler: this.create, - middlewares: [ - new PrivateRouteMiddleware(), - new ValidateDtoMiddleware(CreateCommentDTO), - new ValidateObjectIdMiddleware('offerId'), - ], - }); + this.registerRoutes(); } public async create( @@ -52,4 +41,17 @@ export default class CommentController extends BaseController { await this.offerService.calcRating(offerId); this.created(res, fillDTO(CommentRdo, comment)); } + + private registerRoutes(): void { + this.logger.info('Register routes for CommentController...'); + this.addRoute({ + path: '/', + method: HttpMethod.Post, + handler: this.create, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateDtoMiddleware(CreateCommentDTO), + ], + }); + } } diff --git a/src/shared/modules/comment/comment.entity.ts b/src/shared/modules/comment/comment.entity.ts index 718df10..23bfb6c 100644 --- a/src/shared/modules/comment/comment.entity.ts +++ b/src/shared/modules/comment/comment.entity.ts @@ -7,6 +7,7 @@ import { } from '@typegoose/typegoose'; import { OfferEntity } from '../offer/index.js'; import { UserEntity } from '../user/index.js'; +import { CommentValidation } from './comment.constant.js'; // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export interface CommentEntity extends defaultClasses.Base {} @@ -22,7 +23,12 @@ export class CommentEntity extends defaultClasses.TimeStamps { @prop({ trim: true, required: true }) public text: string; - @prop({ required: true, min: 1, max: 5, default: 0 }) + @prop({ + required: true, + default: 0, + min: CommentValidation.Rating.Min, + max: CommentValidation.Rating.Max, + }) public rating: number; @prop({ ref: OfferEntity, required: true }) diff --git a/src/shared/modules/comment/comment.http b/src/shared/modules/comment/comment.http index 4755fc8..7e749d0 100644 --- a/src/shared/modules/comment/comment.http +++ b/src/shared/modules/comment/comment.http @@ -3,13 +3,12 @@ POST http://localhost:3000/comments HTTP/1.1 Content-Type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNmExNDc1ZDVlOGRmN2MwNDEyNTMwYTgyIiwiaWF0IjoxNzc5NzI1Nzg3LCJleHAiOjE3Nzk4OTg1ODd9.hFl1pYW4ARmBGxkFB4JuxP48xWCg4GI80RUqDCbyX_w { - "text": "Грязища, фу!", - "rating": 1, - "offerId": "69c98315edbb02aef834bfd1", - "userId": "69c982e424f79692fbaad886" + "text": "Great 2!", + "rating": 4, + "offerId": "6a1475f5e8df7c0412530a85" } ### diff --git a/src/shared/modules/comment/default-comment.service.ts b/src/shared/modules/comment/default-comment.service.ts index 514eff5..913800f 100644 --- a/src/shared/modules/comment/default-comment.service.ts +++ b/src/shared/modules/comment/default-comment.service.ts @@ -1,6 +1,6 @@ import { inject, injectable } from 'inversify'; import { CommentService } from './comment-service.interface.js'; -import { Component } from '../../types/index.js'; +import { Component, SortType } from '../../types/index.js'; import { DocumentType, types } from '@typegoose/typegoose'; import { CommentEntity } from './comment.entity.js'; import { CreateCommentDTO } from './dto/create-comment.dto.js'; @@ -21,8 +21,18 @@ export class DefaultCommentService implements CommentService { public async findByOfferId( offerId: string, + count?: number, ): Promise[]> { - return this.commentModel.find({ offerId }).populate('userId'); + const query = this.commentModel + .find({ offerId }) + .sort({ createdAt: SortType.Down }) + .populate('userId'); + + if (count) { + query.limit(count); + } + + return query.exec(); } public async deleteByOfferId(offerId: string): Promise { diff --git a/src/shared/modules/comment/dto/create-comment.dto.ts b/src/shared/modules/comment/dto/create-comment.dto.ts index 168a851..76bfeb9 100644 --- a/src/shared/modules/comment/dto/create-comment.dto.ts +++ b/src/shared/modules/comment/dto/create-comment.dto.ts @@ -1,14 +1,21 @@ import { IsInt, IsMongoId, IsString, Length, Max, Min } from 'class-validator'; import { CreateCommentMessages } from './create-comment.messages.js'; +import { CommentValidation } from '../comment.constant.js'; export class CreateCommentDTO { @IsString({ message: CreateCommentMessages.text.invalidFormat }) - @Length(5, 1024, { message: 'min is 5, max is 1024 ' }) + @Length(CommentValidation.Text.MinLength, CommentValidation.Text.MaxLength, { + message: CreateCommentMessages.text.lengthField, + }) public text: string; @IsInt({ message: CreateCommentMessages.rating.invalidFormat }) - @Min(0, { message: CreateCommentMessages.rating.minValue }) - @Max(5, { message: CreateCommentMessages.rating.maxValue }) + @Min(CommentValidation.Rating.Min, { + message: CreateCommentMessages.rating.minValue, + }) + @Max(CommentValidation.Rating.Max, { + message: CreateCommentMessages.rating.maxValue, + }) public rating: number; @IsMongoId({ message: CreateCommentMessages.offerId.invalidFormat }) diff --git a/src/shared/modules/comment/dto/create-comment.messages.ts b/src/shared/modules/comment/dto/create-comment.messages.ts index c19771a..325105f 100644 --- a/src/shared/modules/comment/dto/create-comment.messages.ts +++ b/src/shared/modules/comment/dto/create-comment.messages.ts @@ -1,7 +1,9 @@ +import { CommentValidation } from '../comment.constant.js'; + export const CreateCommentMessages = { text: { invalidFormat: 'text is required', - lengthField: 'min length is 5, max is 2024', + lengthField: `min length is ${CommentValidation.Text.MinLength}, max is ${CommentValidation.Text.MaxLength}`, }, offerId: { invalidFormat: 'offerId field must be a valid id', @@ -11,7 +13,7 @@ export const CreateCommentMessages = { }, rating: { invalidFormat: 'rating must be an integer', - minValue: 'Minimum rating is 0', - maxValue: 'Maximum rating is 5', + minValue: `Minimum rating is ${CommentValidation.Rating.Min}`, + maxValue: `Maximum rating is ${CommentValidation.Rating.Min}`, }, } as const; diff --git a/src/shared/modules/favorite/default-favorite.service.ts b/src/shared/modules/favorite/default-favorite.service.ts index d6921bb..c80f2ba 100644 --- a/src/shared/modules/favorite/default-favorite.service.ts +++ b/src/shared/modules/favorite/default-favorite.service.ts @@ -1,7 +1,6 @@ import { inject, injectable } from 'inversify'; import { FavoriteService } from './favorite-service.interface.js'; import { Component } from '../../types/index.js'; -import { Logger } from '../../libs/logger/index.js'; import { types } from '@typegoose/typegoose'; import { FavoriteEntity } from './favorite.entity.js'; import { OfferEntity, OfferWithFavorite } from '../offer/index.js'; @@ -9,7 +8,6 @@ import { OfferEntity, OfferWithFavorite } from '../offer/index.js'; @injectable() export class DefaultFavoriteService implements FavoriteService { constructor( - @inject(Component.Logger) private readonly logger: Logger, @inject(Component.FavoriteModel) private readonly favoriteModel: types.ModelType, ) {} @@ -20,41 +18,17 @@ export class DefaultFavoriteService implements FavoriteService { { $setOnInsert: { userId, offerId } }, { upsert: true }, ); - - this.logger.info(`Offer ${offerId} added to favorites for user ${userId}`); } - public async remove(userId: string, offerId: string): Promise { + public async delete(userId: string, offerId: string): Promise { await this.favoriteModel.deleteOne({ userId, offerId }).exec(); - this.logger.info( - `Offer ${offerId} removed from favorites for user ${userId}`, - ); } - public async removeByOfferId(offerId: string): Promise { + public async deleteByOfferId(offerId: string): Promise { await this.favoriteModel.deleteMany({ offerId }).exec(); - this.logger.info(`All favorites removed for offer ${offerId}`); - } - - public async getFavoriteStatus( - userId: string, - offerId: string, - ): Promise { - const result = await this.favoriteModel.exists({ userId, offerId }).exec(); - - return result !== null; - } - - public async getOfferIdsByUser(userId: string): Promise { - const favorites = await this.favoriteModel - .find({ userId }) - .select('offerId') - .lean(); - - return favorites.map((item) => item.offerId.toString()); } - public async getFavoriteOffers(userId: string): Promise { + public async getOffers(userId: string): Promise { const favorites = await this.favoriteModel .find({ userId }) .populate({ @@ -63,8 +37,8 @@ export class DefaultFavoriteService implements FavoriteService { }) .lean(); - return favorites.map((fav) => ({ - ...(fav.offerId as OfferEntity), + return favorites.map((favorite) => ({ + ...(favorite.offerId as OfferEntity), isFavorite: true, })); } diff --git a/src/shared/modules/favorite/favorite-service.interface.ts b/src/shared/modules/favorite/favorite-service.interface.ts index 4aa0b54..ea03527 100644 --- a/src/shared/modules/favorite/favorite-service.interface.ts +++ b/src/shared/modules/favorite/favorite-service.interface.ts @@ -2,10 +2,8 @@ import { OfferWithFavorite } from '../offer/index.js'; export interface FavoriteService { add(userId: string, offerId: string): Promise; - remove(userId: string, offerId: string): Promise; - removeByOfferId(offerId: string): Promise; - getFavoriteStatus(userId: string, offerId: string): Promise; - getOfferIdsByUser(userId: string): Promise; - getFavoriteOffers(userId: string): Promise; + delete(userId: string, offerId: string): Promise; + deleteByOfferId(offerId: string): Promise; + getOffers(userId: string): Promise; exists(documentId: string): Promise; } diff --git a/src/shared/modules/offer/default-offer.service.ts b/src/shared/modules/offer/default-offer.service.ts index d40dbf3..6982598 100644 --- a/src/shared/modules/offer/default-offer.service.ts +++ b/src/shared/modules/offer/default-offer.service.ts @@ -1,4 +1,5 @@ import { inject, injectable } from 'inversify'; +import { StatusCodes } from 'http-status-codes'; import { OfferService } from './offer-service.interface.js'; import { City, Component, SortType } from '../../types/index.js'; import { Logger } from '../../libs/logger/index.js'; @@ -13,6 +14,7 @@ import { import { CommentEntity } from '../comment/index.js'; import { Types } from 'mongoose'; import { FavoriteEntity } from '../favorite/index.js'; +import { HttpError } from '../../libs/rest/index.js'; @injectable() export class DefaultOfferService implements OfferService { @@ -70,36 +72,39 @@ export class DefaultOfferService implements OfferService { ): Promise { const offers = await this.offerModel .find() + .sort({ createdAt: SortType.Down }) .limit(count ?? DEFAULT_OFFER_COUNT) .populate(['userId']) .lean(); if (!userId) { - return offers.map((offer) => ({ ...offer, isFavorite: false })); + return offers.map((offer) => ({ + ...offer, + isFavorite: false, + })); } - const favoriteIds = await this.favoriteModel.distinct('offerId', { - userId, - }); - - const set = new Set(favoriteIds.map(String)); + const favoriteIds = await this.getFavoriteIds(userId); - return offers.map((offer) => ({ - ...offer, - isFavorite: set.has(offer._id.toString()), - })); + return this.addFavoriteFlag(offers, favoriteIds); } public async deleteById( offerId: string, + userId: string, ): Promise | null> { + await this.checkOwner(offerId, userId); + return this.offerModel.findByIdAndDelete(offerId).exec(); } public async updateById( offerId: string, + userId: string, dto: UpdateOfferDTO, ): Promise | null> { + await this.checkOwner(offerId, userId); + return this.offerModel .findByIdAndUpdate(offerId, dto, { new: true }) .exec(); @@ -115,7 +120,7 @@ export class DefaultOfferService implements OfferService { return this.offerModel .findByIdAndUpdate(offerId, { $inc: { - commentCount: 1, + commentsCount: 1, }, }) .exec(); @@ -151,8 +156,12 @@ export class DefaultOfferService implements OfferService { userId?: string, count?: number, ): Promise { - if (!city) { - return []; + if (!Object.values(City).includes(city)) { + throw new HttpError( + StatusCodes.BAD_REQUEST, + 'City is missing or incorrect', + 'OfferController', + ); } const premiumOffers = await this.offerModel @@ -164,18 +173,48 @@ export class DefaultOfferService implements OfferService { .exec(); if (!userId) { - return premiumOffers.map((offer) => ({ ...offer, isFavorite: false })); + return premiumOffers.map((offer) => ({ + ...offer, + isFavorite: false, + })); } + const favoriteIds = await this.getFavoriteIds(userId); + + return this.addFavoriteFlag(premiumOffers, favoriteIds); + } + + private async getFavoriteIds(userId: string): Promise> { const favoriteIds = await this.favoriteModel.distinct('offerId', { userId, }); - const set = new Set(favoriteIds.map(String)); + return new Set(favoriteIds.map(String)); + } - return premiumOffers.map((offer) => ({ + private addFavoriteFlag( + offers: OfferEntity[], + favoriteIds: Set, + ): OfferWithFavorite[] { + return offers.map((offer) => ({ ...offer, - isFavorite: set.has(offer._id.toString()), + isFavorite: favoriteIds.has(offer._id.toString()), })); } + + private async checkOwner(offerId: string, userId: string): Promise { + const offer = await this.offerModel.findById(offerId); + + if (!offer) { + throw new HttpError( + StatusCodes.NOT_FOUND, + 'Offer not found', + 'OfferService', + ); + } + + if (offer.userId.toString() !== userId) { + throw new HttpError(StatusCodes.FORBIDDEN, 'Forbidden', 'OfferService'); + } + } } diff --git a/src/shared/modules/offer/dto/coordinates.dto.ts b/src/shared/modules/offer/dto/coordinates.dto.ts index 3c8f1be..66cf5ae 100644 --- a/src/shared/modules/offer/dto/coordinates.dto.ts +++ b/src/shared/modules/offer/dto/coordinates.dto.ts @@ -1,21 +1,30 @@ /* eslint-disable indent */ import { IsNumber, Max, Min } from 'class-validator'; import { CreateOfferValidationMessage } from './create-offer.messages.js'; +import { OfferValidation } from '../offer.constant.js'; export class CoordinatesDto { @IsNumber( {}, { message: CreateOfferValidationMessage.latitude.invalidFormat }, ) - @Min(-90, { message: CreateOfferValidationMessage.latitude.minValue }) - @Max(90, { message: CreateOfferValidationMessage.latitude.maxValue }) + @Min(OfferValidation.Location.Latitude.Min, { + message: CreateOfferValidationMessage.latitude.minValue, + }) + @Max(OfferValidation.Location.Latitude.Max, { + message: CreateOfferValidationMessage.latitude.maxValue, + }) latitude: number; @IsNumber( {}, { message: CreateOfferValidationMessage.longitude.invalidFormat }, ) - @Min(-180, { message: CreateOfferValidationMessage.longitude.minValue }) - @Max(180, { message: CreateOfferValidationMessage.longitude.maxValue }) + @Min(OfferValidation.Location.Longitude.Min, { + message: CreateOfferValidationMessage.longitude.minValue, + }) + @Max(OfferValidation.Location.Longitude.Max, { + message: CreateOfferValidationMessage.longitude.maxValue, + }) longitude: number; } diff --git a/src/shared/modules/offer/dto/create-offer.dto.ts b/src/shared/modules/offer/dto/create-offer.dto.ts index 0b2eaa1..2cd4807 100644 --- a/src/shared/modules/offer/dto/create-offer.dto.ts +++ b/src/shared/modules/offer/dto/create-offer.dto.ts @@ -1,5 +1,6 @@ /* eslint-disable indent */ import { + ArrayMaxSize, ArrayMinSize, IsArray, IsBoolean, @@ -7,6 +8,7 @@ import { IsEnum, IsIn, IsInt, + IsUrl, Max, MaxLength, Min, @@ -23,16 +25,21 @@ import { import { CreateOfferValidationMessage } from './create-offer.messages.js'; import { Type } from 'class-transformer'; import { CoordinatesDto } from './coordinates.dto.js'; +import { OFFER_IMAGES_LENGTH, OfferValidation } from '../offer.constant.js'; export class CreateOfferDTO { - @MinLength(10, { message: CreateOfferValidationMessage.title.minLength }) - @MaxLength(100, { message: CreateOfferValidationMessage.title.maxLength }) + @MinLength(OfferValidation.Title.MinLength, { + message: CreateOfferValidationMessage.title.minLength, + }) + @MaxLength(OfferValidation.Title.MaxLength, { + message: CreateOfferValidationMessage.title.maxLength, + }) title: string; - @MinLength(20, { + @MinLength(OfferValidation.Description.MinLength, { message: CreateOfferValidationMessage.description.minLength, }) - @MaxLength(1024, { + @MaxLength(OfferValidation.Description.MaxLength, { message: CreateOfferValidationMessage.description.maxLength, }) description: string; @@ -46,6 +53,21 @@ export class CreateOfferDTO { @IsEnum(City, { message: CreateOfferValidationMessage.city.invalid }) city: City; + @IsUrl( + {}, + { message: CreateOfferValidationMessage.previewImage.invalidFormat }, + ) + public previewImage?: string; + + @IsArray({ message: CreateOfferValidationMessage.images.invalidFormat }) + @ArrayMinSize(OFFER_IMAGES_LENGTH, { + message: CreateOfferValidationMessage.images.invalidLength, + }) + @ArrayMaxSize(OFFER_IMAGES_LENGTH, { + message: CreateOfferValidationMessage.images.invalidLength, + }) + public images?: string[]; + @IsBoolean({ message: CreateOfferValidationMessage.isPremium.invalidFormat }) isPremium: boolean; @@ -53,22 +75,34 @@ export class CreateOfferDTO { type: Housing; @IsInt({ message: CreateOfferValidationMessage.rooms.invalidFormat }) - @Min(1, { message: CreateOfferValidationMessage.rooms.minValue }) - @Max(8, { message: CreateOfferValidationMessage.rooms.maxValue }) + @Min(OfferValidation.Rooms.Min, { + message: CreateOfferValidationMessage.rooms.minValue, + }) + @Max(OfferValidation.Rooms.Max, { + message: CreateOfferValidationMessage.rooms.maxValue, + }) rooms: number; @IsInt({ message: CreateOfferValidationMessage.guests.invalidFormat }) - @Min(1, { message: CreateOfferValidationMessage.guests.minValue }) - @Max(10, { message: CreateOfferValidationMessage.guests.maxValue }) + @Min(OfferValidation.Guests.Min, { + message: CreateOfferValidationMessage.guests.minValue, + }) + @Max(OfferValidation.Guests.Max, { + message: CreateOfferValidationMessage.guests.maxValue, + }) guests: number; @IsInt({ message: CreateOfferValidationMessage.price.invalidFormat }) - @Min(100, { message: CreateOfferValidationMessage.price.minValue }) - @Max(100000, { message: CreateOfferValidationMessage.price.maxValue }) + @Min(OfferValidation.Price.Min, { + message: CreateOfferValidationMessage.price.minValue, + }) + @Max(OfferValidation.Price.Max, { + message: CreateOfferValidationMessage.price.maxValue, + }) price: number; @IsArray({ message: CreateOfferValidationMessage.facilities.invalidFormat }) - @ArrayMinSize(1, { + @ArrayMinSize(OfferValidation.Facilities.MinLength, { message: CreateOfferValidationMessage.facilities.invalidLength, }) @IsIn(FacilityValues, { diff --git a/src/shared/modules/offer/dto/create-offer.messages.ts b/src/shared/modules/offer/dto/create-offer.messages.ts index 08306e3..5ed68f0 100644 --- a/src/shared/modules/offer/dto/create-offer.messages.ts +++ b/src/shared/modules/offer/dto/create-offer.messages.ts @@ -1,11 +1,13 @@ +import { OFFER_IMAGES_LENGTH, OfferValidation } from '../offer.constant.js'; + export const CreateOfferValidationMessage = { title: { - minLength: 'Minimum title length must be 10', - maxLength: 'Maximum title length must be 100', + minLength: `Minimum title length must be ${OfferValidation.Title.MinLength}`, + maxLength: `Maximum title length must be ${OfferValidation.Title.MinLength}`, }, description: { - minLength: 'Minimum description length must be 20', - maxLength: 'Maximum description length must be 1024', + minLength: `Minimum description length must be ${OfferValidation.Description.MinLength}`, + maxLength: `Maximum description length must be ${OfferValidation.Description.MinLength}`, }, publishDate: { invalidFormat: 'publishDate must be a valid ISO date', @@ -18,7 +20,7 @@ export const CreateOfferValidationMessage = { }, images: { invalidFormat: 'images must be an array of strings', - invalidLength: 'Exactly 6 images are required', + invalidLength: `Exactly ${OFFER_IMAGES_LENGTH} images are required`, }, isPremium: { invalidFormat: 'isPremium must be a boolean', @@ -28,21 +30,21 @@ export const CreateOfferValidationMessage = { }, rooms: { invalidFormat: 'Rooms must be an integer', - minValue: 'Minimum rooms is 1', - maxValue: 'Maximum rooms is 8', + minValue: `Minimum rooms is ${OfferValidation.Rooms.Min}`, + maxValue: `Maximum rooms is ${OfferValidation.Rooms.Max}`, }, guests: { invalidFormat: 'Guests must be an integer', - minValue: 'Minimum guests is 1', - maxValue: 'Maximum guests is 10', + minValue: `Minimum guests is ${OfferValidation.Guests.Min}`, + maxValue: `Maximum guests is ${OfferValidation.Guests.Min}`, }, price: { invalidFormat: 'Price must be an integer', - minValue: 'Minimum price is 100', - maxValue: 'Maximum price is 100000', + minValue: `Minimum price is ${OfferValidation.Price.Min}`, + maxValue: `Maximum price is ${OfferValidation.Guests.Max}`, }, facilities: { - invalidLength: 'At least 1 item required', + invalidLength: `At least ${OfferValidation.Facilities.MinLength} item required`, invalidFormat: 'facilities must be an array', invalidValue: 'facilities must contain valid Facility values', }, @@ -54,12 +56,12 @@ export const CreateOfferValidationMessage = { }, latitude: { invalidFormat: 'latitude must be a number', - minValue: 'Minimum latitude is -90', - maxValue: 'Maximum latitude is 90', + minValue: `Minimum latitude is ${OfferValidation.Location.Latitude.Min}`, + maxValue: `Maximum latitude is ${OfferValidation.Location.Latitude.Max}`, }, longitude: { invalidFormat: 'longitude must be a number', - minValue: 'Minimum longitude is -180', - maxValue: 'Maximum longitude is 180', + minValue: `Minimum longitude is ${OfferValidation.Location.Longitude.Min}`, + maxValue: `Maximum longitude is ${OfferValidation.Location.Longitude.Max}`, }, } as const; diff --git a/src/shared/modules/offer/dto/update-offer.dto.ts b/src/shared/modules/offer/dto/update-offer.dto.ts index 7453e78..9493ecc 100644 --- a/src/shared/modules/offer/dto/update-offer.dto.ts +++ b/src/shared/modules/offer/dto/update-offer.dto.ts @@ -26,18 +26,23 @@ import { } from '../../../types/index.js'; import { CoordinatesDto } from './coordinates.dto.js'; import { CreateUpdateOfferMessage } from './update-offer.messages.js'; +import { OFFER_IMAGES_LENGTH, OfferValidation } from '../offer.constant.js'; export class UpdateOfferDTO { @IsOptional() - @MinLength(10, { message: CreateUpdateOfferMessage.title.minLength }) - @MaxLength(100, { message: CreateUpdateOfferMessage.title.maxLength }) + @MinLength(OfferValidation.Title.MinLength, { + message: CreateUpdateOfferMessage.title.minLength, + }) + @MaxLength(OfferValidation.Title.MaxLength, { + message: CreateUpdateOfferMessage.title.maxLength, + }) public title?: string; @IsOptional() - @MinLength(20, { + @MinLength(OfferValidation.Description.MinLength, { message: CreateUpdateOfferMessage.description.minLength, }) - @MaxLength(1024, { + @MaxLength(OfferValidation.Description.MaxLength, { message: CreateUpdateOfferMessage.description.maxLength, }) public description?: string; @@ -59,10 +64,10 @@ export class UpdateOfferDTO { @IsOptional() @IsArray({ message: CreateUpdateOfferMessage.images.invalidFormat }) - @ArrayMinSize(6, { + @ArrayMinSize(OFFER_IMAGES_LENGTH, { message: CreateUpdateOfferMessage.images.invalidLength, }) - @ArrayMaxSize(6, { + @ArrayMaxSize(OFFER_IMAGES_LENGTH, { message: CreateUpdateOfferMessage.images.invalidLength, }) public images?: string[]; @@ -77,25 +82,37 @@ export class UpdateOfferDTO { @IsOptional() @IsInt({ message: CreateUpdateOfferMessage.rooms.invalidFormat }) - @Min(1, { message: CreateUpdateOfferMessage.rooms.minValue }) - @Max(8, { message: CreateUpdateOfferMessage.rooms.maxValue }) + @Min(OfferValidation.Rooms.Min, { + message: CreateUpdateOfferMessage.rooms.minValue, + }) + @Max(OfferValidation.Rooms.Max, { + message: CreateUpdateOfferMessage.rooms.maxValue, + }) public rooms?: number; @IsOptional() @IsInt({ message: CreateUpdateOfferMessage.guests.invalidFormat }) - @Min(1, { message: CreateUpdateOfferMessage.guests.minValue }) - @Max(10, { message: CreateUpdateOfferMessage.guests.maxValue }) + @Min(OfferValidation.Guests.Min, { + message: CreateUpdateOfferMessage.guests.minValue, + }) + @Max(OfferValidation.Guests.Max, { + message: CreateUpdateOfferMessage.guests.maxValue, + }) public guests?: number; @IsOptional() @IsInt({ message: CreateUpdateOfferMessage.price.invalidFormat }) - @Min(100, { message: CreateUpdateOfferMessage.price.minValue }) - @Max(100000, { message: CreateUpdateOfferMessage.price.maxValue }) + @Min(OfferValidation.Price.Min, { + message: CreateUpdateOfferMessage.price.minValue, + }) + @Max(OfferValidation.Price.Max, { + message: CreateUpdateOfferMessage.price.maxValue, + }) public price?: number; @IsOptional() @IsArray({ message: CreateUpdateOfferMessage.facilities.invalidFormat }) - @ArrayMinSize(1, { + @ArrayMinSize(OfferValidation.Facilities.MinLength, { message: CreateUpdateOfferMessage.facilities.invalidLength, }) @IsIn(FacilityValues, { diff --git a/src/shared/modules/offer/dto/update-offer.messages.ts b/src/shared/modules/offer/dto/update-offer.messages.ts index db2ea42..e3627b8 100644 --- a/src/shared/modules/offer/dto/update-offer.messages.ts +++ b/src/shared/modules/offer/dto/update-offer.messages.ts @@ -1,11 +1,13 @@ +import { OFFER_IMAGES_LENGTH, OfferValidation } from '../offer.constant.js'; + export const CreateUpdateOfferMessage = { title: { - minLength: 'Minimum title length must be 10', - maxLength: 'Maximum title length must be 100', + minLength: `Minimum title length must be ${OfferValidation.Title.MinLength}`, + maxLength: `Maximum title length must be ${OfferValidation.Title.MinLength}`, }, description: { - minLength: 'Minimum description length must be 20', - maxLength: 'Maximum description length must be 1024', + minLength: `Minimum description length must be ${OfferValidation.Description.MinLength}`, + maxLength: `Maximum description length must be ${OfferValidation.Description.MinLength}`, }, publishDate: { invalidFormat: 'publishDate must be a valid ISO date', @@ -18,36 +20,31 @@ export const CreateUpdateOfferMessage = { }, images: { invalidFormat: 'images must be an array of strings', - invalidLength: 'Exactly 6 images are required', + invalidLength: `Exactly ${OFFER_IMAGES_LENGTH} images are required`, }, isPremium: { invalidFormat: 'isPremium must be a boolean', }, - rating: { - invalidFormat: 'rating must be a number with max 1 decimal place', - minValue: 'Minimum rating is 0', - maxValue: 'Maximum rating is 5', - }, type: { invalid: 'type must be one of Housing', }, rooms: { invalidFormat: 'Rooms must be an integer', - minValue: 'Minimum rooms is 1', - maxValue: 'Maximum rooms is 8', + minValue: `Minimum rooms is ${OfferValidation.Rooms.Min}`, + maxValue: `Maximum rooms is ${OfferValidation.Rooms.Max}`, }, guests: { invalidFormat: 'Guests must be an integer', - minValue: 'Minimum guests is 1', - maxValue: 'Maximum guests is 10', + minValue: `Minimum guests is ${OfferValidation.Guests.Min}`, + maxValue: `Maximum guests is ${OfferValidation.Guests.Min}`, }, price: { invalidFormat: 'Price must be an integer', - minValue: 'Minimum price is 100', - maxValue: 'Maximum price is 100000', + minValue: `Minimum price is ${OfferValidation.Price.Min}`, + maxValue: `Maximum price is ${OfferValidation.Guests.Max}`, }, facilities: { - invalidLength: 'At least 1 item required', + invalidLength: `At least ${OfferValidation.Facilities.MinLength} item required`, invalidFormat: 'facilities must be an array', invalidValue: 'facilities must contain valid Facility values', }, @@ -56,12 +53,12 @@ export const CreateUpdateOfferMessage = { }, latitude: { invalidFormat: 'latitude must be a number', - minValue: 'Minimum latitude is -90', - maxValue: 'Maximum latitude is 90', + minValue: `Minimum latitude is ${OfferValidation.Location.Latitude.Min}`, + maxValue: `Maximum latitude is ${OfferValidation.Location.Latitude.Max}`, }, longitude: { invalidFormat: 'longitude must be a number', - minValue: 'Minimum longitude is -180', - maxValue: 'Maximum longitude is 180', + minValue: `Minimum longitude is ${OfferValidation.Location.Longitude.Min}`, + maxValue: `Maximum longitude is ${OfferValidation.Location.Longitude.Max}`, }, } as const; diff --git a/src/shared/modules/offer/offer-service.interface.ts b/src/shared/modules/offer/offer-service.interface.ts index b85a07f..e438520 100644 --- a/src/shared/modules/offer/offer-service.interface.ts +++ b/src/shared/modules/offer/offer-service.interface.ts @@ -8,9 +8,13 @@ export interface OfferService extends DocumentExists { create(dto: CreateOfferDTO): Promise>; findById(offerId: string, userId?: string): Promise; find(count?: number, userId?: string): Promise; - deleteById(offerId: string): Promise | null>; + deleteById( + offerId: string, + userId: string, + ): Promise | null>; updateById( offerId: string, + userId: string, dto: UpdateOfferDTO, ): Promise | null>; incCommentCount(offerId: string): Promise | null>; diff --git a/src/shared/modules/offer/offer.constant.ts b/src/shared/modules/offer/offer.constant.ts index ddbcd56..832941d 100644 --- a/src/shared/modules/offer/offer.constant.ts +++ b/src/shared/modules/offer/offer.constant.ts @@ -1,3 +1,40 @@ export const DEFAULT_OFFER_COUNT = 60; export const DEFAULT_PREMIUM_OFFER_COUNT = 3; export const OFFER_IMAGES_LENGTH = 6; +export const DEFAULT_COMMENTS_COUNT = 50; + +export const OfferValidation = { + Title: { + MinLength: 10, + MaxLength: 100, + }, + Description: { + MinLength: 20, + MaxLength: 1024, + }, + Rooms: { + Min: 1, + Max: 8, + }, + Guests: { + Min: 1, + Max: 10, + }, + Price: { + Min: 100, + Max: 100000, + }, + Facilities: { + MinLength: 1, + }, + Location: { + Latitude: { + Min: -90, + Max: 90, + }, + Longitude: { + Min: -180, + Max: 180, + }, + }, +} as const; diff --git a/src/shared/modules/offer/offer.controller.ts b/src/shared/modules/offer/offer.controller.ts index f7d52d0..1fe1cb0 100644 --- a/src/shared/modules/offer/offer.controller.ts +++ b/src/shared/modules/offer/offer.controller.ts @@ -28,7 +28,10 @@ import { CommentRdo, CommentService } from '../comment/index.js'; import { CreateOfferDTO } from './dto/create-offer.dto.js'; import { FavoriteService } from '../favorite/index.js'; import { Config, RestSchema } from '../../libs/config/index.js'; -import { OFFER_IMAGES_LENGTH } from './offer.constant.js'; +import { + DEFAULT_COMMENTS_COUNT, + OFFER_IMAGES_LENGTH, +} from './offer.constant.js'; import { UploadImagesRdo } from './rdo/upload-images.rdo.js'; import { UploadPreviewRdo } from './rdo/upload-preview.rdo.js'; @@ -47,124 +50,7 @@ export class OfferController extends BaseController { ) { super(logger); - 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.getPremiumOffersByCity, - }); - this.addRoute({ - path: '/favorites', - method: HttpMethod.Get, - handler: this.getFavorites, - middlewares: [new PrivateRouteMiddleware()], - }); - this.addRoute({ - path: '/', - method: HttpMethod.Post, - handler: this.create, - middlewares: [ - new PrivateRouteMiddleware(), - new ValidateDtoMiddleware(CreateOfferDTO), - ], - }); - this.addRoute({ - path: '/:offerId', - method: HttpMethod.Get, - handler: this.show, - middlewares: [ - new ValidateObjectIdMiddleware('offerId'), - 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'), - ], - }); - 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/comments', - method: HttpMethod.Get, - handler: this.getComments, - middlewares: [ - new ValidateObjectIdMiddleware('offerId'), - new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), - ], - }); - this.addRoute({ - path: '/:offerId/favorite', - method: HttpMethod.Post, - handler: this.addFavorite, - middlewares: [ - new PrivateRouteMiddleware(), - new ValidateObjectIdMiddleware('offerId'), - new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), - ], - }); - this.addRoute({ - path: '/:offerId/favorite', - method: HttpMethod.Delete, - handler: this.removeFavorite, - middlewares: [ - new PrivateRouteMiddleware(), - new ValidateObjectIdMiddleware('offerId'), - new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), - ], - }); - this.addRoute({ - path: '/:offerId/preview', - method: HttpMethod.Post, - handler: this.uploadPreview, - middlewares: [ - new PrivateRouteMiddleware(), - new ValidateObjectIdMiddleware('offerId'), - new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), - new UploadFileMiddleware( - this.configService.get('UPLOAD_DIRECTORY'), - 'preview', - ALLOWED_IMAGES, - MAX_SINGLE_FILE_SIZE, - ), - ], - }); - this.addRoute({ - path: '/:offerId/images', - method: HttpMethod.Post, - handler: this.uploadImages, - middlewares: [ - new PrivateRouteMiddleware(), - new ValidateObjectIdMiddleware('offerId'), - new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), - new UploadFilesMiddleware( - this.configService.get('UPLOAD_DIRECTORY'), - 'images', - ALLOWED_IMAGES, - MAX_SINGLE_FILE_SIZE, - OFFER_IMAGES_LENGTH, - ), - ], - }); + this.registerRoutes(); } public async show( @@ -203,21 +89,39 @@ export class OfferController extends BaseController { } public async delete( - { params }: Request, + { params, tokenPayload }: Request, res: Response, ): Promise { const { offerId } = params; - const offer = await this.offerService.deleteById(offerId); + const currentUserId = tokenPayload?.id; + + const deletedOffer = await this.offerService.deleteById( + offerId, + currentUserId, + ); await this.commentService.deleteByOfferId(offerId); - this.noContent(res, offer); + await this.favoriteService.deleteByOfferId(offerId); + + this.noContent(res, deletedOffer); } public async update( - { body, params }: Request, + { + body, + params, + tokenPayload, + }: Request, res: Response, ): Promise { const { offerId } = params; - const updatedOffer = await this.offerService.updateById(offerId, body); + + const currentUserId = tokenPayload?.id; + + const updatedOffer = await this.offerService.updateById( + offerId, + currentUserId, + body, + ); this.ok(res, fillDTO(OfferRdo, updatedOffer)); } @@ -226,7 +130,10 @@ export class OfferController extends BaseController { res: Response, ): Promise { const { offerId } = params; - const comments = await this.commentService.findByOfferId(offerId); + const comments = await this.commentService.findByOfferId( + offerId, + DEFAULT_COMMENTS_COUNT, + ); this.ok(res, fillDTO(CommentRdo, comments)); } @@ -268,11 +175,11 @@ export class OfferController extends BaseController { this.noContent(res, favorite); } - public async removeFavorite( + public async deleteFavorite( { params, tokenPayload }: Request, res: Response, ): Promise { - const favorite = await this.favoriteService.remove( + const favorite = await this.favoriteService.delete( tokenPayload.id, params.offerId, ); @@ -283,18 +190,17 @@ export class OfferController extends BaseController { { tokenPayload }: Request, res: Response, ): Promise { - const offers = await this.favoriteService.getFavoriteOffers( - tokenPayload.id, - ); + const offers = await this.favoriteService.getOffers(tokenPayload.id); this.ok(res, fillDTO(OfferRdo, offers)); } public async uploadPreview( - { params, file }: Request, + { params, file, tokenPayload }: Request, res: Response, ): Promise { const { offerId } = params; + const currentUserId = tokenPayload?.id; if (!file) { throw new HttpError( @@ -304,18 +210,23 @@ export class OfferController extends BaseController { ); } - const updatedOffer = await this.offerService.updateById(offerId, { - previewImage: file.filename, - }); + const updatedOffer = await this.offerService.updateById( + offerId, + currentUserId, + { + previewImage: file.filename, + }, + ); const previewRdo = fillDTO(UploadPreviewRdo, updatedOffer); this.created(res, previewRdo); } public async uploadImages( - { params, files }: Request, + { params, files, tokenPayload }: Request, res: Response, ): Promise { const { offerId } = params; + const currentUserId = tokenPayload?.id; const filesToUpload = Array.isArray(files) ? files : undefined; if (!filesToUpload || filesToUpload.length !== OFFER_IMAGES_LENGTH) { @@ -327,10 +238,135 @@ export class OfferController extends BaseController { } const images = filesToUpload.map((file) => file.filename); - const updatedOffer = await this.offerService.updateById(offerId, { - images, - }); + const updatedOffer = await this.offerService.updateById( + offerId, + currentUserId, + { + images, + }, + ); const imagesRdo = fillDTO(UploadImagesRdo, updatedOffer); this.created(res, imagesRdo); } + + private registerRoutes(): void { + 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.getPremiumOffersByCity, + }); + this.addRoute({ + path: '/favorites', + method: HttpMethod.Get, + handler: this.getFavorites, + middlewares: [new PrivateRouteMiddleware()], + }); + this.addRoute({ + path: '/', + method: HttpMethod.Post, + handler: this.create, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateDtoMiddleware(CreateOfferDTO), + ], + }); + this.addRoute({ + path: '/:offerId', + method: HttpMethod.Get, + handler: this.show, + middlewares: [ + new ValidateObjectIdMiddleware('offerId'), + 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'), + ], + }); + 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/comments', + method: HttpMethod.Get, + handler: this.getComments, + middlewares: [ + new ValidateObjectIdMiddleware('offerId'), + new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), + ], + }); + this.addRoute({ + path: '/:offerId/favorite', + method: HttpMethod.Post, + handler: this.addFavorite, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateObjectIdMiddleware('offerId'), + new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), + ], + }); + this.addRoute({ + path: '/:offerId/favorite', + method: HttpMethod.Delete, + handler: this.deleteFavorite, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateObjectIdMiddleware('offerId'), + new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), + ], + }); + this.addRoute({ + path: '/:offerId/preview', + method: HttpMethod.Post, + handler: this.uploadPreview, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateObjectIdMiddleware('offerId'), + new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), + new UploadFileMiddleware( + this.configService.get('UPLOAD_DIRECTORY'), + 'preview', + ALLOWED_IMAGES, + MAX_SINGLE_FILE_SIZE, + ), + ], + }); + this.addRoute({ + path: '/:offerId/images', + method: HttpMethod.Post, + handler: this.uploadImages, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateObjectIdMiddleware('offerId'), + new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), + new UploadFilesMiddleware( + this.configService.get('UPLOAD_DIRECTORY'), + 'images', + ALLOWED_IMAGES, + MAX_SINGLE_FILE_SIZE, + OFFER_IMAGES_LENGTH, + ), + ], + }); + } } diff --git a/src/shared/modules/offer/offer.entity.ts b/src/shared/modules/offer/offer.entity.ts index f460f56..38b0eff 100644 --- a/src/shared/modules/offer/offer.entity.ts +++ b/src/shared/modules/offer/offer.entity.ts @@ -14,6 +14,7 @@ import { HousingValues, } from '../../types/index.js'; import { UserEntity } from '../user/index.js'; +import { OfferValidation } from './offer.constant.js'; // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export interface OfferEntity extends defaultClasses.Base {} @@ -54,18 +55,30 @@ export class OfferEntity extends defaultClasses.TimeStamps { public rating!: number; @prop({ default: 0 }) - public commentCount!: number; + public commentsCount!: number; @prop({ required: true, type: () => String, enum: HousingValues }) public type!: Housing; - @prop({ required: true, min: 1, max: 8 }) + @prop({ + required: true, + min: OfferValidation.Rooms.Min, + max: OfferValidation.Rooms.Max, + }) public rooms!: number; - @prop({ required: true, min: 1, max: 10 }) + @prop({ + required: true, + min: OfferValidation.Guests.Min, + max: OfferValidation.Guests.Max, + }) public guests!: number; - @prop({ required: true, min: 100, max: 100000 }) + @prop({ + required: true, + min: OfferValidation.Price.Min, + max: OfferValidation.Price.Max, + }) public price!: number; @prop({ required: true, type: () => [String], enum: FacilityValues }) diff --git a/src/shared/modules/offer/offers.http b/src/shared/modules/offer/offers.http index 0747110..7e1a282 100644 --- a/src/shared/modules/offer/offers.http +++ b/src/shared/modules/offer/offers.http @@ -1,19 +1,19 @@ # Получить объявление по идентификатору -GET http://localhost:3000/offers/69c98315edbb02aef834bfd1 HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3ODk4MDMyLCJleHAiOjE3NzgwNzA4MzJ9.ZJqG9wZG5xLb3gyaJ3R5nWLgiVABfWiWbMpqd2-sILE +GET http://localhost:3000/offers/6a1475f5e8df7c0412530a85 HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNmExMzY5MTQwMjUxZGFhZGFlZTVlMTM2IiwiaWF0IjoxNzc5NjU2OTg4LCJleHAiOjE3Nzk4Mjk3ODh9.x5fodXNwaoXr9sWf5MrWDOcqPw11srZt8cbTZlW8E8g ### ## Получить список объявлений GET http://localhost:3000/offers?limit=10 HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3ODk4MDMyLCJleHAiOjE3NzgwNzA4MzJ9.ZJqG9wZG5xLb3gyaJ3R5nWLgiVABfWiWbMpqd2-sILE +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNmExMzY5MTQwMjUxZGFhZGFlZTVlMTM2IiwiaWF0IjoxNzc5NjkyODA2LCJleHAiOjE3Nzk4NjU2MDZ9.a5IhWu7p3c_7BAjrtENFqoSkRTBAlKlAlfS7jGiPAF4 ### ## Создать новое объявление POST http://localhost:3000/offers HTTP/1.1 Content-Type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3ODk4MDMyLCJleHAiOjE3NzgwNzA4MzJ9.ZJqG9wZG5xLb3gyaJ3R5nWLgiVABfWiWbMpqd2-sILE +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNmExNDc1ZDVlOGRmN2MwNDEyNTMwYTgyIiwiaWF0IjoxNzc5NzI1Nzg3LCJleHAiOjE3Nzk4OTg1ODd9.hFl1pYW4ARmBGxkFB4JuxP48xWCg4GI80RUqDCbyX_w { "title": "Modern apartment in city center", @@ -42,7 +42,6 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZ "Towels", "Fridge" ], - "userId": "69ee5f35f61313c1ddf0d82d", "location": { "latitude": 50.846557, "longitude": 4.351697 @@ -52,15 +51,16 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZ ### ## Удалить объявление по идентификатору - -DELETE http://localhost:3000/offers/69efaafe88e8c8826882b397 HTTP/1.1 +DELETE http://localhost:3000/offers/6a13635c0df9cd8bf6105552 HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNmExMzY5MTQwMjUxZGFhZGFlZTVlMTM2IiwiaWF0IjoxNzc5NjU2OTg4LCJleHAiOjE3Nzk4Mjk3ODh9.x5fodXNwaoXr9sWf5MrWDOcqPw11srZt8cbTZlW8E8g ### ## Обновить объявление -PATCH http://localhost:3000/offers/69c98315edbb02aef834bfd1 HTTP/1.1 +PATCH http://localhost:3000/offers/6a1475f5e8df7c0412530a85 HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNmExNDc1ZDVlOGRmN2MwNDEyNTMwYTgyIiwiaWF0IjoxNzc5NzI1Nzg3LCJleHAiOjE3Nzk4OTg1ODd9.hFl1pYW4ARmBGxkFB4JuxP48xWCg4GI80RUqDCbyX_w { "isPremium": true, @@ -70,32 +70,32 @@ Content-Type: application/json ### ## Список комментариев к объявлению -GET http://localhost:3000/offers/69c98315edbb02aef834bfd1/comments HTTP/1.1 +GET http://localhost:3000/offers/6a1475f5e8df7c0412530a85/comments HTTP/1.1 ### ## Получить список премиальных объявлений по городу -GET http://localhost:3000/offers/premium?city=Paris&limit=10 HTTP/1.1 +GET http://localhost:3000/offers/premium?city=Brussels&limit=10 HTTP/1.1 ### ## Добавить в избранное -POST http://localhost:3000/offers/69c98315edbb02aef834bfd1/favorite HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3ODk4MDMyLCJleHAiOjE3NzgwNzA4MzJ9.ZJqG9wZG5xLb3gyaJ3R5nWLgiVABfWiWbMpqd2-sILE +POST http://localhost:3000/offers/6a1475f5e8df7c0412530a85/favorite HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNmExNDc1ZDVlOGRmN2MwNDEyNTMwYTgyIiwiaWF0IjoxNzc5NzI1Nzg3LCJleHAiOjE3Nzk4OTg1ODd9.hFl1pYW4ARmBGxkFB4JuxP48xWCg4GI80RUqDCbyX_w ### ## Удалить из избранного -DELETE http://localhost:3000/offers/69c98315edbb02aef834bfd1/favorite HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3ODk4MDMyLCJleHAiOjE3NzgwNzA4MzJ9.ZJqG9wZG5xLb3gyaJ3R5nWLgiVABfWiWbMpqd2-sILE +DELETE http://localhost:3000/offers/6a1475f5e8df7c0412530a85/favorite HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNmExNDc1ZDVlOGRmN2MwNDEyNTMwYTgyIiwiaWF0IjoxNzc5NzI1Nzg3LCJleHAiOjE3Nzk4OTg1ODd9.hFl1pYW4ARmBGxkFB4JuxP48xWCg4GI80RUqDCbyX_w ### ## Получить избранные объявления GET http://localhost:3000/offers/favorites HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3ODk4MDMyLCJleHAiOjE3NzgwNzA4MzJ9.ZJqG9wZG5xLb3gyaJ3R5nWLgiVABfWiWbMpqd2-sILE +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNmExNDc1ZDVlOGRmN2MwNDEyNTMwYTgyIiwiaWF0IjoxNzc5NzI1Nzg3LCJleHAiOjE3Nzk4OTg1ODd9.hFl1pYW4ARmBGxkFB4JuxP48xWCg4GI80RUqDCbyX_w ### diff --git a/src/shared/modules/offer/rdo/offer.rdo.ts b/src/shared/modules/offer/rdo/offer.rdo.ts index fa611af..aec213a 100644 --- a/src/shared/modules/offer/rdo/offer.rdo.ts +++ b/src/shared/modules/offer/rdo/offer.rdo.ts @@ -38,6 +38,9 @@ export class OfferRdo { @Expose() public rating: number; + @Expose() + public commentsCount: number; + @Expose() public type: Housing; diff --git a/src/shared/modules/user/default-user.service.ts b/src/shared/modules/user/default-user.service.ts index 7348580..e8a6b5a 100644 --- a/src/shared/modules/user/default-user.service.ts +++ b/src/shared/modules/user/default-user.service.ts @@ -7,6 +7,8 @@ import { Component } from '../../types/index.js'; import { Logger } from '../../libs/logger/index.js'; import { UpdateUserDTO } from './dto/update-user.dto.js'; import { DEFAULT_AVATAR_FILE_NAME } from './user.constant.js'; +import { HttpError } from '../../libs/rest/index.js'; +import { StatusCodes } from 'http-status-codes'; @injectable() export class DefaultUserService implements UserService { @@ -53,8 +55,13 @@ export class DefaultUserService implements UserService { public async updateById( userId: string, + tokenPayloadId: string, dto: UpdateUserDTO, ): Promise | null> { + if (userId !== tokenPayloadId) { + throw new HttpError(StatusCodes.FORBIDDEN, 'Forbidden', 'UserService'); + } + return this.userModel.findByIdAndUpdate(userId, dto, { new: true }).exec(); } } diff --git a/src/shared/modules/user/dto/create-user.dto.ts b/src/shared/modules/user/dto/create-user.dto.ts index 3064d59..9dad6a2 100644 --- a/src/shared/modules/user/dto/create-user.dto.ts +++ b/src/shared/modules/user/dto/create-user.dto.ts @@ -1,21 +1,34 @@ import { IsEmail, IsIn, IsString, Length } from 'class-validator'; import { UserType, UserTypeValues } from '../../../types/index.js'; import { CreateUserMessages } from './create-user.messages.js'; +import { UserValidation } from '../user.constant.js'; export class CreateUserDto { @IsEmail({}, { message: CreateUserMessages.email.invalidFormat }) public email: string; @IsString({ message: CreateUserMessages.firstname.invalidFormat }) - @Length(1, 15, { message: CreateUserMessages.firstname.lengthField }) + @Length( + UserValidation.Firstname.MinLength, + UserValidation.Firstname.MaxLength, + { message: CreateUserMessages.firstname.lengthField }, + ) public firstname: string; @IsString({ message: CreateUserMessages.lastname.invalidFormat }) - @Length(1, 15, { message: CreateUserMessages.lastname.lengthField }) + @Length( + UserValidation.Lastname.MinLength, + UserValidation.Lastname.MaxLength, + { message: CreateUserMessages.lastname.lengthField }, + ) public lastname: string; @IsString({ message: CreateUserMessages.password.invalidFormat }) - @Length(6, 12, { message: CreateUserMessages.password.lengthField }) + @Length( + UserValidation.Password.MinLength, + UserValidation.Password.MaxLength, + { message: CreateUserMessages.password.lengthField }, + ) public password: string; @IsIn(UserTypeValues, { message: CreateUserMessages.type.invalid }) diff --git a/src/shared/modules/user/dto/create-user.messages.ts b/src/shared/modules/user/dto/create-user.messages.ts index e28658f..2c788d4 100644 --- a/src/shared/modules/user/dto/create-user.messages.ts +++ b/src/shared/modules/user/dto/create-user.messages.ts @@ -1,18 +1,20 @@ +import { UserValidation } from '../user.constant.js'; + export const CreateUserMessages = { email: { invalidFormat: 'email must be a valid address', }, firstname: { invalidFormat: 'firstname is required', - lengthField: 'min length is 1, max is 15', + lengthField: `min length is ${UserValidation.Firstname.MinLength}, max is ${UserValidation.Firstname.MaxLength}`, }, lastname: { invalidFormat: 'lastname is required', - lengthField: 'min length is 1, max is 15', + lengthField: `min length is ${UserValidation.Lastname.MinLength}, max is ${UserValidation.Lastname.MaxLength}`, }, password: { invalidFormat: 'password is required', - lengthField: 'min length for password is 6, max is 12', + lengthField: `min length for password is ${UserValidation.Password.MinLength}, max is ${UserValidation.Password.MaxLength}`, }, type: { invalid: 'type must be one of UserType', diff --git a/src/shared/modules/user/user-service.interface.ts b/src/shared/modules/user/user-service.interface.ts index 9a7c544..9614de0 100644 --- a/src/shared/modules/user/user-service.interface.ts +++ b/src/shared/modules/user/user-service.interface.ts @@ -12,6 +12,7 @@ export interface UserService { ): Promise>; updateById( userId: string, + tokenPayloadId: string, dto: UpdateUserDTO, ): Promise | null>; } diff --git a/src/shared/modules/user/user.constant.ts b/src/shared/modules/user/user.constant.ts index 29633af..452df10 100644 --- a/src/shared/modules/user/user.constant.ts +++ b/src/shared/modules/user/user.constant.ts @@ -1 +1,15 @@ export const DEFAULT_AVATAR_FILE_NAME = 'default-avatar.jpg'; +export const UserValidation = { + Firstname: { + MinLength: 1, + MaxLength: 15, + }, + Lastname: { + MinLength: 1, + MaxLength: 15, + }, + Password: { + MinLength: 6, + MaxLength: 12, + }, +}; diff --git a/src/shared/modules/user/user.controller.ts b/src/shared/modules/user/user.controller.ts index 4cd416c..f87c25b 100644 --- a/src/shared/modules/user/user.controller.ts +++ b/src/shared/modules/user/user.controller.ts @@ -7,6 +7,7 @@ import { HttpError, HttpMethod, MAX_SINGLE_FILE_SIZE, + PrivateRouteMiddleware, UploadFileMiddleware, ValidateDtoMiddleware, ValidateObjectIdMiddleware, @@ -36,38 +37,8 @@ export class UserController extends BaseController { @inject(Component.AuthService) 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.checkAuth, - }); - this.addRoute({ - path: '/:userId/avatar', - method: HttpMethod.Post, - handler: this.uploadAvatar, - middlewares: [ - new ValidateObjectIdMiddleware('userId'), - new UploadFileMiddleware( - this.configService.get('UPLOAD_DIRECTORY'), - 'avatar', - ALLOWED_IMAGES, - MAX_SINGLE_FILE_SIZE, - ), - ], - }); + + this.registerRoutes(); } public async create( @@ -99,12 +70,15 @@ export class UserController extends BaseController { } public async uploadAvatar( - { params, file }: Request, + { params, file, tokenPayload }: Request, res: Response, ) { const { userId } = params; + const currentUserId = tokenPayload?.id; const uploadFile = { avatarPath: file?.filename }; - await this.userService.updateById(userId, uploadFile); + + await this.userService.updateById(userId, currentUserId, uploadFile); + this.created( res, fillDTO(UploadUserAvatarRdo, { filepath: uploadFile.avatarPath }), @@ -124,4 +98,41 @@ export class UserController extends BaseController { this.ok(res, fillDTO(LoggedUserRdo, user)); } + + private registerRoutes(): void { + 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.checkAuth, + middlewares: [new PrivateRouteMiddleware()], + }); + this.addRoute({ + path: '/:userId/avatar', + method: HttpMethod.Post, + handler: this.uploadAvatar, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateObjectIdMiddleware('userId'), + new UploadFileMiddleware( + this.configService.get('UPLOAD_DIRECTORY'), + 'avatar', + ALLOWED_IMAGES, + MAX_SINGLE_FILE_SIZE, + ), + ], + }); + } } diff --git a/src/shared/modules/user/user.http b/src/shared/modules/user/user.http index d9abad0..498377f 100644 --- a/src/shared/modules/user/user.http +++ b/src/shared/modules/user/user.http @@ -29,7 +29,8 @@ Content-Type: application/json ## Отправить изображение -POST http://localhost:3000/users/69ee5f35f61313c1ddf0d82d/avatar HTTP/1.1 +POST http://localhost:3000/users/6a1475d5e8df7c0412530a82/avatar HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNmExNDc1ZDVlOGRmN2MwNDEyNTMwYTgyIiwiaWF0IjoxNzc5NzMxODcwLCJleHAiOjE3Nzk5MDQ2NzB9.U9V1c9hjgC5d-jEpPUgNqor3qDItgrp9pq_svWrgezk Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW ------WebKitFormBoundary7MA4YWxkTrZu0gW @@ -45,6 +46,6 @@ Content-Type: image/png ## Проверить токен пользователя GET http://localhost:3000/users/login HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNjllZTVmMzVmNjEzMTNjMWRkZjBkODJkIiwiaWF0IjoxNzc3NTc1NjIzLCJleHAiOjE3Nzc3NDg0MjN9.CDkRYtP0vK_gcJ5CRZwEDj9FVqpQVNt0S0qoHgMPnUM +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJmaXJzdG5hbWUiOiJKYWNrIiwibGFzdG5hbWUiOiJUb3JyYW5jZSIsImlkIjoiNmExNDc1ZDVlOGRmN2MwNDEyNTMwYTgyIiwiaWF0IjoxNzc5NzMxODcwLCJleHAiOjE3Nzk5MDQ2NzB9.U9V1c9hjgC5d-jEpPUgNqor3qDItgrp9pq_svWrgezk ### diff --git a/src/shared/types/facility.type.ts b/src/shared/types/facility.type.ts index 353a0ad..3c85456 100644 --- a/src/shared/types/facility.type.ts +++ b/src/shared/types/facility.type.ts @@ -1,7 +1,4 @@ -import { - createStringUnionGuard, - createStringUnionParser, -} from '../helpers/index.js'; +import { createStringUnionParser } from '../helpers/index.js'; export const FacilityValues = [ 'Breakfast', @@ -15,6 +12,4 @@ export const FacilityValues = [ export type Facility = (typeof FacilityValues)[number]; -export const isFacility = createStringUnionGuard(FacilityValues); - export const parseFacility = createStringUnionParser(FacilityValues); diff --git a/src/shared/types/housing.type.ts b/src/shared/types/housing.type.ts index 45cd0fd..4eaac48 100644 --- a/src/shared/types/housing.type.ts +++ b/src/shared/types/housing.type.ts @@ -1,12 +1,7 @@ -import { - createStringUnionGuard, - createStringUnionParser, -} from '../helpers/index.js'; +import { createStringUnionParser } from '../helpers/index.js'; export const HousingValues = ['apartment', 'house', 'room', 'hotel'] as const; export type Housing = (typeof HousingValues)[number]; -export const isHousing = createStringUnionGuard(HousingValues); - export const parseHousing = createStringUnionParser(HousingValues);