feat(api): improve notifications, reviews, search, and subscriptions modules

- Add listing-sold event listener with spec for notifications
- Add review-deleted event listener with spec for reviews
- Improve search handlers with proper Typesense client injection
- Improve subscription handlers with ConfigService and quota tracking

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 09:43:39 +07:00
parent f15e98a33b
commit e927385ed5
54 changed files with 356 additions and 82 deletions

View File

@@ -0,0 +1,86 @@
import { ListingSoldListener } from '../listeners/listing-sold.listener';
describe('ListingSoldListener', () => {
let listener: ListingSoldListener;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockPrisma: {
listing: { findUnique: ReturnType<typeof vi.fn> };
savedListing: { findMany: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) };
mockPrisma = {
listing: { findUnique: vi.fn() },
savedListing: { findMany: vi.fn().mockResolvedValue([]) },
};
mockLogger = { log: vi.fn(), warn: vi.fn() };
listener = new ListingSoldListener(
mockCommandBus as any,
mockPrisma as any,
mockLogger as any,
);
});
it('notifies seller when listing is sold', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({
id: 'listing-1',
property: { title: 'Căn hộ đẹp' },
seller: { id: 'seller-1', email: 'seller@example.com' },
});
await listener.handle({
aggregateId: 'listing-1',
propertyId: 'prop-1',
finalStatus: 'SOLD',
eventName: 'listing.sold',
occurredAt: new Date(),
});
expect(mockCommandBus.execute).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'seller-1',
channel: 'EMAIL',
templateKey: 'listing.sold',
}),
);
});
it('notifies watchers when listing is sold', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({
id: 'listing-1',
property: { title: 'Căn hộ đẹp' },
seller: { id: 'seller-1', email: 'seller@example.com' },
});
mockPrisma.savedListing.findMany.mockResolvedValue([
{ user: { id: 'watcher-1', email: 'watcher@example.com' } },
]);
await listener.handle({
aggregateId: 'listing-1',
propertyId: 'prop-1',
finalStatus: 'SOLD',
eventName: 'listing.sold',
occurredAt: new Date(),
});
// Seller + 1 watcher = 2 notifications
expect(mockCommandBus.execute).toHaveBeenCalledTimes(2);
});
it('skips notification when listing not found', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
await listener.handle({
aggregateId: 'nonexistent',
propertyId: 'prop-1',
finalStatus: 'SOLD',
eventName: 'listing.sold',
occurredAt: new Date(),
});
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,6 @@
import { Inject } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { type EventBusService } from '@modules/shared/infrastructure/event-bus.service';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type EventBusService, type LoggerService } from '@modules/shared';
import { NotificationSentEvent } from '../../../domain/events/notification-sent.event';
import {
NOTIFICATION_PREFERENCE_REPOSITORY,

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type AgentVerifiedEvent } from '@modules/auth/domain/events/agent-verified.event';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type AgentVerifiedEvent } from '@modules/auth';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()

View File

@@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
export interface InquiryReceivedEvent {

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type ListingApprovedEvent } from '@modules/admin/domain/events/listing-approved.event';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type ListingApprovedEvent } from '@modules/admin';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type ListingRejectedEvent } from '@modules/admin/domain/events/listing-rejected.event';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type ListingRejectedEvent } from '@modules/admin';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()

View File

