feat(agents): add public agent profile page at /agents/[id]
Implements a public-facing agent profile page with: - Backend: new GET /agents/:agentId/profile public API endpoint with agent info, active listings, quality score, and review stats - Frontend: server-rendered profile page with generateMetadata for SEO, JSON-LD structured data (RealEstateAgent schema), breadcrumbs - Agent profile displays bio, service areas, quality score gauge, active listing cards, reviews with star ratings, and contact CTA - Mobile responsive layout with sticky contact sidebar on desktop - Vietnamese UI text throughout, consistent with existing patterns Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -3,13 +3,14 @@ import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { RecalculateQualityScoreHandler } from './application/commands/recalculate-quality-score/recalculate-quality-score.handler';
|
||||
import { ReviewEventsListener } from './application/listeners/review-events.listener';
|
||||
import { GetAgentDashboardHandler } from './application/queries/get-agent-dashboard/get-agent-dashboard.handler';
|
||||
import { GetAgentPublicProfileHandler } from './application/queries/get-agent-public-profile/get-agent-public-profile.handler';
|
||||
import { AGENT_REPOSITORY } from './domain/repositories/agent.repository';
|
||||
import { PrismaAgentRepository } from './infrastructure/repositories/prisma-agent.repository';
|
||||
import { AgentsController } from './presentation/controllers/agents.controller';
|
||||
|
||||
const CommandHandlers = [RecalculateQualityScoreHandler];
|
||||
|
||||
const QueryHandlers = [GetAgentDashboardHandler];
|
||||
const QueryHandlers = [GetAgentDashboardHandler, GetAgentPublicProfileHandler];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule],
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
AGENT_REPOSITORY,
|
||||
type AgentPublicProfileData,
|
||||
type IAgentRepository,
|
||||
} from '../../../domain/repositories/agent.repository';
|
||||
import { GetAgentPublicProfileQuery } from './get-agent-public-profile.query';
|
||||
|
||||
@QueryHandler(GetAgentPublicProfileQuery)
|
||||
export class GetAgentPublicProfileHandler
|
||||
implements IQueryHandler<GetAgentPublicProfileQuery>
|
||||
{
|
||||
constructor(
|
||||
@Inject(AGENT_REPOSITORY)
|
||||
private readonly agentRepo: IAgentRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetAgentPublicProfileQuery,
|
||||
): Promise<AgentPublicProfileData | null> {
|
||||
return this.agentRepo.getPublicProfile(query.agentId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class GetAgentPublicProfileQuery {
|
||||
constructor(public readonly agentId: string) {}
|
||||
}
|
||||
@@ -17,9 +17,48 @@ export interface AgentDashboardData {
|
||||
totalReviews: number;
|
||||
}
|
||||
|
||||
export interface AgentPublicListingItem {
|
||||
id: string;
|
||||
transactionType: string;
|
||||
priceVND: string;
|
||||
status: string;
|
||||
property: {
|
||||
id: string;
|
||||
title: string;
|
||||
propertyType: string;
|
||||
address: string;
|
||||
district: string;
|
||||
city: string;
|
||||
areaM2: number;
|
||||
bedrooms: number | null;
|
||||
bathrooms: number | null;
|
||||
imageUrl: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentPublicProfileData {
|
||||
id: string;
|
||||
fullName: string;
|
||||
avatarUrl: string | null;
|
||||
phone: string;
|
||||
email: string | null;
|
||||
agency: string | null;
|
||||
licenseNumber: string | null;
|
||||
bio: string | null;
|
||||
qualityScore: number;
|
||||
totalDeals: number;
|
||||
isVerified: boolean;
|
||||
serviceAreas: string[];
|
||||
memberSince: string;
|
||||
activeListings: AgentPublicListingItem[];
|
||||
avgReviewRating: number;
|
||||
totalReviews: number;
|
||||
}
|
||||
|
||||
export interface IAgentRepository {
|
||||
findByUserId(userId: string): Promise<{ id: string; userId: string; qualityScore: number } | null>;
|
||||
findById(agentId: string): Promise<{ id: string; userId: string; qualityScore: number } | null>;
|
||||
updateQualityScore(agentId: string, score: number): Promise<void>;
|
||||
getDashboard(agentId: string): Promise<AgentDashboardData>;
|
||||
getPublicProfile(agentId: string): Promise<AgentPublicProfileData | null>;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
type AgentDashboardData,
|
||||
type AgentPublicProfileData,
|
||||
type AgentPublicListingItem,
|
||||
type IAgentRepository,
|
||||
} from '../../domain/repositories/agent.repository';
|
||||
|
||||
@@ -119,4 +121,105 @@ export class PrismaAgentRepository implements IAgentRepository {
|
||||
]);
|
||||
return { total, active };
|
||||
}
|
||||
|
||||
async getPublicProfile(agentId: string): Promise<AgentPublicProfileData | null> {
|
||||
const agent = await this.prisma.agent.findUnique({
|
||||
where: { id: agentId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
fullName: true,
|
||||
avatarUrl: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!agent) return null;
|
||||
|
||||
const [listings, reviewStats] = await Promise.all([
|
||||
this.getActiveListingsForAgent(agentId),
|
||||
this.prisma.review.aggregate({
|
||||
where: { targetType: 'AGENT', targetId: agentId },
|
||||
_avg: { rating: true },
|
||||
_count: { rating: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
fullName: agent.user.fullName,
|
||||
avatarUrl: agent.user.avatarUrl,
|
||||
phone: agent.user.phone,
|
||||
email: agent.user.email,
|
||||
agency: agent.agency,
|
||||
licenseNumber: agent.licenseNumber,
|
||||
bio: agent.bio,
|
||||
qualityScore: agent.qualityScore,
|
||||
totalDeals: agent.totalDeals,
|
||||
isVerified: agent.isVerified,
|
||||
serviceAreas: (agent.serviceAreas as string[]) ?? [],
|
||||
memberSince: agent.createdAt.toISOString(),
|
||||
activeListings: listings,
|
||||
avgReviewRating: Math.round((reviewStats._avg.rating ?? 0) * 10) / 10,
|
||||
totalReviews: reviewStats._count.rating,
|
||||
};
|
||||
}
|
||||
|
||||
private async getActiveListingsForAgent(
|
||||
agentId: string,
|
||||
): Promise<AgentPublicListingItem[]> {
|
||||
const listings = await this.prisma.listing.findMany({
|
||||
where: { agentId, status: 'ACTIVE' },
|
||||
take: 12,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
transactionType: true,
|
||||
priceVND: true,
|
||||
status: true,
|
||||
property: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
propertyType: true,
|
||||
address: true,
|
||||
district: true,
|
||||
city: true,
|
||||
areaM2: true,
|
||||
bedrooms: true,
|
||||
bathrooms: true,
|
||||
media: {
|
||||
where: { type: 'image' },
|
||||
take: 1,
|
||||
orderBy: { order: 'asc' },
|
||||
select: { url: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return listings.map((l) => ({
|
||||
id: l.id,
|
||||
transactionType: l.transactionType,
|
||||
priceVND: l.priceVND.toString(),
|
||||
status: l.status,
|
||||
property: {
|
||||
id: l.property.id,
|
||||
title: l.property.title,
|
||||
propertyType: l.property.propertyType,
|
||||
address: l.property.address,
|
||||
district: l.property.district,
|
||||
city: l.property.city,
|
||||
areaM2: l.property.areaM2,
|
||||
bedrooms: l.property.bedrooms,
|
||||
bathrooms: l.property.bathrooms,
|
||||
imageUrl: l.property.media[0]?.url ?? null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, NotFoundException, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
} from '@modules/auth';
|
||||
import { RecalculateQualityScoreCommand } from '../../application/commands/recalculate-quality-score/recalculate-quality-score.command';
|
||||
import { GetAgentDashboardQuery } from '../../application/queries/get-agent-dashboard/get-agent-dashboard.query';
|
||||
import { type AgentDashboardData } from '../../domain/repositories/agent.repository';
|
||||
import { GetAgentPublicProfileQuery } from '../../application/queries/get-agent-public-profile/get-agent-public-profile.query';
|
||||
import { type AgentDashboardData, type AgentPublicProfileData } from '../../domain/repositories/agent.repository';
|
||||
|
||||
@ApiTags('agents')
|
||||
@Controller('agents')
|
||||
@@ -41,6 +42,23 @@ export class AgentsController {
|
||||
return this.queryBus.execute(new GetAgentDashboardQuery(user.sub));
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Get public agent profile' })
|
||||
@ApiParam({ name: 'agentId', description: 'Agent ID' })
|
||||
@ApiResponse({ status: 200, description: 'Agent public profile data' })
|
||||
@ApiResponse({ status: 404, description: 'Agent not found' })
|
||||
@Get(':agentId/profile')
|
||||
async getPublicProfile(
|
||||
@Param('agentId') agentId: string,
|
||||
): Promise<AgentPublicProfileData> {
|
||||
const profile = await this.queryBus.execute(
|
||||
new GetAgentPublicProfileQuery(agentId),
|
||||
);
|
||||
if (!profile) {
|
||||
throw new NotFoundException('Không tìm thấy môi giới');
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Recalculate quality score (admin/system)' })
|
||||
@ApiParam({ name: 'agentId', description: 'Agent ID' })
|
||||
|
||||
Reference in New Issue
Block a user