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 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/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 diff --git a/src/cli/commands/generate.command.ts b/src/cli/commands/generate.command.ts index 81de19a..67a7ec2 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( + 'Invalid arguments. 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); diff --git a/src/cli/commands/help.command.ts b/src/cli/commands/help.command.ts index 8051326..ee9f9b8 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 6392f74..24d5ea2 100644 --- a/src/cli/commands/import.command.ts +++ b/src/cli/commands/import.command.ts @@ -1,9 +1,11 @@ +import path from 'node:path'; import { Command } from './command.interface.js'; import { TSVFileReader } from '../../shared/libs/file-reader/index.js'; import { createOffer, getErrorMessage, getMongoURI, + ParsedOffer, } from '../../shared/helpers/index.js'; import chalk from 'chalk'; import { @@ -12,6 +14,7 @@ import { UserService, } from '../../shared/modules/user/index.js'; import { + CreateOfferDTO, DefaultOfferService, OfferModel, OfferService, @@ -21,7 +24,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 +31,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 +61,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,53 +83,88 @@ 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); + }); + }); + } else { + console.error(getErrorMessage(error)); + } + + resolve(false); + } } private onCompleteImport(count: number) { console.info(chalk.bgBlueBright(`${count} rows imported`)); - 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 { + 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( - 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; - await this.databaseClient.connect(uri); + this.salt = this.config.get('SALT'); - const fileReader = new TSVFileReader(filename.trim()); + try { + await this.databaseClient.connect(uri); - fileReader.on('line', this.onImportedLine); - fileReader.on('end', this.onCompleteImport); + const fileReader = new TSVFileReader(filename.trim()); + + 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/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/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; } 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( 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/favorite/default-favorite.service.ts b/src/shared/modules/favorite/default-favorite.service.ts index c80f2ba..efaa3ec 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, + OFFER_PREVIEW_FIELDS, + OfferPreviewBase, +} 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 OfferPreviewBase), 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..728e29a 100644 --- a/src/shared/modules/offer/default-offer.service.ts +++ b/src/shared/modules/offer/default-offer.service.ts @@ -4,12 +4,17 @@ 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, + 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 +71,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) => ({ @@ -114,55 +116,33 @@ 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( city: City, userId?: string, count?: number, - ): Promise { + ): Promise { if (!Object.values(City).includes(city)) { throw new HttpError( StatusCodes.BAD_REQUEST, @@ -172,10 +152,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(); @@ -199,10 +178,10 @@ export class DefaultOfferService implements OfferService { return new Set(favoriteIds.map(String)); } - private addFavoriteFlag( - offers: OfferEntity[], + private addFavoriteFlag( + offers: T[], favoriteIds: Set, - ): OfferWithFavorite[] { + ): (T & { isFavorite: boolean })[] { 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..2e30c21 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; + updateOfferStats(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..93497cb 100644 --- a/src/shared/modules/offer/offer.entity.ts +++ b/src/shared/modules/offer/offer.entity.ts @@ -21,6 +21,17 @@ export interface OfferEntity extends defaultClasses.Base {} export type OfferWithFavorite = OfferEntity & { isFavorite: boolean; }; +export type OfferPreview = Omit< + OfferWithFavorite, + | 'description' + | 'images' + | 'facilities' + | 'rooms' + | 'guests' + | 'location' + | 'userId' +>; +export type OfferPreviewBase = Omit; @modelOptions({ schemaOptions: { 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}} ### 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; +}