@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type ListingSoldEvent } from '@modules/listings';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()
export class ListingSoldListener {
constructor(
private readonly commandBus: CommandBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
@OnEvent('listing.sold', { async: true })
async handle(event: ListingSoldEvent): Promise<void> {
this.logger.log(`Handling listing.sold for ${event.aggregateId}`, 'ListingSoldListener');
const listing = await this.prisma.listing.findUnique({
where: { id: event.aggregateId },
include: {
property: { select: { title: true } },
seller: { select: { id: true, email: true } },
},
});
if (!listing) return;
// Notify the seller
if (listing.seller.email) {
await this.commandBus.execute(
new SendNotificationCommand(
listing.seller.id,
'EMAIL',
'listing.sold',
{ listingTitle: listing.property.title, finalStatus: event.finalStatus },
listing.seller.email,
),
);
}
// Notify users who saved/watched this listing
const watchers = await this.prisma.savedListing.findMany({
where: { listingId: event.aggregateId },
include: { user: { select: { id: true, email: true } } },
});
for (const watcher of watchers) {
if (!watcher.user.email) continue;
await this.commandBus.execute(
new SendNotificationCommand(
watcher.user.id,
'EMAIL',
'listing.sold_watcher',
{ listingTitle: listing.property.title },
watcher.user.email,
),
);
}
this.logger.log(
`Notified seller and ${watchers.length} watchers for listing ${event.aggregateId}`,
'ListingSoldListener',
);
}
}

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type PaymentCompletedEvent } from '@modules/payments/domain/events/payment-completed.event';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type PaymentCompletedEvent } from '@modules/payments';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type QuotaExceededEvent } from '@modules/subscriptions/domain/events/quota-exceeded.event';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { type QuotaExceededEvent } from '@modules/subscriptions';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type SubscriptionCancelledEvent } from '@modules/subscriptions/domain/events/subscription-cancelled.event';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { type SubscriptionCancelledEvent } from '@modules/subscriptions';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type UserRegisteredEvent } from '@modules/auth/domain/events/user-registered.event';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type UserRegisteredEvent } from '@modules/auth';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()

View File

@@ -1,4 +1,4 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type DomainEvent } from '@modules/shared';
import { type NotificationChannel } from '../value-objects/notification-channel.vo';
export class NotificationSentEvent implements DomainEvent {

View File

@@ -1 +1,2 @@
export { NotificationsModule } from './notifications.module';
export { SendNotificationCommand } from './application/commands/send-notification/send-notification.command';

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type PrismaService } from '@modules/shared';
import { type NotificationPreferenceEntity } from '../../domain/entities/notification-preference.entity';
import { type INotificationPreferenceRepository } from '../../domain/repositories/notification-preference.repository';
import { type NotificationChannel } from '../../domain/value-objects/notification-channel.vo';

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type Prisma } from '@prisma/client';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type PrismaService } from '@modules/shared';
import { type NotificationEntity, type NotificationStatus } from '../../domain/entities/notification.entity';
import {
type INotificationRepository,

View File

@@ -1,6 +1,6 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type LoggerService } from '@modules/shared';
export interface SendEmailDto {
to: string;
@@ -41,11 +41,11 @@ export class EmailService implements OnModuleInit {
html: dto.html,
});
this.logger.log(`Email sent to ${dto.to}: ${info.messageId}`, 'EmailService');
this.logger.log(`Email sent successfully: ${info.messageId}`, 'EmailService');
return { messageId: info.messageId };
} catch (error) {
this.logger.error(
`Failed to send email to ${dto.to}: ${error instanceof Error ? error.message : String(error)}`,
`Failed to send email: ${error instanceof Error ? error.message : String(error)}`,
'EmailService',
);
throw error;

View File

@@ -6,7 +6,7 @@ import {
messaging,
type ServiceAccount,
} from 'firebase-admin';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type LoggerService } from '@modules/shared';
export interface SendPushDto {
token: string;

View File

@@ -5,6 +5,7 @@ import { AgentVerifiedListener } from './application/listeners/agent-verified.li
import { InquiryReceivedListener } from './application/listeners/inquiry-received.listener';
import { ListingApprovedListener } from './application/listeners/listing-approved.listener';
import { ListingRejectedListener } from './application/listeners/listing-rejected.listener';
import { ListingSoldListener } from './application/listeners/listing-sold.listener';
import { PaymentCompletedListener } from './application/listeners/payment-completed.listener';
import { QuotaExceededListener } from './application/listeners/quota-exceeded.listener';
import { SubscriptionExpiringListener } from './application/listeners/subscription-expiring.listener';
@@ -29,6 +30,7 @@ const EventListeners = [
PaymentCompletedListener,
SubscriptionExpiringListener,
InquiryReceivedListener,
ListingSoldListener,
];
@Module({

View File

@@ -13,8 +13,7 @@ import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiProperty } from '@nestjs/swagger';
import { NotificationChannel as PrismaChannel } from '@prisma/client';
import { IsBoolean, IsEnum, IsString } from 'class-validator';
import { type JwtPayload } from '@modules/auth';
import { CurrentUser } from '@modules/auth/presentation/decorators';
import { CurrentUser, type JwtPayload } from '@modules/auth';
import {
NOTIFICATION_REPOSITORY,
type INotificationRepository,

View File

@@ -0,0 +1,85 @@
import { ReviewDeletedListener } from '../listeners/review-deleted.listener';
describe('ReviewDeletedListener', () => {
let listener: ReviewDeletedListener;
let mockPrisma: {
review: { aggregate: ReturnType<typeof vi.fn> };
agent: { update: ReturnType<typeof vi.fn> };
listing: { update: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPrisma = {
review: { aggregate: vi.fn() },
agent: { update: vi.fn().mockResolvedValue(undefined) },
listing: { update: vi.fn().mockResolvedValue(undefined) },
};
mockLogger = { log: vi.fn(), warn: vi.fn() };
listener = new ReviewDeletedListener(mockPrisma as any, mockLogger as any);
});
it('recalculates agent quality score on review deletion', async () => {
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: 4.2 },
_count: { rating: 5 },
});
await listener.handle({
aggregateId: 'review-1',
userId: 'user-1',
targetType: 'AGENT',
targetId: 'agent-1',
eventName: 'review.deleted',
occurredAt: new Date(),
});
expect(mockPrisma.agent.update).toHaveBeenCalledWith({
where: { id: 'agent-1' },
data: { qualityScore: 4.2 },
});
});
it('recalculates listing average rating on review deletion', async () => {
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: 3.5 },
_count: { rating: 10 },
});
await listener.handle({
aggregateId: 'review-2',
userId: 'user-2',
targetType: 'LISTING',
targetId: 'listing-1',
eventName: 'review.deleted',
occurredAt: new Date(),
});
expect(mockPrisma.listing.update).toHaveBeenCalledWith({
where: { id: 'listing-1' },
data: { averageRating: 3.5, reviewCount: 10 },
});
});
it('handles zero reviews after deletion', async () => {
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: null },
_count: { rating: 0 },
});
await listener.handle({
aggregateId: 'review-3',
userId: 'user-3',
targetType: 'AGENT',
targetId: 'agent-2',
eventName: 'review.deleted',
occurredAt: new Date(),
});
expect(mockPrisma.agent.update).toHaveBeenCalledWith({
where: { id: 'agent-2' },
data: { qualityScore: 0 },
});
});
});

