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:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' })
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user