Add POST /transfer/estimate-from-photos endpoint that uses Claude Vision API to assess furniture/appliance condition from photos, integrating with the existing rule-based pricing engine. Includes rate limiting (5/min), image hash caching, graceful fallback, and 17 unit tests covering all paths. Co-Authored-By: Paperclip <noreply@paperclip.ing>
173 lines
7.3 KiB
TypeScript
173 lines
7.3 KiB
TypeScript
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
|
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
|
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
|
import { JwtAuthGuard, CurrentUser } from '@modules/auth';
|
|
import { EndpointRateLimit, EndpointRateLimitGuard, NotFoundException } from '@modules/shared';
|
|
import { CreateTransferListingCommand } from '../../application/commands/create-transfer-listing/create-transfer-listing.command';
|
|
import { EstimateFromPhotosCommand } from '../../application/commands/estimate-from-photos/estimate-from-photos.command';
|
|
import { EstimateTransferPricesCommand } from '../../application/commands/estimate-transfer-prices/estimate-transfer-prices.command';
|
|
import { UpdateTransferListingCommand } from '../../application/commands/update-transfer-listing/update-transfer-listing.command';
|
|
import { GetTransferListingQuery } from '../../application/queries/get-transfer-listing/get-transfer-listing.query';
|
|
import { ListTransferListingsQuery } from '../../application/queries/list-transfer-listings/list-transfer-listings.query';
|
|
import { TransferStatsQuery } from '../../application/queries/transfer-stats/transfer-stats.query';
|
|
import { type CreateTransferListingDto } from '../dto/create-transfer-listing.dto';
|
|
import { type EstimateFromPhotosDto } from '../dto/estimate-from-photos.dto';
|
|
import { type EstimateTransferPricesDto } from '../dto/estimate-transfer-prices.dto';
|
|
import { type SearchTransferListingsDto } from '../dto/search-transfer-listings.dto';
|
|
import { type UpdateTransferListingDto } from '../dto/update-transfer-listing.dto';
|
|
|
|
@ApiTags('transfer')
|
|
@Controller('transfer')
|
|
export class TransferController {
|
|
constructor(
|
|
private readonly commandBus: CommandBus,
|
|
private readonly queryBus: QueryBus,
|
|
) {}
|
|
|
|
// ── Public endpoints ──────────────────────────────────────────────
|
|
|
|
@ApiOperation({ summary: 'Danh sách sang nhượng', description: 'Tìm kiếm và lọc tin sang nhượng' })
|
|
@ApiResponse({ status: 200, description: 'Danh sách tin sang nhượng phân trang' })
|
|
@Get('listings')
|
|
async listListings(@Query() dto: SearchTransferListingsDto) {
|
|
return this.queryBus.execute(
|
|
new ListTransferListingsQuery(
|
|
dto.q,
|
|
dto.category,
|
|
dto.status,
|
|
dto.district,
|
|
dto.city,
|
|
dto.minPrice,
|
|
dto.maxPrice,
|
|
undefined, // sellerId — only used for "my listings" queries
|
|
dto.page ?? 1,
|
|
dto.limit ?? 20,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Chi tiết tin sang nhượng', description: 'Xem chi tiết tin sang nhượng theo ID' })
|
|
@ApiResponse({ status: 200, description: 'Thông tin chi tiết tin sang nhượng' })
|
|
@ApiResponse({ status: 404, description: 'Không tìm thấy tin sang nhượng' })
|
|
@Get('listings/:id')
|
|
async getListing(@Param('id') id: string) {
|
|
const result = await this.queryBus.execute(new GetTransferListingQuery(id));
|
|
if (!result) {
|
|
throw new NotFoundException('Transfer listing', id);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Thống kê sang nhượng', description: 'Tổng quan thống kê tin sang nhượng' })
|
|
@ApiResponse({ status: 200, description: 'Dữ liệu thống kê' })
|
|
@Get('stats')
|
|
async getStats() {
|
|
return this.queryBus.execute(new TransferStatsQuery());
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Ước tính giá AI', description: 'Ước tính giá trị nội thất/thiết bị dựa trên khấu hao, thương hiệu và tình trạng' })
|
|
@ApiResponse({ status: 200, description: 'Kết quả ước tính giá' })
|
|
@Post('estimate')
|
|
async estimatePrices(@Body() dto: EstimateTransferPricesDto) {
|
|
return this.commandBus.execute(
|
|
new EstimateTransferPricesCommand(dto.items),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({
|
|
summary: 'Ước tính giá từ ảnh (AI Vision)',
|
|
description: 'Sử dụng Claude Vision để đánh giá tình trạng nội thất từ ảnh, kết hợp với công cụ định giá rule-based',
|
|
})
|
|
@ApiResponse({ status: 200, description: 'Kết quả đánh giá tình trạng và ước tính giá' })
|
|
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
|
|
@EndpointRateLimit({ limit: 5, windowSeconds: 60, keyStrategy: 'ip' })
|
|
@UseGuards(EndpointRateLimitGuard)
|
|
@Post('estimate-from-photos')
|
|
async estimateFromPhotos(@Body() dto: EstimateFromPhotosDto) {
|
|
return this.commandBus.execute(
|
|
new EstimateFromPhotosCommand(dto.items),
|
|
);
|
|
}
|
|
|
|
// ── Authenticated endpoints ───────────────────────────────────────
|
|
|
|
@ApiOperation({ summary: 'Tạo tin sang nhượng', description: 'Đăng tin sang nhượng mới' })
|
|
@ApiResponse({ status: 201, description: 'Tin sang nhượng đã tạo' })
|
|
@ApiBearerAuth('JWT')
|
|
@UseGuards(JwtAuthGuard)
|
|
@Post('listings')
|
|
async createListing(
|
|
@CurrentUser() user: { sub: string },
|
|
@Body() dto: CreateTransferListingDto,
|
|
) {
|
|
return this.commandBus.execute(
|
|
new CreateTransferListingCommand(
|
|
user.sub,
|
|
dto.category,
|
|
dto.title,
|
|
dto.description ?? null,
|
|
dto.address,
|
|
dto.ward ?? null,
|
|
dto.district,
|
|
dto.city,
|
|
dto.latitude,
|
|
dto.longitude,
|
|
BigInt(Math.round(dto.askingPriceVND)),
|
|
dto.pricingSource ?? 'MANUAL',
|
|
dto.isNegotiable ?? true,
|
|
dto.areaM2 ?? null,
|
|
dto.monthlyRentVND ? BigInt(Math.round(dto.monthlyRentVND)) : null,
|
|
dto.depositMonths ?? null,
|
|
dto.remainingLeaseMo ?? null,
|
|
dto.businessType ?? null,
|
|
dto.footTraffic ?? null,
|
|
dto.contactPhone ?? null,
|
|
dto.contactName ?? null,
|
|
(dto.items ?? []).map((item) => ({
|
|
name: item.name,
|
|
brand: item.brand,
|
|
modelName: item.modelName,
|
|
category: item.category,
|
|
condition: item.condition,
|
|
purchaseYear: item.purchaseYear,
|
|
originalPriceVND: item.originalPriceVND != null ? BigInt(Math.round(item.originalPriceVND)) : undefined,
|
|
askingPriceVND: BigInt(Math.round(item.askingPriceVND)),
|
|
quantity: item.quantity,
|
|
dimensions: item.dimensions,
|
|
notes: item.notes,
|
|
})),
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Cập nhật tin sang nhượng', description: 'Cập nhật thông tin tin sang nhượng' })
|
|
@ApiResponse({ status: 200, description: 'Tin sang nhượng đã cập nhật' })
|
|
@ApiBearerAuth('JWT')
|
|
@UseGuards(JwtAuthGuard)
|
|
@Patch('listings/:id')
|
|
async updateListing(
|
|
@Param('id') id: string,
|
|
@Body() dto: UpdateTransferListingDto,
|
|
) {
|
|
return this.commandBus.execute(
|
|
new UpdateTransferListingCommand(
|
|
id,
|
|
dto.title,
|
|
dto.description,
|
|
dto.status,
|
|
dto.askingPriceVND ? BigInt(Math.round(dto.askingPriceVND)) : undefined,
|
|
dto.isNegotiable,
|
|
dto.areaM2,
|
|
dto.monthlyRentVND !== undefined ? BigInt(Math.round(dto.monthlyRentVND)) : undefined,
|
|
dto.depositMonths,
|
|
dto.remainingLeaseMo,
|
|
dto.businessType,
|
|
dto.footTraffic,
|
|
dto.contactPhone,
|
|
dto.contactName,
|
|
dto.media,
|
|
),
|
|
);
|
|
}
|
|
}
|