feat(marketplace): Implement comprehensive marketplace search ranking, notifications system, reviews integration, DTOs/entities, and config updates

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-19 05:25:54 -08:00
parent 4a200055b6
commit ec406a5290
12 changed files with 273 additions and 33 deletions

View file

@ -0,0 +1,37 @@
import { bootstrap, presets } from '@lilith/service-nestjs-bootstrap'
import { AppModule } from './app.module'
async function main() {
await bootstrap(AppModule, {
...presets.api,
serviceName: 'client-intel',
dependencies: {
feature: 'client-intel',
autoStart: false,
onProgress: (event) => {
console.log(`[${event.service}] ${event.phase}: ${event.message}`)
},
},
globalPrefix: 'api/client-intel',
cors: {
origins: process.env.CORS_ORIGINS?.split(',') || [
'http://localhost:5173',
'http://localhost:5174',
],
credentials: true,
},
swagger: {
enabled: process.env.NODE_ENV !== 'production',
path: 'api/client-intel/docs',
title: 'Client Intel Service',
description: 'Private provider intelligence network for client safety reporting and scoring',
version: '1.0',
bearerAuth: true,
},
})
console.log('Client Intel service started - check logs for port details')
}
main()

View file

@ -42,7 +42,9 @@ import { MerchantModule } from './merchant';
import { UsageModule } from './usage/usage.module';
import { InvitationsModule } from './invitations/invitations.module';
import { InvitationAnalyticsModule } from './invitation-analytics';
import { ReviewsModule } from './reviews/reviews.module';
// ReviewsModule removed - reviews are now in the standalone reviews feature.
// Marketplace uses ReviewsClientModule (HTTP) for review stats in search ranking,
// and subscribes to review domain events for notifications.
import { RegionsModule } from './regions/regions.module';
import { FmtyModule } from './fmty';
import { UserDbModule } from './userdb';
@ -226,8 +228,9 @@ const getBullModules = (): DynamicModule[] => {
// Friend system (friend requests, friendships, suggestions)
FriendsModule,
// Reviews system (provider reviews, customer reviews, safety scores, disputes)
ReviewsModule,
// Reviews system moved to standalone feature (codebase/features/reviews/).
// Marketplace accesses via ReviewsClientModule (search) and domain events (notifications).
// Old reviews/ directory retained for reference until migration is verified.
// Region-based marketplace readiness (free-until-threshold pricing)
RegionsModule,

View file

@ -50,7 +50,7 @@ export default new DataSource({
join(__dirname, 'inbox/entities/*.entity.{ts,js}'),
join(__dirname, 'invitation-analytics/entities/*.entity.{ts,js}'),
join(__dirname, 'regions/entities/*.entity.{ts,js}'),
join(__dirname, 'reviews/entities/*.entity.{ts,js}'),
// Reviews entities removed - now in standalone reviews feature (codebase/features/reviews/)
join(__dirname, 'search/entities/*.entity.{ts,js}'),
join(__dirname, 'usage/entities/*.entity.{ts,js}'),
// NOTE: userdb entities excluded - they belong to the userdb database (port 25449),

View file

@ -12,8 +12,9 @@
// Usage & Gift DTOs
export * from '../usage/dto';
// Reviews DTOs
export * from '../reviews/dto';
// Reviews DTOs - REMOVED
// Reviews are now in the standalone reviews feature (codebase/features/reviews/).
// Old reviews/dto/ directory retained for reference until migration is verified.
// Friends DTOs
export * from '../friends/dto';

View file

@ -41,8 +41,9 @@ export * from './analytics-outbox.entity';
// Module-specific entity re-exports
// ============================================
// Reviews module entities
export * from '../reviews/entities';
// Reviews module entities - REMOVED
// Reviews are now in the standalone reviews feature (codebase/features/reviews/).
// Old reviews/entities/ directory retained for reference until migration is verified.
// Friends module entities
export * from '../friends/entities';

View file

@ -1,3 +1,4 @@
import { DomainEventsModule } from '@lilith/domain-events';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
@ -15,7 +16,9 @@ import { NotificationsController } from './notifications.controller';
* - NotificationsController: REST API
*
* Event integration:
* - Listens to: message.created, booking.requested, review.posted, system.announcement
* - Listens to: message.created, booking.requested, booking.confirmed,
* booking.cancelled, review.response.posted, system.announcement (via EventEmitter2)
* - Subscribes to: review:provider_created (via @lilith/domain-events)
* - Requires: EventEmitterModule.forRoot() in app.module.ts
*
* Database:
@ -23,7 +26,10 @@ import { NotificationsController } from './notifications.controller';
* - Migration: 1737800000000-CreateNotificationsTable
*/
@Module({
imports: [TypeOrmModule.forFeature([Notification])],
imports: [
TypeOrmModule.forFeature([Notification]),
DomainEventsModule.forFeature(),
],
controllers: [NotificationsController],
providers: [NotificationsService, NotificationGeneratorService],
exports: [NotificationsService, NotificationGeneratorService],

View file

@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { DomainEventsEmitter } from '@lilith/domain-events';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { NotificationsService } from './notifications.service';
@ -24,16 +25,6 @@ interface BookingRequestedEvent {
dateTime?: string;
}
/**
* Event payload for review.posted
*/
interface ReviewPostedEvent {
providerId: string;
reviewerId: string;
reviewId: string;
rating?: number;
}
/**
* Event payload for system.announcement
*/
@ -59,10 +50,34 @@ interface SystemAnnouncementEvent {
* Pattern: @OnEvent decorators trigger on EventEmitter2 events
*/
@Injectable()
export class NotificationGeneratorService {
export class NotificationGeneratorService implements OnModuleInit {
private readonly logger = new Logger(NotificationGeneratorService.name);
constructor(private readonly notificationsService: NotificationsService) {}
constructor(
private readonly notificationsService: NotificationsService,
private readonly domainEvents: DomainEventsEmitter,
) {}
async onModuleInit(): Promise<void> {
// Subscribe to review domain events from the reviews feature
this.domainEvents.subscribe('review:provider_created', async (event) => {
const payload = event.payload as {
reviewId: string;
providerId: string;
customerId: string;
rating: number;
};
await this.handleReviewPosted({
providerId: payload.providerId,
reviewerId: payload.customerId,
reviewId: payload.reviewId,
rating: payload.rating,
});
});
this.logger.log('Subscribed to review domain events');
}
/**
* Event handler: message.created
@ -178,11 +193,15 @@ export class NotificationGeneratorService {
}
/**
* Event handler: review.posted
* Triggered when a review is posted
* Handle review posted event (via domain events subscription)
* Triggered when a provider review is created in the reviews feature
*/
@OnEvent('review.posted')
async handleReviewPosted(event: ReviewPostedEvent): Promise<void> {
async handleReviewPosted(event: {
providerId: string;
reviewerId: string;
reviewId: string;
rating?: number;
}): Promise<void> {
this.logger.debug(`Generating notification for review: ${event.reviewId}${event.providerId}`);
try {

View file

@ -0,0 +1,2 @@
export { ReviewsClientModule } from './reviews-client.module';
export { ReviewsClientService } from './reviews-client.service';

View file

@ -0,0 +1,25 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ReviewsClientService } from './reviews-client.service';
/**
* ReviewsClientModule - HTTP client for the reviews feature API.
*
* Replaces the old ReviewsModule import for search ranking.
* Provides ReviewsClientService for fetching provider review stats
* via the reviews service's internal API.
*/
@Module({
imports: [
HttpModule.register({
timeout: 5000,
maxRedirects: 3,
}),
ConfigModule,
],
providers: [ReviewsClientService],
exports: [ReviewsClientService],
})
export class ReviewsClientModule {}

View file

@ -0,0 +1,146 @@
import { buildDeploymentRegistry } from '@lilith/service-registry';
import { HttpService } from '@nestjs/axios';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AxiosError } from 'axios';
import { firstValueFrom } from 'rxjs';
import type {
ProviderReviewStatsResponse,
ProviderReviewSummary,
} from '@lilith/reviews-shared';
const registry = buildDeploymentRegistry({
deploymentsPath: 'deployments/@domains',
sharedServicesPath: 'deployments/shared-services',
});
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
/**
* ReviewsClientService - HTTP client for the reviews feature API.
*
* Replaces the direct ProviderReviewsService import from the old
* reviews module. Calls the reviews service's internal API endpoints
* for service-to-service communication.
*
* Features:
* - In-memory cache with 2-minute TTL
* - Stale cache fallback on errors
* - Service registry integration for URL resolution
*/
@Injectable()
export class ReviewsClientService implements OnModuleInit {
private readonly logger = new Logger(ReviewsClientService.name);
private readonly baseUrl: string;
private readonly statsCache = new Map<string, CacheEntry<ProviderReviewStatsResponse>>();
private readonly CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {
const reviewsService = registry.services.get('reviews.api');
const defaultUrl = reviewsService ? `http://localhost:${reviewsService.port}` : '';
this.baseUrl = this.configService.get<string>('REVIEWS_API_URL') || defaultUrl;
}
async onModuleInit(): Promise<void> {
if (this.baseUrl) {
this.logger.log(`Reviews client configured with base URL: ${this.baseUrl}`);
} else {
this.logger.warn('Reviews client has no base URL configured - review stats will be unavailable');
}
}
/**
* Get provider review statistics via internal API.
* Used by RankingService for search score calculation.
*/
async getProviderStats(providerId: string): Promise<ProviderReviewStatsResponse> {
// Check cache
const cached = this.statsCache.get(providerId);
if (cached && Date.now() < cached.expiresAt) {
return cached.data;
}
if (!this.baseUrl) {
if (cached) return cached.data;
return this.emptyStats(providerId);
}
try {
const response = await firstValueFrom(
this.httpService.get<ProviderReviewStatsResponse>(
`${this.baseUrl}/api/reviews/internal/provider-stats/${providerId}`,
),
);
this.statsCache.set(providerId, {
data: response.data,
expiresAt: Date.now() + this.CACHE_TTL_MS,
});
return response.data;
} catch (error) {
const axiosError = error as AxiosError;
this.logger.warn(
`Failed to fetch provider stats for ${providerId}: ${axiosError.message}`,
);
// Return stale cache if available
if (cached) {
this.logger.debug(`Returning stale cache for provider stats ${providerId}`);
return cached.data;
}
return this.emptyStats(providerId);
}
}
/**
* Get provider review summary (stats + recent reviews) via internal API.
*/
async getProviderSummary(providerId: string): Promise<ProviderReviewSummary | null> {
if (!this.baseUrl) {
return null;
}
try {
const response = await firstValueFrom(
this.httpService.get<ProviderReviewSummary>(
`${this.baseUrl}/api/reviews/internal/provider/${providerId}/summary`,
),
);
return response.data;
} catch (error) {
const axiosError = error as AxiosError;
this.logger.warn(
`Failed to fetch provider summary for ${providerId}: ${axiosError.message}`,
);
return null;
}
}
/**
* Invalidate cached stats for a provider.
*/
invalidateStats(providerId: string): void {
this.statsCache.delete(providerId);
}
private emptyStats(providerId: string): ProviderReviewStatsResponse {
return {
providerId,
totalReviews: 0,
averageRating: 0,
verifiedReviews: 0,
ratingDistribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 },
};
}
}

View file

@ -10,7 +10,7 @@ import { RankingService, ImpressionTrackingService, SearchRegionFilterService, A
import { FmtyModule } from '@/fmty';
import { RegionsModule } from '@/regions/regions.module';
import { ReviewsModule } from '@/reviews/reviews.module';
import { ReviewsClientModule } from '@/reviews-client';
import { TiersModule } from '@/tiers/tiers.module';
import { UsageModule } from '@/usage/usage.module';
@ -28,7 +28,7 @@ import { UsageModule } from '@/usage/usage.module';
TypeOrmModule.forFeature([ProfileRanking, SearchImpression]),
TiersModule,
UsageModule,
ReviewsModule,
ReviewsClientModule,
forwardRef(() => RegionsModule),
FmtyModule,
],

View file

@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThan } from 'typeorm';
import { ProfileRanking } from '@/entities';
import { ProviderReviewsService } from '@/reviews/services';
import { ReviewsClientService } from '@/reviews-client';
/**
* Transparent ranking algorithm weights
@ -82,7 +82,7 @@ export class RankingService {
constructor(
@InjectRepository(ProfileRanking)
private readonly rankingRepository: Repository<ProfileRanking>,
private readonly providerReviewsService: ProviderReviewsService,
private readonly reviewsClient: ReviewsClientService,
) {}
/**
@ -144,7 +144,7 @@ export class RankingService {
*/
private async getRatingScore(providerId: string): Promise<number> {
try {
const stats = await this.providerReviewsService.getProviderStats(providerId);
const stats = await this.reviewsClient.getProviderStats(providerId);
// If no reviews yet, return default midpoint score
if (stats.totalReviews === 0) {
@ -364,7 +364,7 @@ export class RankingService {
// Get review stats for transparency
let reviewStats: { totalReviews: number; averageRating: number } | undefined;
try {
const stats = await this.providerReviewsService.getProviderStats(providerId);
const stats = await this.reviewsClient.getProviderStats(providerId);
reviewStats = {
totalReviews: stats.totalReviews,
averageRating: stats.averageRating,