From 207516d05acabff9411cfefef9f536be538da2cb Mon Sep 17 00:00:00 2001 From: Adonariz Date: Fri, 5 Jun 2026 20:19:29 +0300 Subject: [PATCH 01/11] updates Workflow.md and renames docker-compose --- Workflow.md | 14 +++++++++++++- docker-compose.dev.yml => docker-compose.yml | 0 2 files changed, 13 insertions(+), 1 deletion(-) rename docker-compose.dev.yml => docker-compose.yml (100%) diff --git a/Workflow.md b/Workflow.md index b671042..59186af 100644 --- a/Workflow.md +++ b/Workflow.md @@ -29,7 +29,7 @@ npm install ```env PORT=3000 SALT=iamsalt -HOST=27.0.0.1 +HOST=127.0.0.1 DB_HOST=localhost DB_USER=admin DB_PASSWORD=test @@ -38,6 +38,7 @@ DB_NAME=six-cities-2 UPLOAD_DIRECTORY=upload STATIC_DIRECTORY_PATH=static JWT_SECRET=secret +DEFAULT_USER_PASSWORD=password ``` Назначение переменных: @@ -55,6 +56,17 @@ JWT_SECRET=secret Перед стартом приложения убедитесь, что сервер MongoDB запущен и доступен по параметрам из `.env`. Если база не будет доступна, REST API не сможет завершить инициализацию. +Для запуска проекта с использованием Docker выполните в терминале: + +```bash +docker-compose \ + --file ./docker-compose.yml \ + --env-file ./.env \ + --project-name "six-cities" \ + up \ + -d +``` + ### 4. Собрать проект ```bash diff --git a/docker-compose.dev.yml b/docker-compose.yml similarity index 100% rename from docker-compose.dev.yml rename to docker-compose.yml From 8e0e434948e976ced63d102b9b639e77c3928ddd Mon Sep 17 00:00:00 2001 From: Adonariz Date: Fri, 5 Jun 2026 20:32:10 +0300 Subject: [PATCH 02/11] adds env variables to import command --- src/cli/commands/import.command.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/cli/commands/import.command.ts b/src/cli/commands/import.command.ts index 6392f74..e746c62 100644 --- a/src/cli/commands/import.command.ts +++ b/src/cli/commands/import.command.ts @@ -92,25 +92,18 @@ export class ImportCommand implements Command { this.databaseClient.disconnect(); } - public async execute( - filename: string, - login: string, - password: string, - host: string, - dbname: string, - salt: string, - ): Promise { + public async execute(filename: string): Promise { this.logger.info('Starting import...'); this.config = new RestConfig(this.logger); const uri = getMongoURI( - login, - password, - host, + this.config.get('DB_USER'), + this.config.get('DB_PASSWORD'), + this.config.get('DB_HOST'), this.config.get('DB_PORT'), - dbname, + this.config.get('DB_NAME'), ); - this.salt = salt; + this.salt = this.config.get('SALT'); await this.databaseClient.connect(uri); From 4e2104d178763ac16cd90eaa9b43892cec4ba12c Mon Sep 17 00:00:00 2001 From: Adonariz Date: Fri, 5 Jun 2026 21:44:22 +0300 Subject: [PATCH 03/11] adds validation to import command --- mocks/test-data.tsv | 10 ----- src/cli/commands/help.command.ts | 8 ++-- src/cli/commands/import.command.ts | 40 +++++++++++++++---- src/shared/helpers/mock-helpers.ts | 22 ++++++++-- src/shared/helpers/offer.ts | 8 +++- .../libs/file-reader/tsv-file-reader.ts | 10 +++-- .../offer-generator/tsv-offer-generator.ts | 7 +++- 7 files changed, 74 insertions(+), 31 deletions(-) delete mode 100644 mocks/test-data.tsv diff --git a/mocks/test-data.tsv b/mocks/test-data.tsv deleted file mode 100644 index 69a1e07..0000000 --- a/mocks/test-data.tsv +++ /dev/null @@ -1,10 +0,0 @@ -Quiet room in residential district Modern interior, fast Wi-Fi, and easy access to public transport make this property ideal for short and long stays. 2026-04-30T12:32:34.314Z Hamburg preview-2.jpg offer-10.jpg;offer-11.jpg;offer-12.jpg false room 4 4 49223 Air conditioning;Laptop friendly workspace;Baby seat;Washer;Towels;Fridge Michael Brown michael.brown@example.com avatar-5.jpg base 49.837202;6.067401 -Comfortable house for family stay Enjoy peaceful surroundings and high-quality amenities in this carefully designed and comfortable accommodation. 2026-04-30T12:32:34.315Z Cologne preview-6.jpg offer-4.jpg;offer-5.jpg;offer-6.jpg;offer-7.jpg;offer-8.jpg;offer-9.jpg;offer-10.jpg;offer-11.jpg;offer-12.jpg true house 2 7 41824 Baby seat;Washer;Towels;Fridge Sophia Wilson emma.johnson@example.com avatar-2.png pro 53.294908;8.239346 -Cozy studio near metro station Bright and spacious living area with everything you need for a pleasant and relaxing stay. 2026-04-30T12:32:34.315Z Cologne preview-1.jpg offer-9.jpg;offer-10.jpg;offer-11.jpg;offer-12.jpg false hotel 8 7 60880 Air conditioning;Laptop friendly workspace;Baby seat Sophia Wilson michael.brown@example.com avatar-5.jpg pro 48.796494;4.630213 -Cozy studio near metro station Ideal choice for business trips or vacations, offering comfort, privacy, and excellent facilities. 2026-04-28T12:32:34.315Z Cologne preview-3.jpg offer-11.jpg;offer-12.jpg false hotel 3 3 4586 Air conditioning;Laptop friendly workspace;Baby seat;Washer Emma Johnson emma.johnson@example.com avatar-4.png pro 50.109832;2.546524 -Bright apartment with balcony Perfect location for exploring the city, with shops, restaurants, and attractions within walking distance. 2026-04-28T12:32:34.315Z Paris preview-2.jpg offer-9.jpg;offer-10.jpg;offer-11.jpg;offer-12.jpg true hotel 5 6 71384 Towels;Fridge Emma Johnson michael.brown@example.com avatar-2.png pro 50.759415;10.787393 -Quiet room in residential district Perfect location for exploring the city, with shops, restaurants, and attractions within walking distance. 2026-04-28T12:32:34.315Z Hamburg preview-3.jpg offer-10.jpg;offer-11.jpg;offer-12.jpg true apartment 7 1 88006 Baby seat;Washer;Towels;Fridge Emma Johnson daniel.miller@example.com avatar-5.jpg pro 50.366498;10.419582 -Luxury hotel room with river view Perfect location for exploring the city, with shops, restaurants, and attractions within walking distance. 2026-05-02T12:32:34.316Z Dusseldorf preview-4.jpg offer-3.jpg;offer-4.jpg;offer-5.jpg;offer-6.jpg;offer-7.jpg;offer-8.jpg;offer-9.jpg;offer-10.jpg;offer-11.jpg true house 1 2 51326 Air conditioning;Laptop friendly workspace;Baby seat Sophia Wilson emma.johnson@example.com avatar-4.png base 48.660732;2.247895 -Bright apartment with balcony Perfect location for exploring the city, with shops, restaurants, and attractions within walking distance. 2026-05-01T12:32:34.316Z Cologne preview-3.jpg offer-9.jpg;offer-10.jpg;offer-11.jpg;offer-12.jpg true hotel 3 6 11163 Washer;Towels;Fridge Emma Johnson olivia.davis@example.com avatar-2.png pro 50.531817;4.426451 -Charming room close to downtown A perfect combination of style, comfort, and convenience for any type of traveler. 2026-04-28T12:32:34.316Z Cologne preview-5.jpg offer-4.jpg;offer-5.jpg;offer-6.jpg;offer-7.jpg;offer-8.jpg false apartment 8 2 40212 Towels;Fridge John Smith daniel.miller@example.com avatar-5.jpg pro 51.225049;4.385628 -Bright apartment with balcony Enjoy peaceful surroundings and high-quality amenities in this carefully designed and comfortable accommodation. 2026-04-29T12:32:34.316Z Dusseldorf preview-4.jpg offer-3.jpg;offer-4.jpg;offer-5.jpg;offer-6.jpg;offer-7.jpg;offer-8.jpg;offer-9.jpg;offer-10.jpg;offer-11.jpg true house 1 9 36044 Towels;Fridge Sophia Wilson sophia.wilson@example.com avatar-3.jpg base 53.861462;4.117904 diff --git a/src/cli/commands/help.command.ts b/src/cli/commands/help.command.ts index 8051326..418b7b0 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 e746c62..268e360 100644 --- a/src/cli/commands/import.command.ts +++ b/src/cli/commands/import.command.ts @@ -4,6 +4,7 @@ import { createOffer, getErrorMessage, getMongoURI, + ParsedOffer, } from '../../shared/helpers/index.js'; import chalk from 'chalk'; import { @@ -12,6 +13,7 @@ import { UserService, } from '../../shared/modules/user/index.js'; import { + CreateOfferDTO, DefaultOfferService, OfferModel, OfferService, @@ -21,7 +23,6 @@ import { MongoDatabaseClient, } from '../../shared/libs/database-client/index.js'; import { ConsoleLogger, Logger } from '../../shared/libs/logger/index.js'; -import { Offer } from '../../shared/types/index.js'; import { CommentModel } from '../../shared/modules/comment/index.js'; import { FavoriteModel } from '../../shared/modules/favorite/index.js'; import { @@ -29,6 +30,8 @@ import { RestConfig, RestSchema, } from '../../shared/libs/config/index.js'; +import { plainToInstance } from 'class-transformer'; +import { validateOrReject, ValidationError } from 'class-validator'; export class ImportCommand implements Command { private userService: UserService; @@ -57,13 +60,13 @@ export class ImportCommand implements Command { return '--import'; } - private async saveOffer(offer: Offer) { + private async saveOffer(offer: ParsedOffer) { const user = await this.userService.findOrCreate( { ...offer.host, password: this.config.get('DEFAULT_USER_PASSWORD') }, this.salt, ); - await this.offerService.create({ + const dto = plainToInstance(CreateOfferDTO, { userId: user.id, title: offer.title, description: offer.description, @@ -79,12 +82,35 @@ export class ImportCommand implements Command { price: offer.price, facilities: offer.facilities, }); + + await validateOrReject(dto); + + await this.offerService.create(dto); } - private async onImportedLine(line: string, resolve: () => void) { - const offer = createOffer(line); - await this.saveOffer(offer); - resolve(); + private async onImportedLine( + line: string, + resolve: (value: boolean) => void, + ) { + try { + const offer = createOffer(line); + + await this.saveOffer(offer); + + resolve(true); + } catch (error) { + if (Array.isArray(error)) { + console.error(chalk.redBright('Validation failed')); + + error.forEach((item: ValidationError) => { + Object.values(item.constraints ?? {}).forEach((message) => { + console.error(message); + }); + }); + } + + resolve(false); + } } private onCompleteImport(count: number) { diff --git a/src/shared/helpers/mock-helpers.ts b/src/shared/helpers/mock-helpers.ts index 4aaed4a..61ecb30 100644 --- a/src/shared/helpers/mock-helpers.ts +++ b/src/shared/helpers/mock-helpers.ts @@ -10,10 +10,26 @@ export function generateRandomBoolean() { return Math.random() < 0.5; } -export function getRandomItems(items: T[]): T[] { +export function getRandomItems(items: T[], count?: number): T[] { + if (items.length === 0) { + return []; + } + + if (count !== undefined) { + if (count > items.length) { + throw new Error( + `Cannot get ${count} items from array with length ${items.length}`, + ); + } + + const shuffledItems = [...items].sort(() => Math.random() - 0.5); + + return shuffledItems.slice(0, count); + } + const startPosition = generateRandomValue(0, items.length - 1); - const endPosition = - startPosition + generateRandomValue(startPosition, items.length); + const endPosition = generateRandomValue(startPosition + 1, items.length); + return items.slice(startPosition, endPosition); } diff --git a/src/shared/helpers/offer.ts b/src/shared/helpers/offer.ts index 44a6bde..c50bdec 100644 --- a/src/shared/helpers/offer.ts +++ b/src/shared/helpers/offer.ts @@ -6,7 +6,11 @@ import { UserType, } from '../types/index.js'; -export function createOffer(offerData: string): Offer { +export type ParsedOffer = Omit & { + publishDate: string; +}; + +export function createOffer(offerData: string): ParsedOffer { const [ title, description, @@ -31,7 +35,7 @@ export function createOffer(offerData: string): Offer { return { title, description, - publishDate: new Date(publishDate), + publishDate, city: City[city as keyof typeof City], previewImage, images: images.split(';'), diff --git a/src/shared/libs/file-reader/tsv-file-reader.ts b/src/shared/libs/file-reader/tsv-file-reader.ts index a2ffaa6..5fdf252 100644 --- a/src/shared/libs/file-reader/tsv-file-reader.ts +++ b/src/shared/libs/file-reader/tsv-file-reader.ts @@ -25,16 +25,18 @@ export class TSVFileReader extends EventEmitter implements FileReader { nextLinePosition = remainingData.indexOf('\n'); while (nextLinePosition >= 0) { - const completeRow = remainingData.slice(0, nextLinePosition + 1); + const completeRow = remainingData.slice(0, nextLinePosition).trim(); remainingData = remainingData.slice(nextLinePosition + 1); - importedRowCount++; - - await new Promise((resolve) => { + const isImported = await new Promise((resolve) => { this.emit('line', completeRow, resolve); }); + if (isImported) { + importedRowCount++; + } + 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 f52c417..8ceb02d 100644 --- a/src/shared/libs/offer-generator/tsv-offer-generator.ts +++ b/src/shared/libs/offer-generator/tsv-offer-generator.ts @@ -35,6 +35,8 @@ const OfferGenerationRange = { }, } as const; +const IMAGES_LENGTH = 6; + export class TSVOfferGenerator implements OfferGenerator { constructor(private readonly mockData: MockServerData) {} @@ -52,7 +54,10 @@ export class TSVOfferGenerator implements OfferGenerator { .toISOString(); const city = getRandomItem(this.mockData.cities); const previewImage = getRandomItem(this.mockData.previewImages); - const images = getRandomItems(this.mockData.offerImages).join(';'); + const images = getRandomItems( + this.mockData.offerImages, + IMAGES_LENGTH, + ).join(';'); const isPremium = generateRandomBoolean(); const type = getRandomItem(this.mockData.housingTypes); const rooms = generateRandomValue( From df7f98d8e1378248ac0170f49eea4f5a64e52c1c Mon Sep 17 00:00:00 2001 From: Adonariz Date: Fri, 5 Jun 2026 22:09:18 +0300 Subject: [PATCH 04/11] adds error handling to import command --- src/cli/commands/import.command.ts | 40 ++++++++++++++----- .../database-client.interface.ts | 1 + 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/cli/commands/import.command.ts b/src/cli/commands/import.command.ts index 268e360..24d5ea2 100644 --- a/src/cli/commands/import.command.ts +++ b/src/cli/commands/import.command.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { Command } from './command.interface.js'; import { TSVFileReader } from '../../shared/libs/file-reader/index.js'; import { @@ -107,6 +108,8 @@ export class ImportCommand implements Command { console.error(message); }); }); + } else { + console.error(getErrorMessage(error)); } resolve(false); @@ -115,11 +118,25 @@ export class ImportCommand implements Command { private onCompleteImport(count: number) { console.info(chalk.bgBlueBright(`${count} rows imported`)); - this.databaseClient.disconnect(); } - public async execute(filename: string): Promise { + public async execute(filename?: string): Promise { + if (!filename) { + console.error( + chalk.redBright( + 'No file provided. Usage: npm run cli -- --import ', + ), + ); + return; + } + + if (path.extname(filename).toLowerCase() !== '.tsv') { + console.error(chalk.redBright('Only .tsv files are supported')); + return; + } + this.logger.info('Starting import...'); + this.config = new RestConfig(this.logger); const uri = getMongoURI( @@ -129,22 +146,25 @@ export class ImportCommand implements Command { this.config.get('DB_PORT'), this.config.get('DB_NAME'), ); + this.salt = this.config.get('SALT'); - await this.databaseClient.connect(uri); + try { + await this.databaseClient.connect(uri); - const fileReader = new TSVFileReader(filename.trim()); + const fileReader = new TSVFileReader(filename.trim()); - fileReader.on('line', this.onImportedLine); - fileReader.on('end', this.onCompleteImport); + fileReader.on('line', this.onImportedLine); + fileReader.on('end', this.onCompleteImport); - try { await fileReader.read(); } catch (error) { - console.error( - chalk.redBright(`Failed to import data from file: ${filename}`), - ); + console.error(`Failed to import data from file: ${filename}`); console.error(getErrorMessage(error)); + } finally { + if (this.databaseClient.isConnectedToDatabase()) { + await this.databaseClient.disconnect(); + } } } } diff --git a/src/shared/libs/database-client/database-client.interface.ts b/src/shared/libs/database-client/database-client.interface.ts index db39976..6a7e6d1 100644 --- a/src/shared/libs/database-client/database-client.interface.ts +++ b/src/shared/libs/database-client/database-client.interface.ts @@ -1,4 +1,5 @@ export interface DatabaseClient { connect(uri: string): Promise; disconnect(): Promise; + isConnectedToDatabase(): boolean; } From deb42d7b7a558bc1b5b5354d1f9b38030db61c6f Mon Sep 17 00:00:00 2001 From: Adonariz Date: Fri, 5 Jun 2026 23:26:29 +0300 Subject: [PATCH 05/11] adds offer preview --- .../favorite/default-favorite.service.ts | 15 ++++--- .../favorite/favorite-service.interface.ts | 4 +- .../modules/offer/default-offer.service.ts | 28 +++++++------ src/shared/modules/offer/index.ts | 1 + .../modules/offer/offer-service.interface.ts | 10 +++-- src/shared/modules/offer/offer.constant.ts | 12 ++++++ src/shared/modules/offer/offer.controller.ts | 7 ++-- src/shared/modules/offer/offer.entity.ts | 18 ++++++++ .../modules/offer/rdo/offer-preview.rdo.ts | 41 +++++++++++++++++++ 9 files changed, 110 insertions(+), 26 deletions(-) create mode 100644 src/shared/modules/offer/rdo/offer-preview.rdo.ts diff --git a/src/shared/modules/favorite/default-favorite.service.ts b/src/shared/modules/favorite/default-favorite.service.ts index c80f2ba..aa237c0 100644 --- a/src/shared/modules/favorite/default-favorite.service.ts +++ b/src/shared/modules/favorite/default-favorite.service.ts @@ -3,7 +3,11 @@ import { FavoriteService } from './favorite-service.interface.js'; import { Component } from '../../types/index.js'; import { types } from '@typegoose/typegoose'; import { FavoriteEntity } from './favorite.entity.js'; -import { OfferEntity, OfferWithFavorite } from '../offer/index.js'; +import { + OfferPreview, + OfferPreviewData, + OFFER_PREVIEW_FIELDS, +} from '../offer/index.js'; @injectable() export class DefaultFavoriteService implements FavoriteService { @@ -28,17 +32,18 @@ export class DefaultFavoriteService implements FavoriteService { await this.favoriteModel.deleteMany({ offerId }).exec(); } - public async getOffers(userId: string): Promise { + public async getOffers(userId: string): Promise { const favorites = await this.favoriteModel .find({ userId }) .populate({ path: 'offerId', - populate: { path: 'userId' }, + select: OFFER_PREVIEW_FIELDS, }) - .lean(); + .lean() + .exec(); return favorites.map((favorite) => ({ - ...(favorite.offerId as OfferEntity), + ...(favorite.offerId as OfferPreviewData), isFavorite: true, })); } diff --git a/src/shared/modules/favorite/favorite-service.interface.ts b/src/shared/modules/favorite/favorite-service.interface.ts index ea03527..19724b8 100644 --- a/src/shared/modules/favorite/favorite-service.interface.ts +++ b/src/shared/modules/favorite/favorite-service.interface.ts @@ -1,9 +1,9 @@ -import { OfferWithFavorite } from '../offer/index.js'; +import { OfferPreview } from '../offer/index.js'; export interface FavoriteService { add(userId: string, offerId: string): Promise; delete(userId: string, offerId: string): Promise; deleteByOfferId(offerId: string): Promise; - getOffers(userId: 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 a6bdcae..6d94797 100644 --- a/src/shared/modules/offer/default-offer.service.ts +++ b/src/shared/modules/offer/default-offer.service.ts @@ -4,12 +4,18 @@ import { OfferService } from './offer-service.interface.js'; import { City, Component, SortType } from '../../types/index.js'; import { Logger } from '../../libs/logger/index.js'; import { types } from '@typegoose/typegoose'; -import { OfferEntity, OfferWithFavorite } from './offer.entity.js'; +import { + OfferEntity, + OfferPreview, + OfferPreviewData, + OfferWithFavorite, +} from './offer.entity.js'; import { CreateOfferDTO } from './dto/create-offer.dto.js'; import { UpdateOfferDTO } from './dto/update-offer.dto.js'; import { DEFAULT_OFFER_COUNT, DEFAULT_PREMIUM_OFFER_COUNT, + OFFER_PREVIEW_FIELDS, } from './offer.constant.js'; import { CommentEntity } from '../comment/index.js'; import { Types } from 'mongoose'; @@ -66,16 +72,13 @@ export class DefaultOfferService implements OfferService { }; } - public async find( - count?: number, - userId?: string, - ): Promise { + public async find(count?: number, userId?: string): Promise { const offers = await this.offerModel - .find() + .find({}, OFFER_PREVIEW_FIELDS) .sort({ publishDate: SortType.Down }) .limit(count ?? DEFAULT_OFFER_COUNT) - .populate(['userId']) - .lean(); + .lean() + .exec(); if (!userId) { return offers.map((offer) => ({ @@ -162,7 +165,7 @@ export class DefaultOfferService implements OfferService { city: City, userId?: string, count?: number, - ): Promise { + ): Promise { if (!Object.values(City).includes(city)) { throw new HttpError( StatusCodes.BAD_REQUEST, @@ -172,10 +175,9 @@ export class DefaultOfferService implements OfferService { } const premiumOffers = await this.offerModel - .find({ city, isPremium: true }) + .find({ city, isPremium: true }, OFFER_PREVIEW_FIELDS) .sort({ publishDate: SortType.Down }) .limit(count ?? DEFAULT_PREMIUM_OFFER_COUNT) - .populate(['userId']) .lean() .exec(); @@ -200,9 +202,9 @@ export class DefaultOfferService implements OfferService { } private addFavoriteFlag( - offers: OfferEntity[], + offers: OfferPreviewData[], favoriteIds: Set, - ): OfferWithFavorite[] { + ): OfferPreview[] { return offers.map((offer) => ({ ...offer, isFavorite: favoriteIds.has(offer._id.toString()), diff --git a/src/shared/modules/offer/index.ts b/src/shared/modules/offer/index.ts index 394962b..2a3eeac 100644 --- a/src/shared/modules/offer/index.ts +++ b/src/shared/modules/offer/index.ts @@ -8,3 +8,4 @@ export * from './rdo/offer.rdo.js'; export * from './dto/create-offer.messages.js'; export * from './rdo/upload-images.rdo.js'; export * from './rdo/upload-preview.rdo.js'; +export * from './offer.constant.js'; diff --git a/src/shared/modules/offer/offer-service.interface.ts b/src/shared/modules/offer/offer-service.interface.ts index d718f01..8797c1a 100644 --- a/src/shared/modules/offer/offer-service.interface.ts +++ b/src/shared/modules/offer/offer-service.interface.ts @@ -1,13 +1,17 @@ import { CreateOfferDTO } from './dto/create-offer.dto.js'; import { DocumentType } from '@typegoose/typegoose'; -import { OfferEntity, OfferWithFavorite } from './offer.entity.js'; +import { + OfferEntity, + OfferPreview, + OfferWithFavorite, +} from './offer.entity.js'; import { UpdateOfferDTO } from './dto/update-offer.dto.js'; import { City, DocumentExists } from '../../types/index.js'; export interface OfferService extends DocumentExists { create(dto: CreateOfferDTO): Promise>; findById(offerId: string, userId?: string): Promise; - find(count?: number, userId?: string): Promise; + find(count?: number, userId?: string): Promise; deleteById( offerId: string, userId: string, @@ -21,7 +25,7 @@ export interface OfferService extends DocumentExists { city: City, userId?: string, count?: number, - ): Promise; + ): Promise; exists(documentId: string): Promise; updateAfterCommentCreated(offerId: string): Promise; } diff --git a/src/shared/modules/offer/offer.constant.ts b/src/shared/modules/offer/offer.constant.ts index 832941d..20a49cb 100644 --- a/src/shared/modules/offer/offer.constant.ts +++ b/src/shared/modules/offer/offer.constant.ts @@ -38,3 +38,15 @@ export const OfferValidation = { }, }, } as const; + +export const OFFER_PREVIEW_FIELDS = { + title: 1, + type: 1, + price: 1, + previewImage: 1, + city: 1, + publishDate: 1, + isPremium: 1, + rating: 1, + commentsCount: 1, +}; diff --git a/src/shared/modules/offer/offer.controller.ts b/src/shared/modules/offer/offer.controller.ts index aac569f..6be0897 100644 --- a/src/shared/modules/offer/offer.controller.ts +++ b/src/shared/modules/offer/offer.controller.ts @@ -35,6 +35,7 @@ import { } from './offer.constant.js'; import { UploadImagesRdo } from './rdo/upload-images.rdo.js'; import { UploadPreviewRdo } from './rdo/upload-preview.rdo.js'; +import { OfferPreviewRdo } from './rdo/offer-preview.rdo.js'; @injectable() export class OfferController extends BaseController { @@ -86,7 +87,7 @@ export class OfferController extends BaseController { const userId = tokenPayload?.id; const offers = await this.offerService.find(query?.limit, userId); - this.ok(res, fillDTO(OfferRdo, offers)); + this.ok(res, fillDTO(OfferPreviewRdo, offers)); } public async delete( @@ -161,7 +162,7 @@ export class OfferController extends BaseController { userId, DEFAULT_PREMIUM_OFFER_COUNT, ); - this.ok(res, fillDTO(OfferRdo, premiumOffersByCity)); + this.ok(res, fillDTO(OfferPreviewRdo, premiumOffersByCity)); } public async addFavorite( @@ -192,7 +193,7 @@ export class OfferController extends BaseController { ): Promise { const offers = await this.favoriteService.getOffers(tokenPayload.id); - this.ok(res, fillDTO(OfferRdo, offers)); + this.ok(res, fillDTO(OfferPreviewRdo, offers)); } public async uploadPreview( diff --git a/src/shared/modules/offer/offer.entity.ts b/src/shared/modules/offer/offer.entity.ts index 38b0eff..b22871c 100644 --- a/src/shared/modules/offer/offer.entity.ts +++ b/src/shared/modules/offer/offer.entity.ts @@ -1,3 +1,4 @@ +import { Types } from 'mongoose'; import { defaultClasses, getModelForClass, @@ -21,6 +22,23 @@ export interface OfferEntity extends defaultClasses.Base {} export type OfferWithFavorite = OfferEntity & { isFavorite: boolean; }; +export type OfferPreviewData = Pick< + OfferEntity, + | 'title' + | 'publishDate' + | 'city' + | 'previewImage' + | 'isPremium' + | 'rating' + | 'commentsCount' + | 'type' + | 'price' +> & { + _id: Types.ObjectId; +}; +export type OfferPreview = OfferPreviewData & { + isFavorite: boolean; +}; @modelOptions({ schemaOptions: { diff --git a/src/shared/modules/offer/rdo/offer-preview.rdo.ts b/src/shared/modules/offer/rdo/offer-preview.rdo.ts new file mode 100644 index 0000000..95fc91a --- /dev/null +++ b/src/shared/modules/offer/rdo/offer-preview.rdo.ts @@ -0,0 +1,41 @@ +import { Expose, Transform } from 'class-transformer'; +import { City, Housing } from '../../../types/index.js'; + +export class OfferPreviewRdo { + @Expose() + @Transform(({ obj }) => obj.id ?? obj._id?.toString()) + public id: string; + + @Expose() + public title: string; + + @Expose() + @Transform(({ value }) => + value instanceof Date ? value.toISOString() : value, + ) + public publishDate: string; + + @Expose() + public city: City; + + @Expose() + public previewImage: string; + + @Expose() + public isPremium: boolean; + + @Expose() + public isFavorite: boolean; + + @Expose() + public rating: number; + + @Expose() + public commentsCount: number; + + @Expose() + public type: Housing; + + @Expose() + public price: number; +} From 00a591114eb5910228b7fdfaade178254d1aff9e Mon Sep 17 00:00:00 2001 From: Adonariz Date: Fri, 5 Jun 2026 23:39:39 +0300 Subject: [PATCH 06/11] refactors addFavoriteFlag type --- .../favorite/default-favorite.service.ts | 4 +-- .../modules/offer/default-offer.service.ts | 7 ++--- src/shared/modules/offer/offer.entity.ts | 29 +++++++------------ 3 files changed, 16 insertions(+), 24 deletions(-) diff --git a/src/shared/modules/favorite/default-favorite.service.ts b/src/shared/modules/favorite/default-favorite.service.ts index aa237c0..efaa3ec 100644 --- a/src/shared/modules/favorite/default-favorite.service.ts +++ b/src/shared/modules/favorite/default-favorite.service.ts @@ -5,8 +5,8 @@ import { types } from '@typegoose/typegoose'; import { FavoriteEntity } from './favorite.entity.js'; import { OfferPreview, - OfferPreviewData, OFFER_PREVIEW_FIELDS, + OfferPreviewBase, } from '../offer/index.js'; @injectable() @@ -43,7 +43,7 @@ export class DefaultFavoriteService implements FavoriteService { .exec(); return favorites.map((favorite) => ({ - ...(favorite.offerId as OfferPreviewData), + ...(favorite.offerId as OfferPreviewBase), isFavorite: true, })); } diff --git a/src/shared/modules/offer/default-offer.service.ts b/src/shared/modules/offer/default-offer.service.ts index 6d94797..217c213 100644 --- a/src/shared/modules/offer/default-offer.service.ts +++ b/src/shared/modules/offer/default-offer.service.ts @@ -7,7 +7,6 @@ import { types } from '@typegoose/typegoose'; import { OfferEntity, OfferPreview, - OfferPreviewData, OfferWithFavorite, } from './offer.entity.js'; import { CreateOfferDTO } from './dto/create-offer.dto.js'; @@ -201,10 +200,10 @@ export class DefaultOfferService implements OfferService { return new Set(favoriteIds.map(String)); } - private addFavoriteFlag( - offers: OfferPreviewData[], + private addFavoriteFlag( + offers: T[], favoriteIds: Set, - ): OfferPreview[] { + ): (T & { isFavorite: boolean })[] { return offers.map((offer) => ({ ...offer, isFavorite: favoriteIds.has(offer._id.toString()), diff --git a/src/shared/modules/offer/offer.entity.ts b/src/shared/modules/offer/offer.entity.ts index b22871c..93497cb 100644 --- a/src/shared/modules/offer/offer.entity.ts +++ b/src/shared/modules/offer/offer.entity.ts @@ -1,4 +1,3 @@ -import { Types } from 'mongoose'; import { defaultClasses, getModelForClass, @@ -22,23 +21,17 @@ export interface OfferEntity extends defaultClasses.Base {} export type OfferWithFavorite = OfferEntity & { isFavorite: boolean; }; -export type OfferPreviewData = Pick< - OfferEntity, - | 'title' - | 'publishDate' - | 'city' - | 'previewImage' - | 'isPremium' - | 'rating' - | 'commentsCount' - | 'type' - | 'price' -> & { - _id: Types.ObjectId; -}; -export type OfferPreview = OfferPreviewData & { - isFavorite: boolean; -}; +export type OfferPreview = Omit< + OfferWithFavorite, + | 'description' + | 'images' + | 'facilities' + | 'rooms' + | 'guests' + | 'location' + | 'userId' +>; +export type OfferPreviewBase = Omit; @modelOptions({ schemaOptions: { From 4e60c2fdf7a978101abad304181b66773357b20b Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 6 Jun 2026 00:07:14 +0300 Subject: [PATCH 07/11] fixes rating count --- .../comment/default-comment.service.ts | 4 +- .../modules/offer/default-offer.service.ts | 48 +++++-------------- .../modules/offer/offer-service.interface.ts | 2 +- 3 files changed, 16 insertions(+), 38 deletions(-) diff --git a/src/shared/modules/comment/default-comment.service.ts b/src/shared/modules/comment/default-comment.service.ts index b4112e5..775c588 100644 --- a/src/shared/modules/comment/default-comment.service.ts +++ b/src/shared/modules/comment/default-comment.service.ts @@ -31,9 +31,9 @@ export class DefaultCommentService implements CommentService { ); } - await this.offerService.updateAfterCommentCreated(offerId); - const comment = await this.commentModel.create(dto); + await this.offerService.updateOfferStats(offerId); + return comment.populate('userId'); } diff --git a/src/shared/modules/offer/default-offer.service.ts b/src/shared/modules/offer/default-offer.service.ts index 217c213..728e29a 100644 --- a/src/shared/modules/offer/default-offer.service.ts +++ b/src/shared/modules/offer/default-offer.service.ts @@ -116,48 +116,26 @@ export class DefaultOfferService implements OfferService { return (await this.offerModel.exists({ _id: documentId })) !== null; } - public async updateAfterCommentCreated(offerId: string): Promise { - await Promise.all([ - this.incCommentCount(offerId), - this.calcRating(offerId), - ]); - } - - private async incCommentCount( - offerId: string, - ): Promise | null> { - return this.offerModel - .findByIdAndUpdate(offerId, { - $inc: { - commentsCount: 1, - }, - }) - .exec(); - } - - private async calcRating(offerId: string): Promise { - const ratings = await this.commentModel.aggregate([ + public async updateOfferStats(offerId: string): Promise { + const stats = await this.commentModel.aggregate([ { $match: { offerId: new Types.ObjectId(offerId) } }, - { $group: { _id: '$offerId', avgRating: { $avg: '$rating' } } }, { - $project: { - _id: 0, - avgRating: { $round: ['$avgRating', 1] }, + $group: { + _id: '$offerId', + avgRating: { $avg: '$rating' }, + commentsCount: { $sum: 1 }, }, }, ]); - const rating = ratings?.length ? ratings[0]?.avgRating : 0; + const rating = stats.length ? Number(stats[0].avgRating.toFixed(1)) : 0; - await this.offerModel - .findByIdAndUpdate( - offerId, - { - rating, - }, - { returnDocument: 'after' }, - ) - .exec(); + const commentsCount = stats.length ? stats[0].commentsCount : 0; + + await this.offerModel.findByIdAndUpdate(offerId, { + rating, + commentsCount, + }); } public async findPremiumByCity( diff --git a/src/shared/modules/offer/offer-service.interface.ts b/src/shared/modules/offer/offer-service.interface.ts index 8797c1a..2e30c21 100644 --- a/src/shared/modules/offer/offer-service.interface.ts +++ b/src/shared/modules/offer/offer-service.interface.ts @@ -27,5 +27,5 @@ export interface OfferService extends DocumentExists { count?: number, ): Promise; exists(documentId: string): Promise; - updateAfterCommentCreated(offerId: string): Promise; + updateOfferStats(offerId: string): Promise; } From 68f8afbfdb22900383a861b49dcc85f068dbf93b Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 6 Jun 2026 21:48:13 +0300 Subject: [PATCH 08/11] updates specification with offer preview --- specification/project.spec.yml | 45 +++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/specification/project.spec.yml b/specification/project.spec.yml index 2413042..6710eff 100644 --- a/specification/project.spec.yml +++ b/specification/project.spec.yml @@ -131,7 +131,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/Offer' + $ref: '#/components/schemas/OfferPreview' post: tags: [offers] @@ -254,7 +254,7 @@ paths: type: array maxItems: 3 items: - $ref: '#/components/schemas/Offer' + $ref: '#/components/schemas/OfferPreview' '400': description: Неверно выбран город @@ -273,7 +273,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/Offer' + $ref: '#/components/schemas/OfferPreview' '401': $ref: '#/components/responses/Unauthorized' @@ -631,3 +631,42 @@ components: type: string location: $ref: '#/components/schemas/Coordinates' + OfferPreview: + type: object + required: + - id + - title + - publishDate + - city + - previewImage + - isPremium + - isFavorite + - type + - price + - commentsCount + - rating + properties: + id: + type: string + title: + type: string + publishDate: + type: string + format: date-time + city: + $ref: '#/components/schemas/City' + previewImage: + type: string + isPremium: + type: boolean + isFavorite: + type: boolean + type: + $ref: '#/components/schemas/Housing' + price: + type: integer + commentsCount: + type: integer + rating: + type: number + format: float From 1991370e94bd8db23fbbee1f3c53d18c27be05af Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 6 Jun 2026 21:53:29 +0300 Subject: [PATCH 09/11] adds arguments check to generate command --- src/cli/commands/generate.command.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/cli/commands/generate.command.ts b/src/cli/commands/generate.command.ts index 81de19a..5eaa842 100644 --- a/src/cli/commands/generate.command.ts +++ b/src/cli/commands/generate.command.ts @@ -32,8 +32,29 @@ export class GenerateCommand implements Command { public async execute(...args: string[]): Promise { const [count, filepath, url] = args; + + if (!count || !filepath || !url) { + console.error( + chalk.redBright( + 'Usage: npm run cli -- --generate ', + ), + ); + + return; + } + const offerCount = Number.parseInt(count, 10); + if (Number.isNaN(offerCount) || offerCount <= 0) { + console.error(chalk.redBright('Count must be a positive integer')); + return; + } + + if (!filepath.endsWith('.tsv')) { + console.error(chalk.redBright('Output file must have .tsv extension')); + return; + } + try { await this.load(url); await this.write(filepath, offerCount); From bcd76a0f6c3a00ca1816378c668d7ce9b165dd1f Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 6 Jun 2026 22:21:12 +0300 Subject: [PATCH 10/11] updates offer http --- src/shared/modules/offer/offers.http | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/modules/offer/offers.http b/src/shared/modules/offer/offers.http index d8ea11c..c7c4ff9 100644 --- a/src/shared/modules/offer/offers.http +++ b/src/shared/modules/offer/offers.http @@ -3,7 +3,7 @@ # Получить объявление по идентификатору GET http://localhost:3000/offers/{{offerId}} HTTP/1.1 -# Authorization: Bearer {{token}} +Authorization: Bearer {{token}} ### @@ -91,7 +91,7 @@ Authorization: Bearer {{token}} ## Удалить из избранного -DELETE http://localhost:3000/offers/6a1475f5e8df7c0412530a85/favorite HTTP/1.1 +DELETE http://localhost:3000/offers/{{offerId}}/favorite HTTP/1.1 Authorization: Bearer {{token}} ### From b7a99418e84eaebe42091fa54aa4d3b644e0ccb6 Mon Sep 17 00:00:00 2001 From: Adonariz Date: Sat, 6 Jun 2026 22:24:56 +0300 Subject: [PATCH 11/11] updates text in generate and help commands --- src/cli/commands/generate.command.ts | 2 +- src/cli/commands/help.command.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/generate.command.ts b/src/cli/commands/generate.command.ts index 5eaa842..67a7ec2 100644 --- a/src/cli/commands/generate.command.ts +++ b/src/cli/commands/generate.command.ts @@ -36,7 +36,7 @@ export class GenerateCommand implements Command { if (!count || !filepath || !url) { console.error( chalk.redBright( - 'Usage: npm run cli -- --generate ', + 'Invalid arguments. Usage: npm run cli -- --generate ', ), ); diff --git a/src/cli/commands/help.command.ts b/src/cli/commands/help.command.ts index 418b7b0..ee9f9b8 100644 --- a/src/cli/commands/help.command.ts +++ b/src/cli/commands/help.command.ts @@ -15,7 +15,7 @@ export class HelpCommand implements Command { --version: # выводит номер версии --help: # печатает этот текст --import : # импортирует данные из TSV - --generate # генерирует произвольное количество тестовых данных + --generate : # генерирует произвольное количество тестовых данных `); } }