diff --git a/package-lock.json b/package-lock.json index 7edbff7..2872bed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "got": "14.6.6", "inversify": "6.2.2", "mongoose": "9.2.2", - "nodemon": "^3.1.14", + "nodemon": "3.1.14", "pino": "10.3.1", "reflect-metadata": "0.2.2", "tslib": "2.8.1" @@ -24,7 +24,7 @@ "@typegoose/typegoose": "13.2.1", "@types/convict": "6.1.6", "@types/convict-format-with-validator": "6.0.6", - "@types/node": "18.17.17", + "@types/node": "25.5.2", "@typescript-eslint/eslint-plugin": "6.7.0", "@typescript-eslint/parser": "6.7.0", "dotenv": "17.3.1", @@ -33,8 +33,8 @@ "json-server": "1.0.0-beta.10", "pino-pretty": "13.1.3", "rimraf": "5.0.1", - "ts-node": "10.9.1", - "typescript": "5.2.2" + "ts-node": "10.9.2", + "typescript": "5.9.3" }, "engines": { "node": "^18.0.0", @@ -780,10 +780,14 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.17.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.17.17.tgz", - "integrity": "sha512-cOxcXsQ2sxiwkykdJqvyFS+MLQPLvIdwh5l6gNg8qF6s+C7XSkEWOZjK+XhUZd+mYvHV/180g2cnCcIl4l06Pw==", - "dev": true + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } }, "node_modules/@types/normalize-package-data": { "version": "2.4.1", @@ -5105,10 +5109,11 @@ } }, "node_modules/ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, + "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -5192,10 +5197,11 @@ } }, "node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5225,6 +5231,13 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -6003,10 +6016,13 @@ "dev": true }, "@types/node": { - "version": "18.17.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.17.17.tgz", - "integrity": "sha512-cOxcXsQ2sxiwkykdJqvyFS+MLQPLvIdwh5l6gNg8qF6s+C7XSkEWOZjK+XhUZd+mYvHV/180g2cnCcIl4l06Pw==", - "dev": true + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "requires": { + "undici-types": "~7.18.0" + } }, "@types/normalize-package-data": { "version": "2.4.1", @@ -8932,9 +8948,9 @@ "requires": {} }, "ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "requires": { "@cspotcode/source-map-support": "^0.8.0", @@ -8984,9 +9000,9 @@ } }, "typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true }, "unbox-primitive": { @@ -9006,6 +9022,12 @@ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==" }, + "undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true + }, "uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index f14e714..7a536e8 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@typegoose/typegoose": "13.2.1", "@types/convict": "6.1.6", "@types/convict-format-with-validator": "6.0.6", - "@types/node": "18.17.17", + "@types/node": "25.5.2", "@typescript-eslint/eslint-plugin": "6.7.0", "@typescript-eslint/parser": "6.7.0", "dotenv": "17.3.1", @@ -31,8 +31,8 @@ "json-server": "1.0.0-beta.10", "pino-pretty": "13.1.3", "rimraf": "5.0.1", - "ts-node": "10.9.1", - "typescript": "5.2.2" + "ts-node": "10.9.2", + "typescript": "5.9.3" }, "type": "module", "engines": { diff --git a/src/main.rest.ts b/src/main.rest.ts index b258ea8..353ddd2 100644 --- a/src/main.rest.ts +++ b/src/main.rest.ts @@ -8,12 +8,14 @@ import { Component } from './shared/types/index.js'; import { createUserContainer } from './shared/modules/user/index.js'; import { createOfferContainer } from './shared/modules/offer/index.js'; +import { createCommentContainer } from './shared/modules/comment/index.js'; async function bootstrap() { const appContainer = Container.merge( createRestApplicationContainer(), createUserContainer(), createOfferContainer(), + createCommentContainer(), ); const app = appContainer.get(Component.RestApplication); diff --git a/src/shared/modules/comment/comment-service.interface.ts b/src/shared/modules/comment/comment-service.interface.ts new file mode 100644 index 0000000..782079c --- /dev/null +++ b/src/shared/modules/comment/comment-service.interface.ts @@ -0,0 +1,9 @@ +import { DocumentType } from '@typegoose/typegoose'; +import { CreateCommentDTO } from './dto/create-comment.dto.js'; +import { CommentEntity } from './comment.entity.js'; + +export interface CommentService { + create(dto: CreateCommentDTO): Promise>; + findByOfferId(offerId: string): Promise[]>; + deleteByOfferId(offerId: string): Promise; +} diff --git a/src/shared/modules/comment/comment.container.ts b/src/shared/modules/comment/comment.container.ts new file mode 100644 index 0000000..926b72a --- /dev/null +++ b/src/shared/modules/comment/comment.container.ts @@ -0,0 +1,21 @@ +import { Container } from 'inversify'; +import { CommentService } from './comment-service.interface.js'; +import { Component } from '../../types/index.js'; +import { DefaultCommentService } from './default-comment.service.js'; +import { types } from '@typegoose/typegoose'; +import { CommentEntity, CommentModel } from './comment.entity.js'; + +export function createCommentContainer() { + const commentContainer = new Container(); + + commentContainer + .bind(Component.CommentService) + .to(DefaultCommentService) + .inSingletonScope(); + + commentContainer + .bind>(Component.CommentModel) + .toConstantValue(CommentModel); + + return commentContainer; +} diff --git a/src/shared/modules/comment/comment.entity.ts b/src/shared/modules/comment/comment.entity.ts new file mode 100644 index 0000000..718df10 --- /dev/null +++ b/src/shared/modules/comment/comment.entity.ts @@ -0,0 +1,35 @@ +import { + defaultClasses, + getModelForClass, + modelOptions, + prop, + Ref, +} from '@typegoose/typegoose'; +import { OfferEntity } from '../offer/index.js'; +import { UserEntity } from '../user/index.js'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface CommentEntity extends defaultClasses.Base {} + +@modelOptions({ + schemaOptions: { + collection: 'comments', + timestamps: true, + }, +}) +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class CommentEntity extends defaultClasses.TimeStamps { + @prop({ trim: true, required: true }) + public text: string; + + @prop({ required: true, min: 1, max: 5, default: 0 }) + public rating: number; + + @prop({ ref: OfferEntity, required: true }) + public offerId: Ref; + + @prop({ ref: UserEntity, required: true }) + public userId: Ref; +} + +export const CommentModel = getModelForClass(CommentEntity); diff --git a/src/shared/modules/comment/default-comment.service.ts b/src/shared/modules/comment/default-comment.service.ts new file mode 100644 index 0000000..514eff5 --- /dev/null +++ b/src/shared/modules/comment/default-comment.service.ts @@ -0,0 +1,33 @@ +import { inject, injectable } from 'inversify'; +import { CommentService } from './comment-service.interface.js'; +import { Component } from '../../types/index.js'; +import { DocumentType, types } from '@typegoose/typegoose'; +import { CommentEntity } from './comment.entity.js'; +import { CreateCommentDTO } from './dto/create-comment.dto.js'; + +@injectable() +export class DefaultCommentService implements CommentService { + constructor( + @inject(Component.CommentModel) + private readonly commentModel: types.ModelType, + ) {} + + public async create( + dto: CreateCommentDTO, + ): Promise> { + const comment = await this.commentModel.create(dto); + return comment.populate('userId'); + } + + public async findByOfferId( + offerId: string, + ): Promise[]> { + return this.commentModel.find({ offerId }).populate('userId'); + } + + public async deleteByOfferId(offerId: string): Promise { + const result = await this.commentModel.deleteMany({ offerId }).exec(); + + return result.deletedCount; + } +} diff --git a/src/shared/modules/comment/dto/create-comment.dto.ts b/src/shared/modules/comment/dto/create-comment.dto.ts new file mode 100644 index 0000000..17ee2c9 --- /dev/null +++ b/src/shared/modules/comment/dto/create-comment.dto.ts @@ -0,0 +1,6 @@ +export class CreateCommentDTO { + public text: string; + public rating: number; + public offerId: string; + public userId: string; +} diff --git a/src/shared/modules/comment/index.ts b/src/shared/modules/comment/index.ts new file mode 100644 index 0000000..bbdc1db --- /dev/null +++ b/src/shared/modules/comment/index.ts @@ -0,0 +1,4 @@ +export * from './comment-service.interface.js'; +export * from './comment.container.js'; +export * from './comment.entity.js'; +export * from './default-comment.service.js'; diff --git a/src/shared/modules/offer/default-offer.service.ts b/src/shared/modules/offer/default-offer.service.ts index bfb50f1..8ad9ee9 100644 --- a/src/shared/modules/offer/default-offer.service.ts +++ b/src/shared/modules/offer/default-offer.service.ts @@ -1,10 +1,17 @@ import { inject, injectable } from 'inversify'; import { OfferService } from './offer-service.interface.js'; -import { Component } from '../../types/index.js'; +import { City, Component, SortType } from '../../types/index.js'; import { Logger } from '../../libs/logger/index.js'; import { types } from '@typegoose/typegoose'; import { OfferEntity } 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, +} from './offer.constants.js'; +import { CommentEntity } from '../comment/index.js'; +import { Types } from 'mongoose'; @injectable() export class DefaultOfferService implements OfferService { @@ -12,6 +19,8 @@ export class DefaultOfferService implements OfferService { @inject(Component.Logger) private readonly logger: Logger, @inject(Component.OfferModel) private readonly offerModel: types.ModelType, + @inject(Component.CommentModel) + private readonly commentModel: types.ModelType, ) {} public async create( @@ -26,6 +35,90 @@ export class DefaultOfferService implements OfferService { public async findById( offerId: string, ): Promise | null> { - return this.offerModel.findById(offerId); + return this.offerModel.findById(offerId).populate(['userId']).exec(); + } + + public async find(count: number): Promise[]> { + return this.offerModel + .find() + .sort({ createdAt: SortType.Down }) + .limit(count ?? DEFAULT_OFFER_COUNT) + .populate(['userId']) + .exec(); + } + + public async deleteById( + offerId: string, + ): Promise | null> { + return this.offerModel.findByIdAndDelete(offerId).exec(); + } + + public async updateById( + offerId: string, + dto: UpdateOfferDTO, + ): Promise | null> { + return this.offerModel + .findByIdAndUpdate(offerId, dto, { new: true }) + .exec(); + } + + public async exists(documentId: string): Promise { + return (await this.offerModel.exists({ _id: documentId })) !== null; + } + + public async incCommentCount( + offerId: string, + ): Promise | null> { + return this.offerModel + .findByIdAndUpdate(offerId, { + $inc: { + commentCount: 1, + }, + }) + .exec(); + } + + async calcRating(offerId: string): Promise { + const ratings = await this.commentModel.aggregate([ + { $match: { offerId: new Types.ObjectId(offerId) } }, + { $group: { _id: '$offerId', avgRating: { $avg: '$rating' } } }, + { + $project: { + _id: 0, + avgRating: { $round: ['$avgRating', 1] }, + }, + }, + ]); + + const rating = ratings?.length ? ratings[0]?.avgRating : 0; + + await this.offerModel + .findByIdAndUpdate( + offerId, + { + rating, + }, + { new: true }, + ) + .exec(); + } + + public async findPremiumByCity( + city: City, + count: number, + ): Promise[]> { + return this.offerModel + .find({ city, isPremium: true }) + .sort({ createdAt: SortType.Down }) + .limit(count ?? DEFAULT_PREMIUM_OFFER_COUNT) + .populate(['userId']) + .exec(); + } + + public async findByFavorite(): Promise[]> { + return this.offerModel + .find({ isFavorite: true }) + .populate(['userId']) + .exec(); } } diff --git a/src/shared/modules/offer/dto/update-offer.dto.ts b/src/shared/modules/offer/dto/update-offer.dto.ts new file mode 100644 index 0000000..ece2429 --- /dev/null +++ b/src/shared/modules/offer/dto/update-offer.dto.ts @@ -0,0 +1,16 @@ +import { City, Coordinates, Facility, Housing } from '../../../types/index.js'; + +export class UpdateOfferDTO { + title: string; + description: string; + city: City; + previewImage: string; + images: string[]; + isPremium: boolean; + type: Housing; + rooms: number; + guests: number; + price: number; + facilities: Facility[]; + location: Coordinates; +} diff --git a/src/shared/modules/offer/offer-service.interface.ts b/src/shared/modules/offer/offer-service.interface.ts index 8ff01a1..1f6262d 100644 --- a/src/shared/modules/offer/offer-service.interface.ts +++ b/src/shared/modules/offer/offer-service.interface.ts @@ -1,8 +1,24 @@ import { CreateOfferDTO } from './dto/create-offer.dto.js'; import { DocumentType } from '@typegoose/typegoose'; import { OfferEntity } from './offer.entity.js'; +import { UpdateOfferDTO } from './dto/update-offer.dto.js'; +import { City } from '../../types/index.js'; export interface OfferService { create(dto: CreateOfferDTO): Promise>; findById(offerId: string): Promise | null>; + find(count: number): Promise[]>; + deleteById(offerId: string): Promise | null>; + updateById( + offerId: string, + dto: UpdateOfferDTO, + ): Promise | null>; + incCommentCount(offerId: string): Promise | null>; + findPremiumByCity( + city: City, + count: number, + ): Promise[]>; + findByFavorite(): Promise[]>; + calcRating(offerId: string): Promise; + exists(documentId: string): Promise; } diff --git a/src/shared/modules/offer/offer.constants.ts b/src/shared/modules/offer/offer.constants.ts new file mode 100644 index 0000000..e02f6cb --- /dev/null +++ b/src/shared/modules/offer/offer.constants.ts @@ -0,0 +1,2 @@ +export const DEFAULT_OFFER_COUNT = 60; +export const DEFAULT_PREMIUM_OFFER_COUNT = 3; diff --git a/src/shared/modules/user/default-user.service.ts b/src/shared/modules/user/default-user.service.ts index c23880b..e32447e 100644 --- a/src/shared/modules/user/default-user.service.ts +++ b/src/shared/modules/user/default-user.service.ts @@ -5,6 +5,7 @@ import { UserEntity } from './user.entity.js'; import { inject, injectable } from 'inversify'; import { Component } from '../../types/index.js'; import { Logger } from '../../libs/logger/index.js'; +import { UpdateUserDTO } from './dto/update-user.dto.js'; @injectable() export class DefaultUserService implements UserService { @@ -45,4 +46,11 @@ export class DefaultUserService implements UserService { return this.create(dto, salt); } + + public async updateById( + userId: string, + dto: UpdateUserDTO, + ): Promise | null> { + return this.userModel.findByIdAndUpdate(userId, dto, { new: true }).exec(); + } } diff --git a/src/shared/modules/user/dto/update-user.dto.ts b/src/shared/modules/user/dto/update-user.dto.ts new file mode 100644 index 0000000..c6d965b --- /dev/null +++ b/src/shared/modules/user/dto/update-user.dto.ts @@ -0,0 +1,5 @@ +export class UpdateUserDTO { + public avatarPath?: string; + public firstname?: string; + public lastname?: string; +} diff --git a/src/shared/modules/user/user-service.interface.ts b/src/shared/modules/user/user-service.interface.ts index 72d9de3..9a7c544 100644 --- a/src/shared/modules/user/user-service.interface.ts +++ b/src/shared/modules/user/user-service.interface.ts @@ -1,6 +1,7 @@ import { DocumentType } from '@typegoose/typegoose'; import { CreateUserDto } from './dto/create-user.dto.js'; import { UserEntity } from './user.entity.js'; +import { UpdateUserDTO } from './dto/update-user.dto.js'; export interface UserService { create(dto: CreateUserDto, salt: string): Promise>; @@ -9,4 +10,8 @@ export interface UserService { dto: CreateUserDto, salt: string, ): Promise>; + updateById( + userId: string, + dto: UpdateUserDTO, + ): Promise | null>; } diff --git a/src/shared/types/component.enum.ts b/src/shared/types/component.enum.ts index f3c69a6..17c7c19 100644 --- a/src/shared/types/component.enum.ts +++ b/src/shared/types/component.enum.ts @@ -7,4 +7,6 @@ export const Component = { UserModel: Symbol.for('UserModel'), OfferService: Symbol.for('OfferService'), OfferModel: Symbol.for('OfferModel'), + CommentService: Symbol.for('CommentService'), + CommentModel: Symbol.for('CommentModel'), } as const; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 7b1772a..4ba2cdf 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -7,3 +7,4 @@ export * from './user.type.js'; export * from './mock-server-data.type.js'; export * from './component.enum.js'; export * from './user-type.type.js'; +export * from './sort-type.enum.js'; diff --git a/src/shared/types/sort-type.enum.ts b/src/shared/types/sort-type.enum.ts new file mode 100644 index 0000000..727ba7c --- /dev/null +++ b/src/shared/types/sort-type.enum.ts @@ -0,0 +1,4 @@ +export enum SortType { + Down = -1, + Up = 1, +} diff --git a/tsconfig.json b/tsconfig.json index bccf5fd..c0a0a7e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "allowSyntheticDefaultImports": true, "experimentalDecorators": true, "sourceMap": true, + "rootDir": "./src", "outDir": "./dist", "emitDecoratorMetadata": true, "types": [