View File

@@ -1,7 +1,7 @@
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { ConflictException, ValidationException } from '@modules/shared/domain/domain-exception';
import { ConflictException, ValidationException } from '@modules/shared';
import { ReviewEntity } from '../../../domain/entities/review.entity';
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
import { Rating } from '../../../domain/value-objects/rating.vo';

View File

@@ -1,6 +1,6 @@
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenException, NotFoundException } from '@modules/shared/domain/domain-exception';
import { ForbiddenException, NotFoundException } from '@modules/shared';
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
import { DeleteReviewCommand } from './delete-review.command';

View File

@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { type ReviewDeletedEvent } from '../../domain/events/review-deleted.event';
@Injectable()
export class ReviewDeletedListener {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
@OnEvent('review.deleted', { async: true })
async handle(event: ReviewDeletedEvent): Promise<void> {
this.logger.log(
`Handling review.deleted: recalculating rating for ${event.targetType}:${event.targetId}`,
'ReviewDeletedListener',
);
// Recalculate average rating for the target (agent or listing)
const stats = await this.prisma.review.aggregate({
where: {
targetType: event.targetType,
targetId: event.targetId,
deletedAt: null,
},
_avg: { rating: true },
_count: { rating: true },
});
const avgRating = stats._avg.rating ?? 0;
const reviewCount = stats._count.rating;
// Update the target's cached rating based on target type
if (event.targetType === 'AGENT') {
await this.prisma.agent.update({
where: { id: event.targetId },
data: { qualityScore: avgRating },
});
} else if (event.targetType === 'LISTING') {
await this.prisma.listing.update({
where: { id: event.targetId },
data: { averageRating: avgRating, reviewCount },
});
}
this.logger.log(
`Rating recalculated for ${event.targetType}:${event.targetId} → avg=${avgRating.toFixed(2)}, count=${reviewCount}`,
'ReviewDeletedListener',
);
}
}

View File

@@ -1,4 +1,4 @@
import { AggregateRoot } from '@modules/shared/domain/aggregate-root';
import { AggregateRoot } from '@modules/shared';
import { ReviewCreatedEvent } from '../events/review-created.event';
import { ReviewDeletedEvent } from '../events/review-deleted.event';
import { type Rating } from '../value-objects/rating.vo';

