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:
parent
4a200055b6
commit
ec406a5290
12 changed files with 273 additions and 33 deletions
37
features/client-intel/backend-api/src/main.ts
Normal file
37
features/client-intel/backend-api/src/main.ts
Normal 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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
export { ReviewsClientModule } from './reviews-client.module';
|
||||
export { ReviewsClientService } from './reviews-client.service';
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue