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
21 KiB
21 KiB
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
export class GetAgentProfileQuery {
constructor(public readonly agentId: string) {}
}
File: apps/api/src/modules/agents/application/queries/get-agent-profile/get-agent-profile.handler.ts
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
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
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
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
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
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
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
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
'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
'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
'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
'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 có đá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:
// 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ử
# 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,
...
}
// 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