feat(listings): add update endpoint, QR code generation, and presigned upload helpers

Wire up PATCH /listings/:id with UpdateListingCommand/Handler, add QR code
image endpoint, extend IMediaStorageService with generatePresignedUpload and
getPublicUrl, and include UpdateListingDto unit tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 05:12:25 +07:00
parent e21e096e54
commit 86adcf7295
5 changed files with 200 additions and 9 deletions

View File

@@ -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';

View File

@@ -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<string>;
delete(fileUrl: string): Promise<void>;
getPresignedUploadUrl(objectKey: string, mimeType: string, expiresInSeconds?: number): Promise<string>;
generatePresignedUpload(
folder: string,
fileName: string,
mimeType: string,
expiresInSeconds?: number,
): Promise<PresignedUploadResult>;
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<PresignedUploadResult> {
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<void> {
try {
const urlObj = new URL(fileUrl);

View File

@@ -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);
});
});

View File

@@ -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<void> {
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<PriceHistoryItem[]> {
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' })

View File

@@ -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';