View File

@@ -1,4 +1,4 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type DomainEvent } from '@modules/shared';
export class ReviewCreatedEvent implements DomainEvent {
readonly eventName = 'review.created';

View File

@@ -1,4 +1,4 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type DomainEvent } from '@modules/shared';
export class ReviewDeletedEvent implements DomainEvent {
readonly eventName = 'review.deleted';

View File

@@ -1,5 +1,4 @@
import { Result } from '@modules/shared/domain/result';
import { ValueObject } from '@modules/shared/domain/value-object';
import { Result, ValueObject } from '@modules/shared';
interface RatingProps {
value: number;

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import type { Review as PrismaReview } from '@prisma/client';
import type { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type PrismaService } from '@modules/shared';
import { ReviewEntity } from '../../domain/entities/review.entity';
import type { ReviewItemData, ReviewStatsData } from '../../domain/repositories/review-read.dto';
import type { IReviewRepository, PaginatedResult } from '../../domain/repositories/review.repository';

View File

@@ -16,9 +16,7 @@ import {
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import type { JwtPayload } from '@modules/auth/infrastructure/services/token.service';
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator';
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
import { CreateReviewCommand } from '../../application/commands/create-review/create-review.command';
import { type CreateReviewResult } from '../../application/commands/create-review/create-review.handler';
import { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command';

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { CreateReviewHandler } from './application/commands/create-review/create-review.handler';
import { DeleteReviewHandler } from './application/commands/delete-review/delete-review.handler';
import { ReviewDeletedListener } from './application/listeners/review-deleted.listener';
import { GetAverageRatingHandler } from './application/queries/get-average-rating/get-average-rating.handler';
import { GetReviewsByTargetHandler } from './application/queries/get-reviews-by-target/get-reviews-by-target.handler';
import { GetReviewsByUserHandler } from './application/queries/get-reviews-by-user/get-reviews-by-user.handler';
@@ -24,6 +25,7 @@ const QueryHandlers = [
{ provide: REVIEW_REPOSITORY, useClass: PrismaReviewRepository },
...CommandHandlers,
...QueryHandlers,
ReviewDeletedListener,
],
exports: [REVIEW_REPOSITORY],
})

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import {
SEARCH_REPOSITORY,
type ISearchRepository,

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import {
SEARCH_REPOSITORY,
type ISearchRepository,

View File

@@ -1,4 +1,4 @@
import { ValueObject } from '@modules/shared/domain/value-object';
import { ValueObject } from '@modules/shared';
interface GeoFilterProps {
lat: number;

View File

@@ -1,4 +1,4 @@
import { ValueObject } from '@modules/shared/domain/value-object';
import { ValueObject } from '@modules/shared';
interface SearchFilterProps {
query?: string;

View File

@@ -1 +1,2 @@
export { SearchModule } from './search.module';
export { TypesenseClientService } from './infrastructure/services/typesense-client.service';

View File

@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { CacheService, CachePrefix, type LoggerService } from '@modules/shared';
import { type ListingIndexerService } from '../services/listing-indexer.service';
@Injectable()

View File

@@ -1,7 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type LoggerService, type PrismaService } from '@modules/shared';
import {
SEARCH_REPOSITORY,
type ISearchRepository,

View File

@@ -1,6 +1,6 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { Client as TypesenseClient } from 'typesense';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type LoggerService } from '@modules/shared';
@Injectable()
export class TypesenseClientService implements OnModuleInit {

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { type Client as TypesenseClient } from 'typesense';
import type { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type LoggerService } from '@modules/shared';
import {
type ISearchRepository,
type ListingDocument,

View File

@@ -12,9 +12,7 @@ import {
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { Roles } from '@modules/auth/presentation/decorators/roles.decorator';
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard';
import { Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { ReindexAllCommand } from '../../application/commands/reindex-all/reindex-all.command';
import { type ReindexResult } from '../../application/commands/reindex-all/reindex-all.handler';
import { GeoSearchQuery } from '../../application/queries/geo-search/geo-search.query';

View File

@@ -1,6 +1,6 @@
import { Module, type OnModuleInit } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type LoggerService } from '@modules/shared';
import { ReindexAllHandler } from './application/commands/reindex-all/reindex-all.handler';
import { SyncListingHandler } from './application/commands/sync-listing/sync-listing.handler';
import { GeoSearchHandler } from './application/queries/geo-search/geo-search.handler';

View File

@@ -1,8 +1,7 @@
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { NotFoundException, ConflictException } from '@modules/shared/domain/domain-exception';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { NotFoundException, ConflictException, type PrismaService } from '@modules/shared';
import { SubscriptionEntity } from '../../../domain/entities/subscription.entity';
import {
SUBSCRIPTION_REPOSITORY,

View File

@@ -1,8 +1,6 @@
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { NotFoundException, ValidationException, CacheService, CachePrefix, type PrismaService } from '@modules/shared';
import {
SUBSCRIPTION_REPOSITORY,
type ISubscriptionRepository,

View File

@@ -1,9 +1,7 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { type Plan } from '@prisma/client';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { NotFoundException, CacheService, CachePrefix, CacheTTL, type PrismaService } from '@modules/shared';
import {
SUBSCRIPTION_REPOSITORY,
type ISubscriptionRepository,

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type PrismaService } from '@modules/shared';
import {
SUBSCRIPTION_REPOSITORY,
type ISubscriptionRepository,

View File

@@ -1,7 +1,6 @@
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { type Plan } from '@prisma/client';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { NotFoundException, type PrismaService } from '@modules/shared';
import { GetPlanQuery } from './get-plan.query';
export interface PlanDto {

View File

@@ -1,4 +1,4 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type DomainEvent } from '@modules/shared';
export class QuotaExceededEvent implements DomainEvent {
readonly eventName = 'quota.exceeded';

View File

@@ -1,5 +1,5 @@
import { type PlanTier } from '@prisma/client';
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type DomainEvent } from '@modules/shared';
export class SubscriptionCancelledEvent implements DomainEvent {
readonly eventName = 'subscription.cancelled';

View File

@@ -1,5 +1,5 @@
import { type PlanTier } from '@prisma/client';
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type DomainEvent } from '@modules/shared';
export class SubscriptionCreatedEvent implements DomainEvent {
readonly eventName = 'subscription.created';

View File

@@ -1,5 +1,5 @@
import { type PlanTier } from '@prisma/client';
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type DomainEvent } from '@modules/shared';
export class SubscriptionUpgradedEvent implements DomainEvent {
readonly eventName = 'subscription.upgraded';

View File

@@ -1,5 +1,7 @@
export { SubscriptionsModule } from './subscriptions.module';
export { SUBSCRIPTION_REPOSITORY } from './domain/repositories/subscription.repository';
export { type ISubscriptionRepository } from './domain/repositories/subscription.repository';
export { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository } from './domain/repositories/subscription.repository';
export { QuotaGuard } from './presentation/guards/quota.guard';
export { RequireQuota } from './presentation/decorators/require-quota.decorator';
export { RequireQuota, QUOTA_METRIC_KEY } from './presentation/decorators/require-quota.decorator';
export { SubscriptionEntity, type SubscriptionProps } from './domain/entities/subscription.entity';
export { QuotaExceededEvent } from './domain/events/quota-exceeded.event';
export { SubscriptionCancelledEvent } from './domain/events/subscription-cancelled.event';

View File

@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type ListingCreatedEvent } from '@modules/listings/domain/events/listing-created.event';
import { type ListingCreatedEvent } from '@modules/listings';
import { MeterUsageCommand } from '../../application/commands/meter-usage/meter-usage.command';
@Injectable()

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type Subscription as PrismaSubscription, type Plan as PrismaPlan } from '@prisma/client';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type PrismaService } from '@modules/shared';
import { SubscriptionEntity, type SubscriptionProps } from '../../domain/entities/subscription.entity';
import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository';

View File

@@ -18,9 +18,7 @@ import {
ApiParam,
} from '@nestjs/swagger';
import { type PlanTier } from '@prisma/client';
import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service';
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator';
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
import { CancelSubscriptionCommand } from '../../application/commands/cancel-subscription/cancel-subscription.command';
import { type CancelSubscriptionResult } from '../../application/commands/cancel-subscription/cancel-subscription.handler';
import { CreateSubscriptionCommand } from '../../application/commands/create-subscription/create-subscription.command';