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 { UploadMediaHandler } from './commands/upload-media/upload-media.handler';
|
||||||
export { ModerateListingCommand } from './commands/moderate-listing/moderate-listing.command';
|
export { ModerateListingCommand } from './commands/moderate-listing/moderate-listing.command';
|
||||||
export { ModerateListingHandler } from './commands/moderate-listing/moderate-listing.handler';
|
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
|
// Queries
|
||||||
export { GetListingQuery } from './queries/get-listing/get-listing.query';
|
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 { SearchListingsHandler } from './queries/search-listings/search-listings.handler';
|
||||||
export { GetPendingModerationQuery } from './queries/get-pending-moderation/get-pending-moderation.query';
|
export { GetPendingModerationQuery } from './queries/get-pending-moderation/get-pending-moderation.query';
|
||||||
export { GetPendingModerationHandler } from './queries/get-pending-moderation/get-pending-moderation.handler';
|
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,
|
CreateBucketCommand,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||||
import { LoggerService } from '@modules/shared';
|
import { type LoggerService } from '@modules/shared';
|
||||||
|
|
||||||
export const MEDIA_STORAGE_SERVICE = Symbol('MEDIA_STORAGE_SERVICE');
|
export const MEDIA_STORAGE_SERVICE = Symbol('MEDIA_STORAGE_SERVICE');
|
||||||
|
|
||||||
|
export interface PresignedUploadResult {
|
||||||
|
uploadUrl: string;
|
||||||
|
objectKey: string;
|
||||||
|
publicUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IMediaStorageService {
|
export interface IMediaStorageService {
|
||||||
upload(buffer: Buffer, originalName: string, mimeType: string, folder: string): Promise<string>;
|
upload(buffer: Buffer, originalName: string, mimeType: string, folder: string): Promise<string>;
|
||||||
delete(fileUrl: string): Promise<void>;
|
delete(fileUrl: string): Promise<void>;
|
||||||
getPresignedUploadUrl(objectKey: string, mimeType: string, expiresInSeconds?: number): Promise<string>;
|
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 {
|
function requireEnv(key: string): string {
|
||||||
@@ -117,6 +130,27 @@ export class MinioMediaStorageService implements IMediaStorageService, OnModuleI
|
|||||||
return getSignedUrl(this.s3, command, { expiresIn: expiresInSeconds });
|
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> {
|
async delete(fileUrl: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(fileUrl);
|
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,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
Res,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@@ -21,8 +22,10 @@ import {
|
|||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import * as QRCode from 'qrcode';
|
||||||
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
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 { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
||||||
import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command';
|
import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command';
|
||||||
import type { CreateListingResult } from '../../application/commands/create-listing/create-listing.handler';
|
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 { UploadMediaCommand } from '../../application/commands/upload-media/upload-media.command';
|
||||||
import { GetListingQuery } from '../../application/queries/get-listing/get-listing.query';
|
import { GetListingQuery } from '../../application/queries/get-listing/get-listing.query';
|
||||||
import { GetPendingModerationQuery } from '../../application/queries/get-pending-moderation/get-pending-moderation.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 { SearchListingsQuery } from '../../application/queries/search-listings/search-listings.query';
|
||||||
import type { ListingDetailData, ListingSearchItem } from '../../domain/repositories/listing-read.dto';
|
import type { ListingDetailData, ListingSearchItem } from '../../domain/repositories/listing-read.dto';
|
||||||
import type { PaginatedResult } from '../../domain/repositories/listing.repository';
|
import type { PaginatedResult } from '../../domain/repositories/listing.repository';
|
||||||
import { CreateListingDto } from '../dto/create-listing.dto';
|
import type { CreateListingDto } from '../dto/create-listing.dto';
|
||||||
import { ModerateListingDto } from '../dto/moderate-listing.dto';
|
import type { ModerateListingDto } from '../dto/moderate-listing.dto';
|
||||||
import { SearchListingsDto } from '../dto/search-listings.dto';
|
import { type SearchListingsDto } from '../dto/search-listings.dto';
|
||||||
import { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
|
import type { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
|
||||||
import { UpdateListingDto } from '../dto/update-listing.dto';
|
import type { UpdateListingDto } from '../dto/update-listing.dto';
|
||||||
|
|
||||||
@ApiTags('listings')
|
@ApiTags('listings')
|
||||||
@Controller('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' })
|
@ApiOperation({ summary: 'Get listing details by ID' })
|
||||||
@ApiParam({ name: 'id', description: 'Listing UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' })
|
@ApiParam({ name: 'id', description: 'Listing UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' })
|
||||||
@ApiResponse({ status: 200, description: 'Listing details returned' })
|
@ApiResponse({ status: 200, description: 'Listing details returned' })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { CreateListingDto } from './create-listing.dto';
|
export { CreateListingDto } from './create-listing.dto';
|
||||||
|
export { UpdateListingDto } from './update-listing.dto';
|
||||||
export { UpdateListingStatusDto } from './update-listing-status.dto';
|
export { UpdateListingStatusDto } from './update-listing-status.dto';
|
||||||
export { ModerateListingDto } from './moderate-listing.dto';
|
export { ModerateListingDto } from './moderate-listing.dto';
|
||||||
export { SearchListingsDto } from './search-listings.dto';
|
export { SearchListingsDto } from './search-listings.dto';
|
||||||
|
|||||||
Reference in New Issue
Block a user