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:
Ho Ngoc Hai
2026-04-16 05:15:04 +07:00
parent c920934fb6
commit d4e100a00c
48 changed files with 1766 additions and 225 deletions

View File

@@ -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,
) {}
}

View File

@@ -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');
}
}
}

View File

@@ -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',
);
}
}

View File

@@ -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',
);
}
}
}

View File

@@ -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,
},
});
}
}

View File

@@ -0,0 +1,3 @@
export class GetPriceHistoryQuery {
constructor(public readonly listingId: string) {}
}

View File

@@ -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,
) {}
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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';