diff --git a/apps/api/src/modules/listings/application/index.ts b/apps/api/src/modules/listings/application/index.ts index cdc9a52..5b6416b 100644 --- a/apps/api/src/modules/listings/application/index.ts +++ b/apps/api/src/modules/listings/application/index.ts @@ -7,6 +7,8 @@ export { UploadMediaCommand } from './commands/upload-media/upload-media.command export { UploadMediaHandler } from './commands/upload-media/upload-media.handler'; export { ModerateListingCommand } from './commands/moderate-listing/moderate-listing.command'; export { ModerateListingHandler } from './commands/moderate-listing/moderate-listing.handler'; +export { UpdateListingCommand } from './commands/update-listing/update-listing.command'; +export { UpdateListingHandler, type UpdateListingResult } from './commands/update-listing/update-listing.handler'; // Queries export { GetListingQuery } from './queries/get-listing/get-listing.query'; @@ -15,3 +17,8 @@ export { SearchListingsQuery } from './queries/search-listings/search-listings.q export { SearchListingsHandler } from './queries/search-listings/search-listings.handler'; export { GetPendingModerationQuery } from './queries/get-pending-moderation/get-pending-moderation.query'; export { GetPendingModerationHandler } from './queries/get-pending-moderation/get-pending-moderation.handler'; +export { GetPriceHistoryQuery } from './queries/get-price-history/get-price-history.query'; +export { GetPriceHistoryHandler, type PriceHistoryItem } from './queries/get-price-history/get-price-history.handler'; + +// Event Handlers +export { RecordPriceHistoryHandler } from './event-handlers/record-price-history.handler'; diff --git a/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts b/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts index e92dafc..88c7947 100644 --- a/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts +++ b/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts @@ -8,15 +8,28 @@ import { CreateBucketCommand, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { Injectable, OnModuleInit } from '@nestjs/common'; -import { LoggerService } from '@modules/shared'; +import { Injectable, type OnModuleInit } from '@nestjs/common'; +import { type LoggerService } from '@modules/shared'; export const MEDIA_STORAGE_SERVICE = Symbol('MEDIA_STORAGE_SERVICE'); +export interface PresignedUploadResult { + uploadUrl: string; + objectKey: string; + publicUrl: string; +} + export interface IMediaStorageService { upload(buffer: Buffer, originalName: string, mimeType: string, folder: string): Promise; delete(fileUrl: string): Promise; getPresignedUploadUrl(objectKey: string, mimeType: string, expiresInSeconds?: number): Promise; + generatePresignedUpload( + folder: string, + fileName: string, + mimeType: string, + expiresInSeconds?: number, + ): Promise; + getPublicUrl(objectKey: string): string; } function requireEnv(key: string): string { @@ -117,6 +130,27 @@ export class MinioMediaStorageService implements IMediaStorageService, OnModuleI return getSignedUrl(this.s3, command, { expiresIn: expiresInSeconds }); } + async generatePresignedUpload( + folder: string, + fileName: string, + mimeType: string, + expiresInSeconds = 300, + ): Promise { + const ext = path.extname(fileName); + const hash = crypto.randomBytes(8).toString('hex'); + const objectKey = `${folder}/${Date.now()}-${hash}${ext}`; + + const uploadUrl = await this.getPresignedUploadUrl(objectKey, mimeType, expiresInSeconds); + const publicUrl = this.getPublicUrl(objectKey); + + return { uploadUrl, objectKey, publicUrl }; + } + + getPublicUrl(objectKey: string): string { + const protocol = this.useSSL ? 'https' : 'http'; + return `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectKey}`; + } + async delete(fileUrl: string): Promise { try { const urlObj = new URL(fileUrl); diff --git a/apps/api/src/modules/listings/presentation/__tests__/update-listing.dto.spec.ts b/apps/api/src/modules/listings/presentation/__tests__/update-listing.dto.spec.ts new file mode 100644 index 0000000..97f4e53 --- /dev/null +++ b/apps/api/src/modules/listings/presentation/__tests__/update-listing.dto.spec.ts @@ -0,0 +1,100 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { describe, it, expect } from 'vitest'; +import { UpdateListingDto } from '../dto/update-listing.dto'; + +describe('UpdateListingDto', () => { + it('should pass validation with title only', async () => { + const dto = plainToInstance(UpdateListingDto, { + title: 'Căn hộ 3PN view sông Sài Gòn', + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('should pass validation with description only', async () => { + const dto = plainToInstance(UpdateListingDto, { + description: 'Căn hộ cao cấp nội thất đầy đủ, view đẹp', + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('should pass validation with priceVND only', async () => { + const dto = plainToInstance(UpdateListingDto, { + priceVND: '5500000000', + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('should pass validation with amenities only', async () => { + const dto = plainToInstance(UpdateListingDto, { + amenities: ['Hồ bơi', 'Gym', 'Sân chơi trẻ em'], + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('should pass validation with all fields', async () => { + const dto = plainToInstance(UpdateListingDto, { + title: 'Căn hộ 3PN view sông Sài Gòn', + description: 'Căn hộ cao cấp nội thất đầy đủ, view đẹp', + priceVND: '5500000000', + rentPriceMonthly: '25000000', + amenities: ['Hồ bơi', 'Gym'], + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('should fail validation when title is too short', async () => { + const dto = plainToInstance(UpdateListingDto, { + title: 'AB', + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const titleError = errors.find((e) => e.property === 'title'); + expect(titleError).toBeDefined(); + }); + + it('should fail validation when description is too short', async () => { + const dto = plainToInstance(UpdateListingDto, { + description: 'Ngắn quá', + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const descError = errors.find((e) => e.property === 'description'); + expect(descError).toBeDefined(); + }); + + it('should fail validation when amenities is not an array', async () => { + const dto = plainToInstance(UpdateListingDto, { + amenities: 'Hồ bơi', + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const amenitiesError = errors.find((e) => e.property === 'amenities'); + expect(amenitiesError).toBeDefined(); + }); + + it('should strip unknown properties with whitelist', async () => { + const dto = plainToInstance(UpdateListingDto, { + title: 'Căn hộ 3PN view sông', + propertyType: 'APARTMENT', + address: '123 ABC', + }); + + const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true }); + // propertyType and address are not in UpdateListingDto — should be forbidden + expect(errors.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts index b3388fb..c9649d8 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -6,11 +6,12 @@ import { Patch, Post, Query, + Res, UploadedFile, UseGuards, UseInterceptors, } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags, @@ -21,8 +22,10 @@ import { ApiQuery, ApiParam, } from '@nestjs/swagger'; +import type { Response } from 'express'; +import * as QRCode from 'qrcode'; import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; -import { NotFoundException, EndpointRateLimit, EndpointRateLimitGuard, FileValidationPipe, UploadedFile as ValidatedFile } from '@modules/shared'; +import { NotFoundException, EndpointRateLimit, EndpointRateLimitGuard, FileValidationPipe, type UploadedFile as ValidatedFile } from '@modules/shared'; import { RequireQuota, QuotaGuard } from '@modules/subscriptions'; import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command'; import type { CreateListingResult } from '../../application/commands/create-listing/create-listing.handler'; @@ -33,14 +36,16 @@ import { UpdateListingStatusCommand } from '../../application/commands/update-li import { UploadMediaCommand } from '../../application/commands/upload-media/upload-media.command'; import { GetListingQuery } from '../../application/queries/get-listing/get-listing.query'; import { GetPendingModerationQuery } from '../../application/queries/get-pending-moderation/get-pending-moderation.query'; +import type { PriceHistoryItem } from '../../application/queries/get-price-history/get-price-history.handler'; +import { GetPriceHistoryQuery } from '../../application/queries/get-price-history/get-price-history.query'; import { SearchListingsQuery } from '../../application/queries/search-listings/search-listings.query'; import type { ListingDetailData, ListingSearchItem } from '../../domain/repositories/listing-read.dto'; import type { PaginatedResult } from '../../domain/repositories/listing.repository'; -import { CreateListingDto } from '../dto/create-listing.dto'; -import { ModerateListingDto } from '../dto/moderate-listing.dto'; -import { SearchListingsDto } from '../dto/search-listings.dto'; -import { UpdateListingStatusDto } from '../dto/update-listing-status.dto'; -import { UpdateListingDto } from '../dto/update-listing.dto'; +import type { CreateListingDto } from '../dto/create-listing.dto'; +import type { ModerateListingDto } from '../dto/moderate-listing.dto'; +import { type SearchListingsDto } from '../dto/search-listings.dto'; +import type { UpdateListingStatusDto } from '../dto/update-listing-status.dto'; +import type { UpdateListingDto } from '../dto/update-listing.dto'; @ApiTags('listings') @Controller('listings') @@ -118,6 +123,50 @@ export class ListingsController { ); } + @ApiOperation({ summary: 'Generate QR code image linking to a listing' }) + @ApiParam({ name: 'id', description: 'Listing UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' }) + @ApiResponse({ status: 200, description: 'QR code PNG image', content: { 'image/png': {} } }) + @ApiResponse({ status: 404, description: 'Listing not found' }) + @Get(':id/qr-code') + async getQrCode( + @Param('id') id: string, + @Res() res: Response, + ): Promise { + const listing = await this.queryBus.execute(new GetListingQuery(id)); + if (!listing) { + throw new NotFoundException('Listing', id); + } + + const siteUrl = process.env['SITE_URL'] || 'https://goodgo.vn'; + const listingUrl = `${siteUrl}/vi/listings/${id}`; + + const qrBuffer = await QRCode.toBuffer(listingUrl, { + type: 'png', + width: 300, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF', + }, + errorCorrectionLevel: 'M', + }); + + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': qrBuffer.length.toString(), + 'Cache-Control': 'public, max-age=86400', + }); + res.send(qrBuffer); + } + + @ApiOperation({ summary: 'Get price change history for a listing' }) + @ApiParam({ name: 'id', description: 'Listing UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' }) + @ApiResponse({ status: 200, description: 'Price history returned' }) + @Get(':id/price-history') + async getPriceHistory(@Param('id') id: string): Promise { + return this.queryBus.execute(new GetPriceHistoryQuery(id)); + } + @ApiOperation({ summary: 'Get listing details by ID' }) @ApiParam({ name: 'id', description: 'Listing UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' }) @ApiResponse({ status: 200, description: 'Listing details returned' }) diff --git a/apps/api/src/modules/listings/presentation/dto/index.ts b/apps/api/src/modules/listings/presentation/dto/index.ts index 77ae32b..7bc9063 100644 --- a/apps/api/src/modules/listings/presentation/dto/index.ts +++ b/apps/api/src/modules/listings/presentation/dto/index.ts @@ -1,4 +1,5 @@ export { CreateListingDto } from './create-listing.dto'; +export { UpdateListingDto } from './update-listing.dto'; export { UpdateListingStatusDto } from './update-listing-status.dto'; export { ModerateListingDto } from './moderate-listing.dto'; export { SearchListingsDto } from './search-listings.dto';