diff --git a/src/rest/rest.application.ts b/src/rest/rest.application.ts index d23f675..7f43bc3 100644 --- a/src/rest/rest.application.ts +++ b/src/rest/rest.application.ts @@ -8,6 +8,7 @@ import express, { Express } from 'express'; import { Controller } from '../shared/libs/rest/controller/controller.interface.js'; import { ExceptionFilter } from '../shared/libs/rest/exception-filter/exception-filter.interface.js'; import { ParseTokenMiddleware } from '../shared/libs/rest/middleware/parse-token.middleware.js'; +import { getFullServerPath } from '../shared/helpers/common.js'; @injectable() export class RestApplication { @@ -57,6 +58,11 @@ export class RestApplication { '/upload', express.static(this.config.get('UPLOAD_FILE_DIRECTORY')), ); + + this.server.use( + '/static', + express.static(this.config.get('STATIC_DIRECTORY_PATH')), + ); } private async _initControllers() { @@ -91,7 +97,7 @@ export class RestApplication { await this.__initExceptionFilters(); this.logger.info( - `🚀 Server started on http://localhost:${this.config.get('PORT')}`, + `🚀 Server started on ${getFullServerPath(this.config.get('HOST'), this.config.get('PORT'))}`, ); this.logger.info('Database initialization'); diff --git a/src/rest/rest.container.ts b/src/rest/rest.container.ts index 2b31faa..80b213a 100644 --- a/src/rest/rest.container.ts +++ b/src/rest/rest.container.ts @@ -9,6 +9,7 @@ import { } from '../shared/libs/database-client/index.js'; import { ExceptionFilter } from '../shared/libs/rest/exception-filter/exception-filter.interface.js'; import { AppExceptionFilter } from '../shared/libs/rest/exception-filter/app-exception-filter.js'; +import { PathTransformer } from '../shared/libs/transform/path-transformer.js'; export function createRestContainer() { const restApplicationContainer = new Container(); @@ -32,6 +33,10 @@ export function createRestContainer() { .bind(Component.ExceptionFilter) .to(AppExceptionFilter) .inSingletonScope(); + restApplicationContainer + .bind(Component.PathTransformer) + .to(PathTransformer) + .inSingletonScope(); return restApplicationContainer; } diff --git a/src/shared/helpers/common.ts b/src/shared/helpers/common.ts index 1643ffa..ee06918 100644 --- a/src/shared/helpers/common.ts +++ b/src/shared/helpers/common.ts @@ -29,3 +29,7 @@ export function fillDTO(dto: ClassConstructor, plainObject: V) { export function createErrorObject(message: string) { return { error: message }; } + +export function getFullServerPath(host: string, port: number) { + return `http://${host}:${port}`; +} diff --git a/src/shared/libs/config/rest.config.schema.ts b/src/shared/libs/config/rest.config.schema.ts index a318da0..7fa9001 100644 --- a/src/shared/libs/config/rest.config.schema.ts +++ b/src/shared/libs/config/rest.config.schema.ts @@ -11,6 +11,8 @@ export type RestSchema = { DB_NAME: string; UPLOAD_FILE_DIRECTORY: string; JWT_SECRET: string; + HOST: string; + STATIC_DIRECTORY_PATH: string; }; convict.addFormats(validator); @@ -70,4 +72,16 @@ export const restSchema = convict({ env: 'JWT_SECRET', default: null, }, + HOST: { + doc: 'Host name', + format: String, + env: 'HOST', + default: null, + }, + STATIC_DIRECTORY_PATH: { + doc: 'Static directory path', + format: String, + env: 'STATIC_DIRECTORY_PATH', + default: null, + }, }); diff --git a/src/shared/libs/rest/controller/base-controller.abstract.ts b/src/shared/libs/rest/controller/base-controller.abstract.ts index d130324..427f8f1 100644 --- a/src/shared/libs/rest/controller/base-controller.abstract.ts +++ b/src/shared/libs/rest/controller/base-controller.abstract.ts @@ -1,10 +1,12 @@ -import { injectable } from 'inversify'; +import { inject, injectable } from 'inversify'; import { Controller } from './controller.interface.js'; import { Response, Router } from 'express'; import { Route } from '../types/route.interface.js'; import { StatusCodes } from 'http-status-codes'; import { Logger } from '../../logger/index.js'; import expressAsyncHandler from 'express-async-handler'; +import { Component } from '../../../types/container.js'; +import { PathTransformer } from '../../transform/path-transformer.js'; const DEFAULT_CONTENT_TYPE = 'application/json'; @@ -16,6 +18,9 @@ export abstract class BaseController implements Controller { this._router = Router(); } + @inject(Component.PathTransformer) + protected readonly pathTransformer: PathTransformer; + get router(): Router { return this._router; } @@ -37,7 +42,10 @@ export abstract class BaseController implements Controller { } public send(res: Response, statusCode: number, data: T) { - res.type(DEFAULT_CONTENT_TYPE).status(statusCode).json(data); + const modifiedData = this.pathTransformer.execute( + data as Record, + ); + res.type(DEFAULT_CONTENT_TYPE).status(statusCode).json(modifiedData); } public created(res: Response, data: T) { diff --git a/src/shared/libs/rest/middleware/upload-file.middleware.ts b/src/shared/libs/rest/middleware/upload-file.middleware.ts index 3d0de85..2e7631f 100644 --- a/src/shared/libs/rest/middleware/upload-file.middleware.ts +++ b/src/shared/libs/rest/middleware/upload-file.middleware.ts @@ -1,16 +1,20 @@ import { NextFunction, Request, Response } from 'express'; -import { Middleware } from '../index.js'; import multer, { diskStorage } from 'multer'; import { extension } from 'mime-types'; import * as crypto from 'node:crypto'; +import { Middleware } from './middleware.interface.js'; export class UploadFileMiddleware implements Middleware { constructor( - private readonly fieldName: string, - private readonly uploadDirectory: string, + private uploadDirectory: string, + private fieldName: string, ) {} - execute(req: Request, res: Response, next: NextFunction): void { + public async execute( + req: Request, + res: Response, + next: NextFunction, + ): Promise { const storage = diskStorage({ destination: this.uploadDirectory, filename: (_req, file, callback) => { diff --git a/src/shared/libs/transform/path-transformer.ts b/src/shared/libs/transform/path-transformer.ts new file mode 100644 index 0000000..2452170 --- /dev/null +++ b/src/shared/libs/transform/path-transformer.ts @@ -0,0 +1,62 @@ +import { inject, injectable } from 'inversify'; +import { Logger } from '../logger/index.js'; +import { Component } from '../../types/container.js'; +import { Config, RestSchema } from '../config/index.js'; +import { + DEFAULT_STATIC_IMAGES, + STATIC_RESOURCE_FIELDS, +} from './transformer.const.js'; +import { getFullServerPath } from '../../helpers/common.js'; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +@injectable() +export class PathTransformer { + constructor( + @inject(Component.Logger) private readonly logger: Logger, + @inject(Component.Config) private readonly config: Config, + ) { + this.logger.info('PathTransformer was created'); + } + + private hasDefaultImage(value: string) { + return DEFAULT_STATIC_IMAGES.includes(value); + } + + private isStaticProperty(property: string) { + return STATIC_RESOURCE_FIELDS.includes(property); + } + + public execute(data: Record) { + const stack = [data]; + while (stack.length > 0) { + const current = stack.pop(); + for (const key in current) { + if (Object.hasOwn(current, key)) { + const value = current[key]; + + if (isObject(value)) { + stack.push(value); + continue; + } + + if (this.isStaticProperty(key) && typeof value === 'string') { + const staticPath = '/static'; + const uploadPath = '/upload'; + const serverHost = this.config.get('HOST'); + const serverPort = this.config.get('PORT'); + const rootPath = this.hasDefaultImage(value) + ? staticPath + : uploadPath; + current[key] = + `${getFullServerPath(serverHost, serverPort)}${rootPath}/${value}`; + } + } + } + } + + return data; + } +} diff --git a/src/shared/libs/transform/transformer.const.ts b/src/shared/libs/transform/transformer.const.ts new file mode 100644 index 0000000..b113565 --- /dev/null +++ b/src/shared/libs/transform/transformer.const.ts @@ -0,0 +1,3 @@ +export const DEFAULT_STATIC_IMAGES = ['default-avatar.jpg']; + +export const STATIC_RESOURCE_FIELDS = ['avatar', 'imagePreview', 'images']; diff --git a/src/shared/modules/comment/comment-service.ts b/src/shared/modules/comment/comment-service.ts index de1e4eb..a9bd51f 100644 --- a/src/shared/modules/comment/comment-service.ts +++ b/src/shared/modules/comment/comment-service.ts @@ -6,6 +6,7 @@ import { CommentDto } from './dto/comment-dto.js'; import { CommentEntity } from './comment.entity.js'; import { DocumentType, types } from '@typegoose/typegoose'; import { OfferServiceInterface } from '../offer/offer-service.interface.js'; +import { DISPLAY_COMMENT_COUNT } from './comment.const.js'; @injectable() export class CommentService implements CommentServiceInterface { @@ -31,7 +32,11 @@ export class CommentService implements CommentServiceInterface { public async findByOfferId( offerId: string, ): Promise[]> { - return this.commentModel.find({ offerId }).populate('author'); + return this.commentModel + .find({ offerId }) + .limit(DISPLAY_COMMENT_COUNT) + .sort({ date: -1 }) + .exec(); } public async deleteByOfferId(offerId: string): Promise { diff --git a/src/shared/modules/comment/comment.const.ts b/src/shared/modules/comment/comment.const.ts new file mode 100644 index 0000000..9f0214e --- /dev/null +++ b/src/shared/modules/comment/comment.const.ts @@ -0,0 +1 @@ +export const DISPLAY_COMMENT_COUNT = 50; diff --git a/src/shared/modules/offer/dto/offer-dto.ts b/src/shared/modules/offer/dto/offer-dto.ts index e9aa503..f630359 100644 --- a/src/shared/modules/offer/dto/offer-dto.ts +++ b/src/shared/modules/offer/dto/offer-dto.ts @@ -72,7 +72,7 @@ export class CreateOfferDto { @IsArray({ message: OfferDTOValidationMessage.goods.isArray }) goods!: Amenity[]; - @IsObject({ message: OfferDTOValidationMessage.host.isObject }) + @IsString({ message: OfferDTOValidationMessage.host.isObject }) host!: string; @IsInt({ message: OfferDTOValidationMessage.commentsCount.isInt }) diff --git a/src/shared/modules/offer/offer-controller.ts b/src/shared/modules/offer/offer-controller.ts index 0c67e7d..174c26d 100644 --- a/src/shared/modules/offer/offer-controller.ts +++ b/src/shared/modules/offer/offer-controller.ts @@ -18,6 +18,8 @@ import { DocumentExistsMiddleware } from '../../libs/rest/middleware/document-ex import { DocumentExists } from '../../libs/rest/types/document-exists.interface.js'; import { PrivateRouteMiddleware } from '../../libs/rest/middleware/private-route.middleware.js'; import { UserServiceInterface } from '../user/user-service.interface.js'; +import { CommentRDO } from '../comment/rdo/comment-rdo.js'; +import { MAX_DISPLAY_OFFERS_COUNT } from './offer.const.js'; export class OfferController extends BaseController { constructor( @@ -62,6 +64,20 @@ export class OfferController extends BaseController { new DocumentExistsMiddleware('offerId', 'offer', this.offerService), ], }); + this.addRoute({ + path: '/premium', + method: HttpMethod.Get, + handler: this.showPremiumOffers, + }); + this.addRoute({ + path: '/:offerId', + method: HttpMethod.Get, + handler: this.getDetailOffers, + middlewares: [ + new ValidateObjectIdMiddleware('offerId'), + new DocumentExistsMiddleware('offerId', 'offer', this.offerService), + ], + }); this.addRoute({ path: '/:offerId/comments', method: HttpMethod.Get, @@ -74,7 +90,8 @@ export class OfferController extends BaseController { } public async index(req: Request, res: Response) { - const offers = await this.offerService.find(); + const limit = Number(req.params.limit) || MAX_DISPLAY_OFFERS_COUNT; + const offers = await this.offerService.find(limit); let favoritesIds: string[] = []; if (req.tokenPayload) { @@ -105,11 +122,18 @@ export class OfferController extends BaseController { const result = await this.offerService.createOffer({ ...req.body, - userId: req.tokenPayload.id, + host: req.tokenPayload.id, }); this.created(res, fillDTO(OfferRDO, result)); } + public async getDetailOffers(req: Request, res: Response) { + const offer = await this.offerService.findOfferById( + req.params.offerId as string, + ); + this.ok(res, fillDTO(OfferRDO, offer)); + } + public async update(req: Request, res: Response) { const updateOffer = await this.offerService.updateById( req.params.id as string, @@ -144,8 +168,8 @@ export class OfferController extends BaseController { public async getComments(req: Request, res: Response) { const offerId = req.params.offerId; - const comments = this.commentService.findByOfferId(offerId as string); + const comments = await this.commentService.findByOfferId(offerId as string); - this.ok(res, fillDTO(OfferRDO, comments)); + this.ok(res, fillDTO(CommentRDO, comments)); } } diff --git a/src/shared/modules/offer/offer-service.interface.ts b/src/shared/modules/offer/offer-service.interface.ts index 6f85822..7cc5e9b 100644 --- a/src/shared/modules/offer/offer-service.interface.ts +++ b/src/shared/modules/offer/offer-service.interface.ts @@ -15,5 +15,5 @@ export interface OfferServiceInterface { incCommentCount(offerId: string): Promise | null>; decCommentCount(offerId: string): Promise | null>; findPremiumOffersByCity(city: City): Promise[]>; - find(): Promise[]>; + find(limit: number): Promise[]>; } diff --git a/src/shared/modules/offer/offer-service.ts b/src/shared/modules/offer/offer-service.ts index 8ad7b2b..6fda718 100644 --- a/src/shared/modules/offer/offer-service.ts +++ b/src/shared/modules/offer/offer-service.ts @@ -9,6 +9,7 @@ import { DocumentType, types } from '@typegoose/typegoose'; import { CommentEntity } from '../comment/comment.entity.js'; import { Types } from 'mongoose'; import { DocumentExists } from '../../libs/rest/types/document-exists.interface.js'; +import { DISPLAY_PREMIUM_OFFERS_COUNT } from './offer.const.js'; @injectable() export class OfferService implements OfferServiceInterface, DocumentExists { @@ -63,8 +64,8 @@ export class OfferService implements OfferServiceInterface, DocumentExists { .exec(); } - async find(): Promise[]> { - return this.offerModel.find().populate('host').exec(); + async find(limit: number): Promise[]> { + return this.offerModel.find().populate('host').limit(limit).exec(); } async findPremiumOffersByCity( @@ -72,6 +73,8 @@ export class OfferService implements OfferServiceInterface, DocumentExists { ): Promise[]> { return this.offerModel .find({ city, isPremium: true }) + .sort({ date: -1 }) + .limit(DISPLAY_PREMIUM_OFFERS_COUNT) .populate('host') .exec(); } diff --git a/src/shared/modules/offer/offer.const.ts b/src/shared/modules/offer/offer.const.ts new file mode 100644 index 0000000..0071f44 --- /dev/null +++ b/src/shared/modules/offer/offer.const.ts @@ -0,0 +1,2 @@ +export const DISPLAY_PREMIUM_OFFERS_COUNT = 3; +export const MAX_DISPLAY_OFFERS_COUNT = 60; diff --git a/src/shared/modules/offer/offer.entity.ts b/src/shared/modules/offer/offer.entity.ts index b0df816..c397e12 100644 --- a/src/shared/modules/offer/offer.entity.ts +++ b/src/shared/modules/offer/offer.entity.ts @@ -5,7 +5,7 @@ import { prop, Ref, } from '@typegoose/typegoose'; -import { Amenity, City, HousingType } from '../../types/offer.js'; +import { Amenity, City, HousingType, Location } from '../../types/offer.js'; import { UserEntity } from '../user/user.entity.js'; // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging @@ -17,10 +17,6 @@ export interface OfferEntity extends defaultClasses.Base {} timestamps: true, }, }) -class Location { - public latitude!: number; - public longitude!: number; -} // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class OfferEntity extends defaultClasses.TimeStamps { @@ -104,7 +100,7 @@ export class OfferEntity extends defaultClasses.TimeStamps { @prop({ required: true }) public commentsCount!: number; - @prop({ _id: false, type: () => Location, required: true }) + @prop({ _id: false, required: true }) public location!: Location; } diff --git a/src/shared/modules/user/dto/user-dto.ts b/src/shared/modules/user/dto/user-dto.ts index 90906cc..a6da1f3 100644 --- a/src/shared/modules/user/dto/user-dto.ts +++ b/src/shared/modules/user/dto/user-dto.ts @@ -1,10 +1,4 @@ -import { - IsBoolean, - IsEmail, - IsString, - MaxLength, - MinLength, -} from 'class-validator'; +import { IsBoolean, IsEmail, MaxLength, MinLength } from 'class-validator'; import { UserDTOValidationMessage } from './user-dto.messages.js'; export class CreateUserDto { @@ -15,9 +9,6 @@ export class CreateUserDto { @IsEmail({}, { message: UserDTOValidationMessage.email.isEmail }) public email: string; - @IsString({ message: UserDTOValidationMessage.avatar.isString }) - public avatar: string; - @MinLength(6, { message: UserDTOValidationMessage.password.min }) @MaxLength(12, { message: UserDTOValidationMessage.password.max }) public password: string; diff --git a/src/shared/modules/user/rdo/user.rdo.ts b/src/shared/modules/user/rdo/user.rdo.ts index 95f044d..33c743c 100644 --- a/src/shared/modules/user/rdo/user.rdo.ts +++ b/src/shared/modules/user/rdo/user.rdo.ts @@ -17,6 +17,38 @@ export class UserRDO { favorites: string[]; } +export class UserFavoriteOfferRDO { + @Expose() + title: string; + + @Expose() + date: Date; + + @Expose() + city: string; + + @Expose() + type: string; + + @Expose() + price: number; + + @Expose() + isFavorite: boolean; + + @Expose() + imagePreview: string; + + @Expose() + isPremium: boolean; + + @Expose() + rating: number; + + @Expose() + commentsCount: number; +} + export class LoggedUserRDO { @Expose() public email: string; diff --git a/src/shared/modules/user/user-controller.ts b/src/shared/modules/user/user-controller.ts index 6979ca3..febc222 100644 --- a/src/shared/modules/user/user-controller.ts +++ b/src/shared/modules/user/user-controller.ts @@ -7,13 +7,18 @@ import { Logger } from '../../libs/logger/index.js'; import { UserServiceInterface } from './user-service.interface.js'; import { StatusCodes } from 'http-status-codes'; import { Config, RestSchema } from '../../libs/config/index.js'; -import { LoggedUserRDO, UserRDO } from './rdo/user.rdo.js'; +import { + LoggedUserRDO, + UserFavoriteOfferRDO, + UserRDO, +} from './rdo/user.rdo.js'; import { fillDTO } from '../../helpers/common.js'; import { HttpError } from '../../libs/rest/errors/http-error.js'; import { ValidateDTOMiddleware } from '../../libs/rest/middleware/validate-object.middleware.js'; import { CreateUserDto, LoginUserDTO } from './dto/user-dto.js'; import { AuthServiceInterface, UserNotFoundException } from '../auth/index.js'; import { PrivateRouteMiddleware } from '../../libs/rest/middleware/private-route.middleware.js'; +import { UploadFileMiddleware } from '../../libs/rest/middleware/upload-file.middleware.js'; export class UserController extends BaseController { constructor( @@ -39,10 +44,23 @@ export class UserController extends BaseController { middlewares: [new ValidateDTOMiddleware(LoginUserDTO)], }); + this.addRoute({ + path: '/:userId/favorite', + method: HttpMethod.Get, + handler: this.getFavorites, + middlewares: [new PrivateRouteMiddleware()], + }); + this.addRoute({ path: '/:userId/avatar', method: HttpMethod.Post, handler: this.uploadAvatar, + middlewares: [ + new UploadFileMiddleware( + this.config.get('UPLOAD_FILE_DIRECTORY'), + 'avatar', + ), + ], }); this.addRoute({ @@ -70,6 +88,20 @@ export class UserController extends BaseController { this.created(res, fillDTO(UserRDO, result)); } + public async getFavorites(req: Request, res: Response) { + const user = await this.userService.findById(req.tokenPayload.id); + if (!user) { + throw new HttpError( + StatusCodes.UNAUTHORIZED, + 'Unauthorized', + 'UserController', + ); + } + const favorites = await this.userService.findFavoriteOffers(user.id); + + this.ok(res, fillDTO(UserFavoriteOfferRDO, favorites)); + } + public async auth(req: Request, res: Response) { const user = await this.authService.verify(req.body as LoginUserDTO); diff --git a/src/shared/modules/user/user-service.ts b/src/shared/modules/user/user-service.ts index 9412d7f..df5ec79 100644 --- a/src/shared/modules/user/user-service.ts +++ b/src/shared/modules/user/user-service.ts @@ -6,6 +6,7 @@ import { inject, injectable } from 'inversify'; import { Component } from '../../types/container.js'; import { Logger } from '../../libs/logger/index.js'; import { OfferEntity } from '../offer/offer.entity.js'; +import { DEFAULT_AVATAR_FILE_NAME } from './user.constant.js'; @injectable() export class UserService implements UserServiceInterface { @@ -18,7 +19,7 @@ export class UserService implements UserServiceInterface { dto: CreateUserDto, salt: string, ): Promise> { - const user = new UserEntity(dto); + const user = new UserEntity({ ...dto, avatar: DEFAULT_AVATAR_FILE_NAME }); user.setPassword(dto.password, salt); const result = await UserModel.create(user); @@ -38,8 +39,10 @@ export class UserService implements UserServiceInterface { async findFavoriteOffers( userId: number, ): Promise[]> { - const user = await UserModel.findById(userId).populate('favorites').exec(); - + const user = await this.userModel + .findById(userId) + .populate('favorites') + .exec(); return (user?.favorites as DocumentType[]) ?? []; } diff --git a/src/shared/modules/user/user.constant.ts b/src/shared/modules/user/user.constant.ts new file mode 100644 index 0000000..29633af --- /dev/null +++ b/src/shared/modules/user/user.constant.ts @@ -0,0 +1 @@ +export const DEFAULT_AVATAR_FILE_NAME = 'default-avatar.jpg'; diff --git a/src/shared/types/container.ts b/src/shared/types/container.ts index 7125145..2abbcfa 100644 --- a/src/shared/types/container.ts +++ b/src/shared/types/container.ts @@ -15,4 +15,5 @@ export const Component = { CommentController: Symbol.for('CommentController'), AuthService: Symbol.for('AuthService'), AuthExceptionFilter: Symbol.for('AuthExceptionFilter'), + PathTransformer: Symbol.for('PathTransformer'), } as const; diff --git a/src/static/default-avatar.jpg b/src/static/default-avatar.jpg new file mode 100644 index 0000000..ef5daf0 Binary files /dev/null and b/src/static/default-avatar.jpg differ