Files
goodgo-platform/docs/audits/AGENT_PROFILE_CODE_EXAMPLES.md
Ho Ngoc Hai 11f2bf26e6
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 29s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m42s
Deploy / Build Web Image (push) Failing after 27s
Deploy / Build AI Services Image (push) Failing after 29s
E2E Tests / Playwright E2E (push) Failing after 43s
Deploy / Build API Image (push) Failing after 1m31s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Security Scanning / Trivy Scan — API Image (push) Failing after 5m35s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 3m45s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — Web Image (push) Failing after 13m51s
Security Scanning / Trivy Filesystem Scan (push) Failing after 14m46s
Security Scanning / Security Gate (push) Has been cancelled
chore: update project documentation, audit reports, and initialize IDE configuration files
2026-04-19 03:12:54 +07:00

723 lines
21 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Trang Hồ Sơ Công Khai Môi Giới — Ví Dụ Mã Nguồn & Mẫu Triển Khai
## 1⃣ BACKEND: Tạo Endpoint API
### File: `apps/api/src/modules/agents/application/queries/get-agent-profile/get-agent-profile.query.ts`
```typescript
export class GetAgentProfileQuery {
constructor(public readonly agentId: string) {}
}
```
### File: `apps/api/src/modules/agents/application/queries/get-agent-profile/get-agent-profile.handler.ts`
```typescript
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared';
import {
AGENT_REPOSITORY,
type IAgentRepository,
} from '../../../domain/repositories/agent.repository';
import { GetAgentProfileQuery } from './get-agent-profile.query';
@QueryHandler(GetAgentProfileQuery)
export class GetAgentProfileHandler implements IQueryHandler<GetAgentProfileQuery> {
constructor(
@Inject(AGENT_REPOSITORY)
private readonly agentRepo: IAgentRepository,
) {}
async execute(query: GetAgentProfileQuery) {
const agent = await this.agentRepo.findById(query.agentId);
if (!agent) {
throw new NotFoundException('Agent not found');
}
return this.agentRepo.getPublicProfile(query.agentId);
}
}
```
### File: `apps/api/src/modules/agents/presentation/dto/agent-public-profile.dto.ts`
```typescript
import { ApiProperty } from '@nestjs/swagger';
export class AgentPublicProfileDto {
@ApiProperty()
id: string;
@ApiProperty()
fullName: string;
@ApiProperty({ nullable: true })
avatarUrl: string | null;
@ApiProperty({ nullable: true })
licenseNumber: string | null;
@ApiProperty({ nullable: true })
agency: string | null;
@ApiProperty()
qualityScore: number;
@ApiProperty({ nullable: true })
bio: string | null;
@ApiProperty({ type: [String] })
serviceAreas: string[];
@ApiProperty()
isVerified: boolean;
@ApiProperty()
totalListings: number;
@ApiProperty()
activeListings: number;
@ApiProperty()
avgReviewRating: number;
@ApiProperty()
totalReviews: number;
@ApiProperty()
createdAt: string;
@ApiProperty()
updatedAt: string;
}
```
### File: Cập nhật `apps/api/src/modules/agents/presentation/controllers/agents.controller.ts`
```typescript
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
import { JwtAuthGuard, RolesGuard, Roles } from '@modules/auth';
import { GetAgentProfileQuery } from '../../application/queries/get-agent-profile/get-agent-profile.query';
import { AgentPublicProfileDto } from '../dto/agent-public-profile.dto';
@ApiTags('agents')
@Controller('agents')
export class AgentsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
// ── Endpoint công khai ────────────────────────────────────────
@ApiOperation({ summary: 'Get public agent profile' })
@ApiParam({ name: 'agentId', description: 'Agent ID' })
@ApiResponse({ status: 200, description: 'Agent profile', type: AgentPublicProfileDto })
@ApiResponse({ status: 404, description: 'Agent not found' })
@Get(':agentId/profile')
async getPublicProfile(@Param('agentId') agentId: string): Promise<AgentPublicProfileDto> {
return this.queryBus.execute(new GetAgentProfileQuery(agentId));
}
// ── Các endpoint hiện có (không thay đổi) ─────────────────────────
// ... phần còn lại của controller
}
```
### File: Cập nhật `apps/api/src/modules/agents/domain/repositories/agent.repository.ts`
```typescript
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>;
// PHƯƠNG THỨC MỚI:
getPublicProfile(agentId: string): Promise<AgentPublicProfileDto>;
}
```
### File: Cập nhật `apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts`
```typescript
async getPublicProfile(agentId: string) {
const agent = await this.prisma.agent.findUnique({
where: { id: agentId },
include: {
user: {
select: {
fullName: true,
avatarUrl: true,
email: true,
phone: true,
},
},
},
});
if (!agent) return null;
// Lấy thống kê song song
const [totalListings, activeListings, reviewStats] = await Promise.all([
this.prisma.listing.count({
where: { agentId },
}),
this.prisma.listing.count({
where: { agentId, status: 'ACTIVE' },
}),
this.prisma.review.aggregate({
where: { targetId: agentId, targetType: 'AGENT' },
_avg: { rating: true },
_count: true,
}),
]);
return {
id: agent.id,
fullName: agent.user.fullName,
avatarUrl: agent.user.avatarUrl,
licenseNumber: agent.licenseNumber,
agency: agent.agency,
qualityScore: agent.qualityScore,
bio: agent.bio,
serviceAreas: agent.serviceAreas as string[],
isVerified: agent.isVerified,
totalListings,
activeListings,
avgReviewRating: reviewStats._avg.rating ?? 0,
totalReviews: reviewStats._count,
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
};
}
```
---
## 2⃣ FRONTEND: API Client
### File: `apps/web/lib/agents-api.ts`
```typescript
import { apiClient } from './api-client';
export interface AgentPublicProfile {
id: string;
fullName: string;
avatarUrl: string | null;
licenseNumber: string | null;
agency: string | null;
qualityScore: number;
bio: string | null;
serviceAreas: string[];
isVerified: boolean;
totalListings: number;
activeListings: number;
avgReviewRating: number;
totalReviews: number;
createdAt: string;
updatedAt: string;
}
export const agentsApi = {
getById: (id: string) =>
apiClient.get<AgentPublicProfile>(`/agents/${id}/profile`),
};
```
### File: `apps/web/lib/agents-server.ts`
```typescript
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
export async function fetchAgentById(id: string) {
try {
const res = await fetch(`${API_BASE_URL}/agents/${id}/profile`, {
next: { revalidate: 3600 }, // ISR: làm mới mỗi 1 giờ
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
```
---
## 3⃣ FRONTEND: Server Component (Trang)
### File: `apps/web/app/[locale]/(public)/agents/[id]/page.tsx`
```typescript
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { AgentDetailClient } from '@/components/agents/agent-detail-client';
import {
JsonLd,
generateBreadcrumbJsonLd,
} from '@/components/seo/json-ld';
import { fetchAgentById } from '@/lib/agents-server';
const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn';
interface PageProps {
params: { locale: string; id: string };
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const agent = await fetchAgentById(params.id);
if (!agent) {
return { title: 'Agent not found' };
}
const title = `${agent.fullName} — Real Estate Agent at GoodGo`;
const description = [
agent.bio || 'Real estate agent',
`Quality Score: ${agent.qualityScore}/100`,
`${agent.activeListings} active listings`,
`${agent.avgReviewRating} (${agent.totalReviews} reviews)`,
]
.filter(Boolean)
.join(' • ');
const canonicalUrl = `${siteUrl}/${params.locale}/agents/${params.id}`;
return {
title,
description,
alternates: {
canonical: canonicalUrl,
languages: {
vi: `${siteUrl}/vi/agents/${params.id}`,
en: `${siteUrl}/en/agents/${params.id}`,
},
},
openGraph: {
type: 'profile',
locale: params.locale === 'vi' ? 'vi_VN' : 'en_US',
url: canonicalUrl,
title,
description,
siteName: 'GoodGo',
images: agent.avatarUrl
? [{ url: agent.avatarUrl, width: 200, height: 200, alt: agent.fullName }]
: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'GoodGo' }],
},
twitter: {
card: 'summary',
title,
description,
images: agent.avatarUrl ? [agent.avatarUrl] : ['/og-image.png'],
},
};
}
export default async function AgentProfilePage({ params }: PageProps) {
const agent = await fetchAgentById(params.id);
if (!agent) {
notFound();
}
const agentJsonLd = {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: agent.fullName,
description: agent.bio,
image: agent.avatarUrl,
url: `${siteUrl}/${params.locale}/agents/${params.id}`,
...(agent.serviceAreas.length > 0 && {
areaServed: agent.serviceAreas.map((area) => ({
'@type': 'Place',
name: area,
})),
}),
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: agent.avgReviewRating,
reviewCount: agent.totalReviews,
},
};
const breadcrumbJsonLd = generateBreadcrumbJsonLd([
{ name: 'Home', url: siteUrl },
{ name: 'Agents', url: `${siteUrl}/agents` },
{ name: agent.fullName, url: `${siteUrl}/${params.locale}/agents/${params.id}` },
]);
return (
<>
<JsonLd data={agentJsonLd} />
<JsonLd data={breadcrumbJsonLd} />
<AgentDetailClient agent={agent} />
</>
);
}
```
---
## 4⃣ FRONTEND: Các Client Component
### File: `apps/web/components/agents/agent-detail-client.tsx`
```typescript
'use client';
import * as React from 'react';
import { AgentDetailClient as AgentDetailHeader } from './agent-header';
import { AgentListingsSection } from './agent-listings-section';
import { AgentReviewsSection } from './agent-reviews-section';
import type { AgentPublicProfile } from '@/lib/agents-api';
interface AgentDetailClientProps {
agent: AgentPublicProfile;
}
export function AgentDetailClient({ agent }: AgentDetailClientProps) {
return (
<main>
<AgentDetailHeader agent={agent} />
<AgentReviewsSection agentId={agent.id} />
<AgentListingsSection agentId={agent.id} />
</main>
);
}
```
### File: `apps/web/components/agents/agent-header.tsx`
```typescript
'use client';
import Image from 'next/image';
import * as React from 'react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import type { AgentPublicProfile } from '@/lib/agents-api';
interface AgentDetailClientProps {
agent: AgentPublicProfile;
}
export function AgentDetailClient({ agent }: AgentDetailClientProps) {
return (
<section className="py-16 md:py-24">
<div className="mx-auto max-w-7xl px-4">
<Card>
<CardContent className="p-6 md:p-8">
<div className="flex flex-col gap-6 md:flex-row">
{/* Ảnh đại diện */}
<div className="flex-shrink-0">
{agent.avatarUrl ? (
<Image
src={agent.avatarUrl}
alt={agent.fullName}
width={120}
height={120}
className="h-28 w-28 rounded-lg object-cover"
/>
) : (
<div className="h-28 w-28 rounded-lg bg-muted flex items-center justify-center text-muted-foreground">
No photo
</div>
)}
</div>
{/* Thông tin */}
<div className="flex-1">
<h1 className="text-3xl font-bold">{agent.fullName}</h1>
{/* Huy hiệu */}
<div className="mt-3 flex flex-wrap gap-2">
{agent.isVerified && (
<Badge variant="default"> Verified</Badge>
)}
<Badge variant="secondary">
{agent.qualityScore.toFixed(1)}/100
</Badge>
</div>
{/* Chi tiết */}
<dl className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-3">
{agent.licenseNumber && (
<>
<dt className="text-sm font-medium text-muted-foreground">License</dt>
<dd className="text-sm font-semibold">{agent.licenseNumber}</dd>
</>
)}
{agent.agency && (
<>
<dt className="text-sm font-medium text-muted-foreground">Agency</dt>
<dd className="text-sm font-semibold">{agent.agency}</dd>
</>
)}
<div>
<dt className="text-sm font-medium text-muted-foreground">Listings</dt>
<dd className="text-sm font-semibold">{agent.activeListings} active</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Reviews</dt>
<dd className="text-sm font-semibold">
{agent.avgReviewRating.toFixed(1)} ({agent.totalReviews})
</dd>
</div>
</dl>
{/* Tiểu sử */}
{agent.bio && (
<p className="mt-4 text-sm text-muted-foreground">{agent.bio}</p>
)}
{/* Khu vực phục vụ */}
{agent.serviceAreas.length > 0 && (
<div className="mt-4">
<p className="text-xs font-medium text-muted-foreground">Serves:</p>
<div className="mt-1 flex flex-wrap gap-2">
{agent.serviceAreas.map((area) => (
<Badge key={area} variant="outline">
📍 {area}
</Badge>
))}
</div>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
</section>
);
}
```
### File: `apps/web/components/agents/agent-listings-section.tsx`
```typescript
'use client';
import * as React from 'react';
import { PropertyCard } from '@/components/search/property-card';
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
interface AgentListingsSectionProps {
agentId: string;
}
export function AgentListingsSection({ agentId }: AgentListingsSectionProps) {
const [listings, setListings] = React.useState<ListingDetail[]>([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
listingsApi
.search({ agentId, status: 'ACTIVE', limit: 12 })
.then((res) => setListings(res.data))
.finally(() => setLoading(false));
}, [agentId]);
return (
<section className="py-16 md:py-24">
<div className="mx-auto max-w-7xl px-4">
<h2 className="text-2xl font-bold">Active Listings ({listings.length})</h2>
<p className="mt-2 text-muted-foreground">
{listings.length === 0 ? 'No active listings' : 'Browse properties from this agent'}
</p>
{loading ? (
<div className="mt-8 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-80 rounded-lg bg-muted animate-pulse"
/>
))}
</div>
) : listings.length > 0 ? (
<div className="mt-8 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{listings.map((listing) => (
<PropertyCard key={listing.id} listing={listing} />
))}
</div>
) : (
<div className="mt-8 text-center text-muted-foreground">
No active listings available
</div>
)}
</div>
</section>
);
}
```
### File: `apps/web/components/agents/agent-reviews-section.tsx`
```typescript
'use client';
import * as React from 'react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { apiClient } from '@/lib/api-client';
import type { ListingDetail } from '@/lib/listings-api';
interface ReviewItem {
id: string;
rating: number;
comment: string | null;
createdAt: string;
user: {
fullName: string;
avatarUrl: string | null;
};
}
interface ReviewStats {
averageRating: number;
totalReviews: number;
}
interface AgentReviewsSectionProps {
agentId: string;
}
export function AgentReviewsSection({ agentId }: AgentReviewsSectionProps) {
const [reviews, setReviews] = React.useState<ReviewItem[]>([]);
const [stats, setStats] = React.useState<ReviewStats | null>(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
Promise.all([
apiClient.get(`/reviews?targetType=AGENT&targetId=${agentId}&limit=10`),
apiClient.get(`/reviews/stats?targetType=AGENT&targetId=${agentId}`),
])
.then(([reviewsRes, statsRes]) => {
setReviews(reviewsRes.data);
setStats(statsRes);
})
.finally(() => setLoading(false));
}, [agentId]);
if (loading) return <div className="py-16 text-center text-muted-foreground">Loading reviews...</div>;
return (
<section className="py-16 md:py-24 bg-muted/50">
<div className="mx-auto max-w-7xl px-4">
<div className="mb-8">
<h2 className="text-2xl font-bold">Đánh Giá Của Khách Hàng</h2>
{stats && (
<div className="mt-4 flex items-center gap-4">
<div>
<div className="text-3xl font-bold">{stats.averageRating.toFixed(1)}</div>
<div className="text-sm text-muted-foreground">trên 5,0</div>
</div>
<div className="text-sm text-muted-foreground">
Dựa trên {stats.totalReviews} đánh giá
</div>
</div>
)}
</div>
{reviews.length > 0 ? (
<div className="space-y-4">
{reviews.map((review) => (
<Card key={review.id}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div>
<div className="font-semibold">{review.user.fullName}</div>
<div className="flex gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className="text-lg">
{i < review.rating ? '⭐' : '☆'}
</span>
))}
</div>
</div>
<div className="text-xs text-muted-foreground">
{new Date(review.createdAt).toLocaleDateString()}
</div>
</div>
{review.comment && (
<p className="mt-2 text-sm text-muted-foreground">{review.comment}</p>
)}
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center text-muted-foreground">
Chưa đánh giá
</div>
)}
</div>
</section>
);
}
```
---
## 5⃣ THAM KHẢO GIAO DIỆN
Tất cả các component đều sử dụng:
- Các class **Tailwind CSS** trực tiếp (không dùng CSS module)
- **Breakpoint responsive**: `md:`, `lg:`
- **Chế độ tối**: Dùng biến CSS trong `globals.css`
- **Mẫu component**: Card → CardContent
### Các mẫu khoảng cách thông dụng:
```typescript
// Các phần
<section className="py-16 md:py-24">
<div className="mx-auto max-w-7xl px-4">
{/* nội dung */}
</div>
</section>
// Card
<Card>
<CardContent className="p-4 md:p-6">
{/* nội dung */}
</CardContent>
</Card>
// Lưới
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* các phần tử */}
</div>
```
---
## 🧪 Danh Sách Kiểm Thử
```bash
# Kiểm thử API Backend
curl http://localhost:3001/api/v1/agents/[valid-agent-id]/profile
# Phản hồi kỳ vọng
{
"id": "...",
"fullName": "...",
"qualityScore": 4.5,
"totalListings": 45,
"activeListings": 32,
"avgReviewRating": 4.7,
"totalReviews": 120,
...
}
```
```typescript
// Kiểm thử Frontend
import { agentsApi } from '@/lib/agents-api';
const agent = await agentsApi.getById('agent-id-here');
console.log(agent); // Phải khớp với cấu trúc trên
```