feat(listings): add user-facing scam/abuse report flow (GOO-19)

- Add ListingFlag model with FlagReason enum (SCAM, DUPLICATE, WRONG_INFO, ALREADY_SOLD, INAPPROPRIATE)
- Add POST /listings/:id/report endpoint with rate limiting and duplicate prevention
- Auto-flag listings with ≥3 reports to PENDING_REVIEW for moderator review
- Add GET /admin/flagged-listings endpoint for admin moderation queue
- Add "Báo cáo" button + modal on listing detail page (Vietnamese UI)
- Add Prisma migration for listing_flags table with unique constraint per user/listing

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-23 00:19:12 +07:00
parent 94d462ef4f
commit 0329455e9a
26 changed files with 615 additions and 57 deletions

View File

@@ -22,6 +22,7 @@ import { UserDeactivatedListener } from './application/listeners/user-deactivate
import { GetAiSettingsHandler } from './application/queries/get-ai-settings/get-ai-settings.handler';
import { GetAuditLogsHandler } from './application/queries/get-audit-logs/get-audit-logs.handler';
import { GetDashboardStatsHandler } from './application/queries/get-dashboard-stats/get-dashboard-stats.handler';
import { GetFlaggedListingsHandler } from './application/queries/get-flagged-listings/get-flagged-listings.handler';
import { GetKycQueueHandler } from './application/queries/get-kyc-queue/get-kyc-queue.handler';
import { GetModerationAuditLogsHandler } from './application/queries/get-moderation-audit-logs/get-moderation-audit-logs.handler';
import { GetModerationQueueHandler } from './application/queries/get-moderation-queue/get-moderation-queue.handler';
@@ -56,6 +57,7 @@ const CommandHandlers = [
const QueryHandlers = [
GetModerationQueueHandler,
GetFlaggedListingsHandler,
GetDashboardStatsHandler,
GetRevenueStatsHandler,
GetUsersHandler,

View File

@@ -0,0 +1,109 @@
import { InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService, PrismaService } from '@modules/shared';
import { GetFlaggedListingsQuery } from './get-flagged-listings.query';
export interface FlaggedListingItem {
listingId: string;
propertyTitle: string;
sellerName: string;
status: string;
totalReports: number;
reasons: string[];
latestReportAt: string;
}
export interface FlaggedListingsResult {
items: FlaggedListingItem[];
total: number;
page: number;
limit: number;
}
@QueryHandler(GetFlaggedListingsQuery)
export class GetFlaggedListingsHandler implements IQueryHandler<GetFlaggedListingsQuery> {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(query: GetFlaggedListingsQuery): Promise<FlaggedListingsResult> {
try {
const { page, limit } = query;
const skip = (page - 1) * limit;
// Get listings that have pending flags, grouped by listing
const flaggedListings = await this.prisma.listingFlag.groupBy({
by: ['listingId'],
where: { status: 'PENDING' },
_count: { id: true },
_max: { createdAt: true },
orderBy: { _count: { id: 'desc' } },
skip,
take: limit,
});
const totalGroups = await this.prisma.listingFlag.groupBy({
by: ['listingId'],
where: { status: 'PENDING' },
});
const total = totalGroups.length;
if (flaggedListings.length === 0) {
return { items: [], total: 0, page, limit };
}
const listingIds = flaggedListings.map((f) => f.listingId);
// Fetch listing details
const listings = await this.prisma.listing.findMany({
where: { id: { in: listingIds } },
select: {
id: true,
status: true,
property: { select: { title: true } },
seller: { select: { fullName: true } },
},
});
const listingMap = new Map(listings.map((l) => [l.id, l]));
// Fetch distinct reasons per listing
const reasonFlags = await this.prisma.listingFlag.findMany({
where: { listingId: { in: listingIds }, status: 'PENDING' },
select: { listingId: true, reason: true },
distinct: ['listingId', 'reason'],
});
const reasonMap = new Map<string, string[]>();
for (const rf of reasonFlags) {
const arr = reasonMap.get(rf.listingId) ?? [];
arr.push(rf.reason);
reasonMap.set(rf.listingId, arr);
}
const items: FlaggedListingItem[] = flaggedListings.map((group) => {
const listing = listingMap.get(group.listingId);
return {
listingId: group.listingId,
propertyTitle: listing?.property?.title ?? 'Unknown',
sellerName: listing?.seller?.fullName ?? 'Unknown',
status: listing?.status ?? 'UNKNOWN',
totalReports: group._count.id,
reasons: reasonMap.get(group.listingId) ?? [],
latestReportAt: group._max.createdAt?.toISOString() ?? '',
};
});
return { items, total, page, limit };
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to get flagged listings: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
'GetFlaggedListingsHandler',
);
throw new InternalServerErrorException('Lỗi khi lấy danh sách tin bị báo cáo');
}
}
}

View File

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

View File

@@ -37,6 +37,8 @@ import { ApproveListingDto } from '../dto/approve-listing.dto';
import { BulkModerateDto } from '../dto/bulk-moderate.dto';
import { RejectKycDto } from '../dto/reject-kyc.dto';
import { RejectListingDto } from '../dto/reject-listing.dto';
import { GetFlaggedListingsQuery } from '../../application/queries/get-flagged-listings/get-flagged-listings.query';
import type { FlaggedListingsResult } from '../../application/queries/get-flagged-listings/get-flagged-listings.handler';
@ApiTags('admin')
@ApiBearerAuth('JWT')
@@ -139,6 +141,27 @@ export class AdminModerationController {
);
}
// ── Flagged Listings (User Reports) ──
@Get('flagged-listings')
@ApiOperation({ summary: 'Get listings flagged by users (báo cáo)' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default 1)' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default 20)' })
@ApiResponse({ status: 200, description: 'Flagged listings queue retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async getFlaggedListings(
@Query('page') page?: string,
@Query('limit') limit?: string,
): Promise<FlaggedListingsResult> {
return this.queryBus.execute(
new GetFlaggedListingsQuery(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
),
);
}
// ── KYC ──
@Get('kyc')

View File

@@ -0,0 +1,10 @@
import { FlagReason } from '@prisma/client';
export class ReportListingCommand {
constructor(
public readonly listingId: string,
public readonly reporterId: string,
public readonly reason: FlagReason,
public readonly description?: string,
) {}
}

View File

@@ -0,0 +1,119 @@
import { HttpStatus, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, ErrorCode, LoggerService, PrismaService } from '@modules/shared';
import { ReportListingCommand } from './report-listing.command';
/** Threshold: auto-flag listing for moderator review when it reaches this many reports. */
const AUTO_FLAG_THRESHOLD = 3;
export interface ReportListingResult {
flagId: string;
listingId: string;
totalReports: number;
autoFlagged: boolean;
}
@CommandHandler(ReportListingCommand)
export class ReportListingHandler implements ICommandHandler<ReportListingCommand> {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(command: ReportListingCommand): Promise<ReportListingResult> {
try {
// Verify listing exists and is active
const listing = await this.prisma.listing.findUnique({
where: { id: command.listingId },
select: { id: true, status: true },
});
if (!listing) {
throw new DomainException(
ErrorCode.NOT_FOUND,
'Tin đăng không tồn tại',
HttpStatus.NOT_FOUND,
);
}
// Prevent self-reporting
const isSeller = await this.prisma.listing.findFirst({
where: { id: command.listingId, sellerId: command.reporterId },
select: { id: true },
});
if (isSeller) {
throw new DomainException(
ErrorCode.BAD_REQUEST,
'Không thể báo cáo tin đăng của chính mình',
HttpStatus.BAD_REQUEST,
);
}
// Check for duplicate report (unique constraint will also catch this)
const existingFlag = await this.prisma.listingFlag.findUnique({
where: {
listingId_reporterId: {
listingId: command.listingId,
reporterId: command.reporterId,
},
},
});
if (existingFlag) {
throw new DomainException(
ErrorCode.CONFLICT,
'Bạn đã báo cáo tin đăng này rồi',
HttpStatus.CONFLICT,
);
}
// Create the flag
const flag = await this.prisma.listingFlag.create({
data: {
listingId: command.listingId,
reporterId: command.reporterId,
reason: command.reason,
description: command.description ?? null,
},
});
// Count total reports for this listing
const totalReports = await this.prisma.listingFlag.count({
where: { listingId: command.listingId },
});
// Auto-flag: when ≥3 reports, move listing to PENDING_REVIEW for moderator
let autoFlagged = false;
if (totalReports >= AUTO_FLAG_THRESHOLD && listing.status === 'ACTIVE') {
await this.prisma.listing.update({
where: { id: command.listingId },
data: {
status: 'PENDING_REVIEW',
moderationNotes: `Tự động chuyển sang chờ duyệt: ${totalReports} báo cáo từ người dùng`,
},
});
autoFlagged = true;
this.logger.log(
`Listing ${command.listingId} auto-flagged for moderation (${totalReports} reports)`,
'ReportListingHandler',
);
}
return {
flagId: flag.id,
listingId: command.listingId,
totalReports,
autoFlagged,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to report listing: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
'ReportListingHandler',
);
throw new InternalServerErrorException('Không thể báo cáo tin đăng');
}
}
}

View File

@@ -65,6 +65,9 @@ import { PromoteFeaturedListingDto } from '../dto/promote-featured-listing.dto';
import { SearchListingsDto } from '../dto/search-listings.dto';
import { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
import { UpdateListingDto } from '../dto/update-listing.dto';
import { ReportListingDto } from '../dto/report-listing.dto';
import { ReportListingCommand } from '../../application/commands/report-listing/report-listing.command';
import type { ReportListingResult } from '../../application/commands/report-listing/report-listing.handler';
@ApiTags('listings')
@Controller('listings')
@@ -129,6 +132,7 @@ export class ListingsController {
petFriendly: dto.petFriendly,
suitableFor: dto.suitableFor,
whyThisLocation: dto.whyThisLocation,
certificateVerified: dto.certificateVerified,
},
),
);
@@ -334,6 +338,7 @@ export class ListingsController {
petFriendly: dto.petFriendly,
suitableFor: dto.suitableFor,
whyThisLocation: dto.whyThisLocation,
certificateVerified: dto.certificateVerified,
},
dto.agentId,
),
@@ -524,4 +529,27 @@ export class ListingsController {
new PromoteFeaturedListingCommand(id, user.sub, dto.durationDays),
);
}
// ── Report / Flag ──
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Report a listing (Báo cáo tin đăng)' })
@ApiParam({ name: 'id', description: 'Listing ID' })
@ApiResponse({ status: 201, description: 'Báo cáo thành công' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Listing not found' })
@ApiResponse({ status: 409, description: 'Đã báo cáo tin đăng này' })
@UseGuards(JwtAuthGuard)
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Post(':id/report')
async reportListing(
@Param('id') id: string,
@Body() dto: ReportListingDto,
@CurrentUser() user: JwtPayload,
): Promise<ReportListingResult> {
return this.commandBus.execute(
new ReportListingCommand(id, user.sub, dto.reason as any, dto.description),
);
}
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
export class ReportListingDto {
@ApiProperty({
enum: ['SCAM', 'DUPLICATE', 'WRONG_INFO', 'ALREADY_SOLD', 'INAPPROPRIATE'],
example: 'SCAM',
description: 'Lý do báo cáo',
})
@IsEnum(['SCAM', 'DUPLICATE', 'WRONG_INFO', 'ALREADY_SOLD', 'INAPPROPRIATE'] as const)
reason!: 'SCAM' | 'DUPLICATE' | 'WRONG_INFO' | 'ALREADY_SOLD' | 'INAPPROPRIATE';
@ApiPropertyOptional({ example: 'Tin đăng có dấu hiệu lừa đảo', description: 'Mô tả chi tiết (tuỳ chọn)' })
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
}

View File

@@ -27,6 +27,7 @@ export interface ListingDocument {
viewCount: number;
saveCount: number;
projectName: string | null;
legalStatus: string | null;
amenities: string[];
isFeatured: number; // 1 if featuredUntil > now, 0 otherwise
}

View File

@@ -26,6 +26,7 @@ const mockListing = {
district: 'District 1',
city: 'HCMC',
projectName: null,
legalStatus: null,
amenities: ['parking'],
},
};

View File

@@ -29,6 +29,7 @@ function makeDocument(overrides?: Partial<ListingDocument>): ListingDocument {
viewCount: 10,
saveCount: 5,
projectName: null,
legalStatus: null,
amenities: ['parking'],
...overrides,
};

View File

@@ -119,6 +119,7 @@ export class ListingIndexerService {
viewCount: l.viewCount,
saveCount: l.saveCount,
projectName: p.projectName,
legalStatus: p.legalStatus,
amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [],
isFeatured: l.featuredUntil && l.featuredUntil > new Date()
? featuredTierWeight(l.featuredPackage as string | null)
@@ -170,6 +171,7 @@ export class ListingIndexerService {
viewCount: listing.viewCount,
saveCount: listing.saveCount,
projectName: p.projectName,
legalStatus: p.legalStatus,
amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [],
isFeatured: listing.featuredUntil && listing.featuredUntil > new Date()
? featuredTierWeight(listing.featuredPackage as string | null)

View File

@@ -63,7 +63,7 @@ export class PostgresSearchRepository implements ISearchRepository {
ST_Y(p."location"::geometry) AS "lat",
ST_X(p."location"::geometry) AS "lng",
l."agentId", l."sellerId", l."status", l."publishedAt",
l."viewCount", l."saveCount", p."projectName", p."amenities"
l."viewCount", l."saveCount", p."projectName", p."legalStatus", p."amenities"
FROM "Listing" l JOIN "Property" p ON l."propertyId" = p."id"
${whereClause}
${orderClause}

View File

@@ -27,6 +27,7 @@ export interface RawListingRow {
viewCount: number;
saveCount: number;
projectName: string | null;
legalStatus?: string | null;
amenities: unknown;
featuredUntil?: Date | string | null;
}
@@ -60,6 +61,7 @@ export function mapRowToListingDocument(row: RawListingRow): ListingDocument {
viewCount: row.viewCount ?? 0,
saveCount: row.saveCount ?? 0,
projectName: row.projectName,
legalStatus: row.legalStatus ?? null,
amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [],
isFeatured: row.featuredUntil && new Date(row.featuredUntil) > new Date() ? 1 : 0,
};

View File

@@ -1,32 +1,3 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { Client as TypesenseClient } from 'typesense';
import { LoggerService } from '@modules/shared';
@Injectable()
export class TypesenseClientService implements OnModuleInit {
private client!: TypesenseClient;
constructor(private readonly logger: LoggerService) {}
onModuleInit(): void {
this.client = new TypesenseClient({
nodes: [
{
host: process.env['TYPESENSE_HOST'] || 'localhost',
port: parseInt(process.env['TYPESENSE_PORT'] || '8108', 10),
protocol: process.env['TYPESENSE_PROTOCOL'] || 'http',
},
],
apiKey: process.env['TYPESENSE_API_KEY'] || 'ts_dev_key_change_me',
connectionTimeoutSeconds: 5,
retryIntervalSeconds: 0.1,
numRetries: 3,
});
this.logger.log('TypesenseClientService initialized', 'TypesenseClient');
}
getClient(): TypesenseClient {
return this.client;
}
}
// Re-export from SharedModule for backward compatibility.
// The canonical location is now @modules/shared.
export { TypesenseClientService } from '@modules/shared';

View File

@@ -40,6 +40,7 @@ const LISTING_SCHEMA: CollectionCreateSchema = {
{ name: 'viewCount', type: 'int32', facet: false },
{ name: 'saveCount', type: 'int32', facet: false },
{ name: 'projectName', type: 'string', facet: true, optional: true },
{ name: 'legalStatus', type: 'string', facet: true, optional: true },
{ name: 'amenities', type: 'string[]', facet: true, optional: true },
{ name: 'isFeatured', type: 'int32', facet: true },
],

View File

@@ -1,7 +1,7 @@
import { Module, type OnModuleInit } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { makeCounterProvider } from '@willsoto/nestjs-prometheus';
import { LoggerService } from '@modules/shared';
import { LoggerService, TypesenseClientService } from '@modules/shared';
import { SubscriptionsModule } from '@modules/subscriptions';
import { CreateSavedSearchHandler } from './application/commands/create-saved-search/create-saved-search.handler';
import { DeleteSavedSearchHandler } from './application/commands/delete-saved-search/delete-saved-search.handler';
@@ -21,7 +21,6 @@ import { SavedSearchAlertHandler } from './infrastructure/event-handlers/saved-s
import { ListingIndexerService } from './infrastructure/services/listing-indexer.service';
import { PostgresSearchRepository } from './infrastructure/services/postgres-search.repository';
import { ResilientSearchRepository, SEARCH_DEGRADATION_TOTAL } from './infrastructure/services/resilient-search.repository';
import { TypesenseClientService } from './infrastructure/services/typesense-client.service';
import { TypesenseSearchRepository } from './infrastructure/services/typesense-search.repository';
import { SavedSearchController } from './presentation/controllers/saved-search.controller';
import { SearchController } from './presentation/controllers/search.controller';
@@ -34,7 +33,6 @@ const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler, GetSavedSearch
controllers: [SearchController, SavedSearchController],
providers: [
// Infrastructure
TypesenseClientService,
TypesenseSearchRepository,
PostgresSearchRepository,
ResilientSearchRepository,
@@ -61,11 +59,10 @@ const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler, GetSavedSearch
...CommandHandlers,
...QueryHandlers,
],
exports: [ListingIndexerService, SEARCH_REPOSITORY, TypesenseClientService],
exports: [ListingIndexerService, SEARCH_REPOSITORY],
})
export class SearchModule implements OnModuleInit {
constructor(
private readonly typesenseClient: TypesenseClientService,
private readonly searchRepo: ResilientSearchRepository,
private readonly logger: LoggerService,
) {}

View File

@@ -14,6 +14,7 @@ export { RedisService } from './redis.service';
export { RedisIoAdapter } from './redis-io.adapter';
export { CacheService, CachePrefix, CacheTTL } from './cache.service';
export { LoggerService } from './logger.service';
export { TypesenseClientService } from './typesense-client.service';
export { EventBusService } from './event-bus.service';
export { GlobalExceptionFilter } from './filters/global-exception.filter';
export { CorrelationIdMiddleware } from './middleware/correlation-id.middleware';

View File

@@ -0,0 +1,37 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { Client as TypesenseClient } from 'typesense';
import { LoggerService } from './logger.service';
/**
* Provides a shared Typesense client for search, indexers, and health probes.
* Lives in SharedModule so any feature module can inject it without importing
* SearchModule.
*/
@Injectable()
export class TypesenseClientService implements OnModuleInit {
private client!: TypesenseClient;
constructor(private readonly logger: LoggerService) {}
onModuleInit(): void {
this.client = new TypesenseClient({
nodes: [
{
host: process.env['TYPESENSE_HOST'] || 'localhost',
port: parseInt(process.env['TYPESENSE_PORT'] || '8108', 10),
protocol: process.env['TYPESENSE_PROTOCOL'] || 'http',
},
],
apiKey: process.env['TYPESENSE_API_KEY'] || 'ts_dev_key_change_me',
connectionTimeoutSeconds: 5,
retryIntervalSeconds: 0.1,
numRetries: 3,
});
this.logger.log('TypesenseClientService initialized', 'TypesenseClient');
}
getClient(): TypesenseClient {
return this.client;
}
}

View File

@@ -19,6 +19,7 @@ import { RequestLoggingMiddleware } from './infrastructure/middleware/request-lo
import { SanitizeInputMiddleware } from './infrastructure/middleware/sanitize-input.middleware';
import { PrismaService } from './infrastructure/prisma.service';
import { RedisService } from './infrastructure/redis.service';
import { TypesenseClientService } from './infrastructure/typesense-client.service';
@Global()
@Module({
@@ -34,6 +35,7 @@ import { RedisService } from './infrastructure/redis.service';
RedisService,
CacheService,
EventBusService,
TypesenseClientService,
makeCounterProvider({
name: CACHE_HIT_TOTAL,
help: 'Total number of cache hits',
@@ -54,7 +56,7 @@ import { RedisService } from './infrastructure/redis.service';
useClass: GlobalExceptionFilter,
},
],
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, FieldEncryptionService, PrometheusModule],
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, FieldEncryptionService, TypesenseClientService, PrometheusModule],
})
export class SharedModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {

View File

@@ -1,7 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { type BankTransferConfirmedEvent } from '@modules/payments';
import { LoggerService, PrismaService } from '@modules/shared';
import { LoggerService } from '@modules/shared';
import {
SUBSCRIPTION_REPOSITORY,
type ISubscriptionRepository,
} from '../../domain/repositories/subscription.repository';
/**
* Handles subscription activation once a bank-transfer payment is confirmed.
@@ -15,13 +19,17 @@ import { LoggerService, PrismaService } from '@modules/shared';
* happens upstream during payment creation; this listener is the
* side-effect hook that flips the subscription status.
*
* Uses ISubscriptionRepository to keep the domain entity authoritative —
* no raw Prisma access in this handler.
*
* NOTE: Intentionally defensive — if no subscription exists yet the event
* is logged and skipped; downstream processes (CS or renewal cron) pick it up.
*/
@Injectable()
export class BankTransferSubscriptionActivationHandler {
constructor(
private readonly prisma: PrismaService,
@Inject(SUBSCRIPTION_REPOSITORY)
private readonly subscriptionRepo: ISubscriptionRepository,
private readonly logger: LoggerService,
) {}
@@ -32,10 +40,7 @@ export class BankTransferSubscriptionActivationHandler {
}
try {
const subscription = await this.prisma.subscription.findFirst({
where: { userId: event.userId },
orderBy: { updatedAt: 'desc' },
});
const subscription = await this.subscriptionRepo.findByUserId(event.userId);
if (!subscription) {
this.logger.warn(
@@ -46,21 +51,18 @@ export class BankTransferSubscriptionActivationHandler {
}
const now = new Date();
const baseDate =
const baseStart =
subscription.currentPeriodEnd > now ? subscription.currentPeriodStart : now;
const baseEnd =
subscription.currentPeriodEnd > now ? subscription.currentPeriodEnd : now;
// Default to 30-day extension; renewal command handles more granular math
const nextPeriodEnd = new Date(
baseDate.getTime() + 30 * 24 * 60 * 60 * 1000,
baseEnd.getTime() + 30 * 24 * 60 * 60 * 1000,
);
await this.prisma.subscription.update({
where: { id: subscription.id },
data: {
status: 'ACTIVE',
currentPeriodEnd: nextPeriodEnd,
},
});
subscription.renewPeriod(baseStart, nextPeriodEnd);
await this.subscriptionRepo.update(subscription);
this.logger.log(
`Subscription activated via bank transfer: subscriptionId=${subscription.id}, userId=${event.userId}, paymentId=${event.aggregateId}, periodEnd=${nextPeriodEnd.toISOString()}`,

View File

@@ -8,6 +8,7 @@ import { CheckQuotaHandler } from './application/queries/check-quota/check-quota
import { GetBillingHistoryHandler } from './application/queries/get-billing-history/get-billing-history.handler';
import { GetPlanHandler } from './application/queries/get-plan/get-plan.handler';
import { SUBSCRIPTION_REPOSITORY } from './domain/repositories/subscription.repository';
import { BankTransferSubscriptionActivationHandler } from './infrastructure/event-handlers/bank-transfer-subscription-activation.handler';
import { ListingCreatedUsageHandler } from './infrastructure/event-handlers/listing-created-usage.handler';
import { SavedSearchCreatedUsageHandler } from './infrastructure/event-handlers/saved-search-created-usage.handler';
import { PrismaSubscriptionRepository } from './infrastructure/repositories/prisma-subscription.repository';
@@ -40,6 +41,7 @@ const QueryHandlers = [
// Event Listeners
ListingCreatedUsageHandler,
SavedSearchCreatedUsageHandler,
BankTransferSubscriptionActivationHandler,
// CQRS
...CommandHandlers,