feat: implement project development module, transfer management features, and industrial AVM model integration

This commit is contained in:
Ho Ngoc Hai
2026-04-18 20:34:35 +07:00
parent 0f3b4d7b0d
commit 38b9def99a
66 changed files with 9051 additions and 17 deletions

View File

@@ -0,0 +1,7 @@
export class GenerateTransferUploadUrlsCommand {
constructor(
public readonly sellerId: string,
public readonly listingId: string | null,
public readonly files: { fileName: string; mimeType: string }[],
) {}
}

View File

@@ -0,0 +1,52 @@
import { Inject, Logger } from '@nestjs/common';
import { type CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { CommandHandler as CqrsCommandHandler } from '@nestjs/cqrs';
import {
MEDIA_STORAGE_SERVICE,
type IMediaStorageService,
} from '@modules/listings/infrastructure/services/media-storage.service';
import { GenerateTransferUploadUrlsCommand } from './generate-transfer-upload-urls.command';
export interface TransferUploadUrlResult {
uploadUrl: string;
objectKey: string;
publicUrl: string;
}
@CqrsCommandHandler(GenerateTransferUploadUrlsCommand)
export class GenerateTransferUploadUrlsHandler
implements ICommandHandler<GenerateTransferUploadUrlsCommand>
{
private readonly logger = new Logger(GenerateTransferUploadUrlsHandler.name);
constructor(
@Inject(MEDIA_STORAGE_SERVICE)
private readonly storage: IMediaStorageService,
) {}
async execute(command: GenerateTransferUploadUrlsCommand): Promise<TransferUploadUrlResult[]> {
const folder = command.listingId
? `transfer/${command.sellerId}/${command.listingId}`
: `transfer/${command.sellerId}/draft`;
const results: TransferUploadUrlResult[] = [];
for (const file of command.files.slice(0, 10)) {
try {
const result = await this.storage.generatePresignedUpload(
folder,
file.fileName,
file.mimeType,
600, // 10 min expiry
);
results.push(result);
} catch (err) {
this.logger.error(
`Failed to generate upload URL for ${file.fileName}: ${err instanceof Error ? err.message : 'Unknown'}`,
);
}
}
return results;
}
}

View File

@@ -0,0 +1,2 @@
export { ModerateTransferListingCommand } from './moderate-transfer-listing.command';
export { ModerateTransferListingHandler } from './moderate-transfer-listing.handler';

View File

@@ -0,0 +1,9 @@
export class ModerateTransferListingCommand {
constructor(
public readonly listingId: string,
public readonly moderatorId: string,
public readonly action: 'approve' | 'reject',
public readonly moderationScore?: number,
public readonly notes?: string,
) {}
}

View File

@@ -0,0 +1,37 @@
import { Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { type EventBusService, NotFoundException } from '@modules/shared';
import { TransferListingUpdatedEvent } from '../../../domain/events';
import {
TRANSFER_LISTING_REPOSITORY,
type ITransferListingRepository,
} from '../../../domain/repositories/transfer-listing.repository';
import { ModerateTransferListingCommand } from './moderate-transfer-listing.command';
@CommandHandler(ModerateTransferListingCommand)
export class ModerateTransferListingHandler implements ICommandHandler<ModerateTransferListingCommand> {
constructor(
@Inject(TRANSFER_LISTING_REPOSITORY)
private readonly repo: ITransferListingRepository,
private readonly eventBus: EventBusService,
) {}
async execute(cmd: ModerateTransferListingCommand): Promise<{ status: string }> {
const entity = await this.repo.findById(cmd.listingId);
if (!entity) {
throw new NotFoundException('Transfer listing', cmd.listingId);
}
if (cmd.action === 'approve') {
entity.approve(cmd.moderationScore, cmd.notes);
} else {
entity.reject(cmd.moderationScore, cmd.notes);
}
await this.repo.update(entity);
this.eventBus.publish(new TransferListingUpdatedEvent(cmd.listingId));
return { status: entity.status };
}
}

View File

@@ -0,0 +1,2 @@
export { ListPendingTransfersQuery } from './list-pending-transfers.query';
export { ListPendingTransfersHandler } from './list-pending-transfers.handler';

View File

@@ -0,0 +1,23 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
TRANSFER_LISTING_REPOSITORY,
type ITransferListingRepository,
} from '../../../domain/repositories/transfer-listing.repository';
import { ListPendingTransfersQuery } from './list-pending-transfers.query';
@QueryHandler(ListPendingTransfersQuery)
export class ListPendingTransfersHandler implements IQueryHandler<ListPendingTransfersQuery> {
constructor(
@Inject(TRANSFER_LISTING_REPOSITORY)
private readonly repo: ITransferListingRepository,
) {}
async execute(query: ListPendingTransfersQuery) {
return this.repo.search({
status: 'PENDING_REVIEW',
page: query.page,
limit: query.limit,
});
}
}

View File

@@ -0,0 +1,6 @@
export class ListPendingTransfersQuery {
constructor(
public readonly page: number = 1,
public readonly limit: number = 20,
) {}
}

View File

@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsMimeType, IsOptional, IsString, ValidateNested } from 'class-validator';
class UploadFileSpec {
@ApiProperty({ example: 'sofa-front.jpg' })
@IsString()
fileName!: string;
@ApiProperty({ example: 'image/jpeg' })
@IsMimeType()
mimeType!: string;
}
export class GenerateTransferUploadUrlsDto {
@ApiProperty({ required: false, description: 'Listing ID (null for draft uploads)' })
@IsOptional()
@IsString()
listingId?: string;
@ApiProperty({ type: [UploadFileSpec], minItems: 1, maxItems: 10 })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(10)
@ValidateNested({ each: true })
@Type(() => UploadFileSpec)
files!: UploadFileSpec[];
}

View File

@@ -0,0 +1,20 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
export class ModerateTransferListingDto {
@ApiProperty({ enum: ['approve', 'reject'], description: 'Hành động kiểm duyệt' })
@IsIn(['approve', 'reject'])
action!: 'approve' | 'reject';
@ApiPropertyOptional({ description: 'Điểm kiểm duyệt (0-100)', minimum: 0, maximum: 100 })
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
moderationScore?: number;
@ApiPropertyOptional({ description: 'Ghi chú kiểm duyệt' })
@IsOptional()
@IsString()
notes?: string;
}