feat(api): add price history, Stringee SMS, Zalo OA, WebSocket notifications, and feature-listing command
- Add PriceHistory model + migration, price-changed domain event, and event handler - Add GetPriceHistory query handler and controller endpoint - Implement StringeeSmsService and ZaloOaService with unit tests - Add Zalo ZNS templates for Vietnamese notification messages - Add WebSocket notification gateway for real-time push - Add FeatureListingCommand for promoted listings - Apply remaining consistent-type-imports lint fixes across API modules Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import type { PaymentProvider } from '@prisma/client';
|
||||
|
||||
export type FeaturePackage = '3_days' | '7_days' | '30_days';
|
||||
|
||||
export class FeatureListingCommand {
|
||||
constructor(
|
||||
public readonly listingId: string,
|
||||
public readonly userId: string,
|
||||
public readonly package_: FeaturePackage,
|
||||
public readonly provider: PaymentProvider,
|
||||
public readonly returnUrl: string,
|
||||
public readonly ipAddress: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type CommandBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CreatePaymentCommand } from '@modules/payments/application/commands/create-payment/create-payment.command';
|
||||
import type { CreatePaymentResult } from '@modules/payments/application/commands/create-payment/create-payment.handler';
|
||||
import {
|
||||
DomainException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
ValidationException,
|
||||
type LoggerService,
|
||||
} from '@modules/shared';
|
||||
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
||||
import { type FeaturePackage, FeatureListingCommand } from './feature-listing.command';
|
||||
|
||||
const PACKAGE_PRICES: Record<FeaturePackage, bigint> = {
|
||||
'3_days': 99_000n,
|
||||
'7_days': 199_000n,
|
||||
'30_days': 499_000n,
|
||||
};
|
||||
|
||||
export interface FeatureListingResult {
|
||||
paymentId: string;
|
||||
paymentUrl: string;
|
||||
providerTxId: string;
|
||||
package_: FeaturePackage;
|
||||
priceVND: string;
|
||||
}
|
||||
|
||||
@CommandHandler(FeatureListingCommand)
|
||||
export class FeatureListingHandler implements ICommandHandler<FeatureListingCommand> {
|
||||
constructor(
|
||||
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: FeatureListingCommand): Promise<FeatureListingResult> {
|
||||
try {
|
||||
const listing = await this.listingRepo.findById(command.listingId);
|
||||
if (!listing) {
|
||||
throw new NotFoundException('Listing', command.listingId);
|
||||
}
|
||||
|
||||
if (listing.sellerId !== command.userId && listing.agentId !== command.userId) {
|
||||
throw new ForbiddenException('Chỉ người bán hoặc môi giới mới có thể đẩy tin nổi bật');
|
||||
}
|
||||
|
||||
if (listing.status !== 'ACTIVE') {
|
||||
throw new ValidationException('Chỉ tin đăng đang hoạt động mới có thể đẩy nổi bật', {
|
||||
status: listing.status,
|
||||
});
|
||||
}
|
||||
|
||||
const price = PACKAGE_PRICES[command.package_];
|
||||
if (!price) {
|
||||
throw new ValidationException('Gói không hợp lệ', { package: command.package_ });
|
||||
}
|
||||
|
||||
const paymentResult: CreatePaymentResult = await this.commandBus.execute(
|
||||
new CreatePaymentCommand(
|
||||
command.userId,
|
||||
command.provider,
|
||||
'FEATURED_LISTING',
|
||||
price,
|
||||
`Đẩy tin nổi bật ${command.package_.replace('_', ' ')} - Listing ${command.listingId}`,
|
||||
command.returnUrl,
|
||||
command.ipAddress,
|
||||
command.listingId,
|
||||
`feature_${command.listingId}_${Date.now()}`,
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Featured listing payment created: listing=${command.listingId}, package=${command.package_}, payment=${paymentResult.paymentId}`,
|
||||
'FeatureListingHandler',
|
||||
);
|
||||
|
||||
return {
|
||||
...paymentResult,
|
||||
package_: command.package_,
|
||||
priceVND: price.toString(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to create featured listing payment: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể tạo thanh toán đẩy tin nổi bật');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { type PaymentCompletedEvent } from '@modules/payments';
|
||||
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||
|
||||
const PACKAGE_DURATION_DAYS: Record<string, number> = {
|
||||
'99000': 3,
|
||||
'199000': 7,
|
||||
'499000': 30,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ActivateFeaturedListingHandler {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@OnEvent('payment.completed', { async: true })
|
||||
async handle(event: PaymentCompletedEvent): Promise<void> {
|
||||
const payment = await this.prisma.payment.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
select: { type: true, transactionId: true, amountVND: true },
|
||||
});
|
||||
|
||||
if (!payment || payment.type !== 'FEATURED_LISTING' || !payment.transactionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listingId = payment.transactionId;
|
||||
const days = PACKAGE_DURATION_DAYS[payment.amountVND.toString()] ?? 7;
|
||||
|
||||
const now = new Date();
|
||||
const listing = await this.prisma.listing.findUnique({
|
||||
where: { id: listingId },
|
||||
select: { featuredUntil: true },
|
||||
});
|
||||
|
||||
// Extend from current featuredUntil if still active, otherwise from now
|
||||
const baseDate = listing?.featuredUntil && listing.featuredUntil > now
|
||||
? listing.featuredUntil
|
||||
: now;
|
||||
|
||||
const featuredUntil = new Date(baseDate.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
|
||||
await this.prisma.listing.update({
|
||||
where: { id: listingId },
|
||||
data: { featuredUntil },
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Activated featured listing: id=${listingId}, until=${featuredUntil.toISOString()}, days=${days}`,
|
||||
'ActivateFeaturedListingHandler',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { EventsHandler, type IEventHandler } from '@nestjs/cqrs';
|
||||
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||
import { ListingPriceChangedEvent } from '../../domain/events/listing-price-changed.event';
|
||||
|
||||
@EventsHandler(ListingPriceChangedEvent)
|
||||
export class RecordPriceHistoryHandler implements IEventHandler<ListingPriceChangedEvent> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async handle(event: ListingPriceChangedEvent): Promise<void> {
|
||||
try {
|
||||
await this.prisma.priceHistory.create({
|
||||
data: {
|
||||
listingId: event.aggregateId,
|
||||
oldPrice: event.oldPrice,
|
||||
newPrice: event.newPrice,
|
||||
changedAt: event.occurredAt,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.debug(
|
||||
`Recorded price change for listing ${event.aggregateId}: ${event.oldPrice} → ${event.newPrice}`,
|
||||
'RecordPriceHistoryHandler',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to record price history for listing ${event.aggregateId}: ${(err as Error).message}`,
|
||||
(err as Error).stack,
|
||||
'RecordPriceHistoryHandler',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { GetPriceHistoryQuery } from './get-price-history.query';
|
||||
|
||||
export interface PriceHistoryItem {
|
||||
id: string;
|
||||
oldPrice: bigint;
|
||||
newPrice: bigint;
|
||||
changedAt: Date;
|
||||
}
|
||||
|
||||
@QueryHandler(GetPriceHistoryQuery)
|
||||
export class GetPriceHistoryHandler implements IQueryHandler<GetPriceHistoryQuery> {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async execute(query: GetPriceHistoryQuery): Promise<PriceHistoryItem[]> {
|
||||
return this.prisma.priceHistory.findMany({
|
||||
where: { listingId: query.listingId },
|
||||
orderBy: { changedAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
oldPrice: true,
|
||||
newPrice: true,
|
||||
changedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class GetPriceHistoryQuery {
|
||||
constructor(public readonly listingId: string) {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { DomainEvent } from '@modules/shared';
|
||||
|
||||
export class ListingPriceChangedEvent implements DomainEvent {
|
||||
readonly eventName = 'listing.price_changed';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly oldPrice: bigint,
|
||||
public readonly newPrice: bigint,
|
||||
) {}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Listing as PrismaListing, ListingStatus } from '@prisma/client';
|
||||
import { PrismaService } from '@modules/shared';
|
||||
import { ListingEntity, ListingProps } from '../../domain/entities/listing.entity';
|
||||
import { ListingDetailData, ListingSearchItem, ListingSellerItem } from '../../domain/repositories/listing-read.dto';
|
||||
import { IListingRepository, ListingSearchParams, PaginatedResult } from '../../domain/repositories/listing.repository';
|
||||
import { type Listing as PrismaListing, type ListingStatus } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { ListingEntity, type ListingProps } from '../../domain/entities/listing.entity';
|
||||
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto';
|
||||
import { type IListingRepository, type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';
|
||||
import { Price } from '../../domain/value-objects/price.vo';
|
||||
import { findByIdWithProperty, searchListings, findBySellerIdQuery } from './listing-read.queries';
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, PropertyMedia as PrismaMedia, PropertyType, Direction } from '@prisma/client';
|
||||
import { PrismaService } from '@modules/shared';
|
||||
import { PropertyMediaEntity, PropertyMediaProps } from '../../domain/entities/property-media.entity';
|
||||
import { PropertyEntity, PropertyProps } from '../../domain/entities/property.entity';
|
||||
import { IPropertyRepository } from '../../domain/repositories/property.repository';
|
||||
import { type Prisma, type PropertyMedia as PrismaMedia, type PropertyType, type Direction } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { PropertyMediaEntity, type PropertyMediaProps } from '../../domain/entities/property-media.entity';
|
||||
import { PropertyEntity, type PropertyProps } from '../../domain/entities/property.entity';
|
||||
import { type IPropertyRepository } from '../../domain/repositories/property.repository';
|
||||
import { Address } from '../../domain/value-objects/address.vo';
|
||||
import { GeoPoint } from '../../domain/value-objects/geo-point.vo';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Ip,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -29,6 +30,8 @@ import { NotFoundException, EndpointRateLimit, EndpointRateLimitGuard, FileValid
|
||||
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';
|
||||
import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command';
|
||||
import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler';
|
||||
import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';
|
||||
import { UpdateListingCommand } from '../../application/commands/update-listing/update-listing.command';
|
||||
import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PaymentProvider } from '@prisma/client';
|
||||
import { IsEnum, IsIn, IsString, IsUrl } from 'class-validator';
|
||||
|
||||
export class FeatureListingDto {
|
||||
@ApiProperty({
|
||||
enum: ['3_days', '7_days', '30_days'],
|
||||
example: '7_days',
|
||||
description: 'Featured listing package duration',
|
||||
})
|
||||
@IsIn(['3_days', '7_days', '30_days'])
|
||||
package!: '3_days' | '7_days' | '30_days';
|
||||
|
||||
@ApiProperty({ enum: PaymentProvider, example: 'VNPAY', description: 'Payment provider' })
|
||||
@IsEnum(PaymentProvider)
|
||||
provider!: PaymentProvider;
|
||||
|
||||
@ApiProperty({ example: 'https://goodgo.vn/payment/callback', description: 'Payment return URL' })
|
||||
@IsUrl()
|
||||
@IsString()
|
||||
returnUrl!: string;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { CreateListingDto } from './create-listing.dto';
|
||||
export { FeatureListingDto } from './feature-listing.dto';
|
||||
export { UpdateListingDto } from './update-listing.dto';
|
||||
export { UpdateListingStatusDto } from './update-listing-status.dto';
|
||||
export { ModerateListingDto } from './moderate-listing.dto';
|
||||
|
||||
Reference in New Issue
Block a user