Files
goodgo-platform/docs/audits/AGENT_PROFILE_EXPLORATION.md
Ho Ngoc Hai e78d706b42 chore: update infrastructure configs, audit docs, and env template
- Update Docker Compose configs for Redis, Typesense, and MinIO services
- Update GitHub Actions deploy workflow with improved caching and steps
- Extend .env.example with Stringee, Zalo OA, and FCM config keys
- Update audit documentation with latest findings and recommendations
- Update CHANGELOG and README with recent feature additions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 05:17:38 +07:00

21 KiB

GoodGo Agent Public Profile Page — Comprehensive Exploration Report

Date: April 11, 2026
Scope: Full stack exploration for implementing /agents/[id] public profile page
Codebase: GoodGo Platform (Next.js 15 + NestJS 10 + PostgreSQL + Prisma)


1. WEB APP STRUCTURE & ROUTING

File Structure

apps/web/
├── app/                          # Next.js 15 App Router (Server Components)
│   ├── [locale]/                 # Internationalization (i18n) at root level
│   │   ├── (admin)/              # Admin routes (protected)
│   │   ├── (auth)/               # Auth routes (sign-in, etc.)
│   │   ├── (dashboard)/          # Authenticated user dashboard
│   │   └── (public)/             # Public-facing routes
│   │       ├── listings/[id]/    # Existing listing detail page pattern
│   │       ├── search/
│   │       ├── compare/
│   │       ├── pricing/
│   │       └── page.tsx          # Landing page
│   ├── layout.tsx                # Root layout
│   ├── robots.ts                 # SEO: robots.txt
│   └── sitemap.ts                # SEO: sitemap.xml
├── components/
│   ├── ui/                       # UI library (button, card, badge, input, etc.)
│   ├── listings/                 # Listing-specific components
│   ├── search/                   # Search & property card components
│   ├── seo/                      # JSON-LD, structured data
│   └── providers/                # Context providers
├── lib/
│   ├── api-client.ts             # Fetch wrapper with CSRF protection
│   ├── listings-api.ts           # API client for listings
│   ├── profile-api.ts            # Auth/agent profile API client
│   ├── listings-server.ts        # Server-side data fetching
│   ├── currency.ts               # Currency formatting utilities
│   └── validations/              # Zod schemas (listings, auth, etc.)
└── public/                       # Static assets

Routing Patterns

Public Routes (under (public)):

  • / → Home/landing page
  • /listings/[id] → Listing detail (EXISTING PATTERN)
  • /search → Search results page
  • /compare → Property comparison page
  • /pricing → Pricing page

Key Insight: The (public) route group is for unauthenticated users. Agent profiles should follow the same pattern: /agents/[id] under the (public) group.


Agent Profile Type (Frontend)

File: apps/web/lib/profile-api.ts

export interface AgentProfile {
  id: string;
  email: string | null;
  phone: string;
  fullName: string;
  avatarUrl: string | null;
  role: string;
  kycStatus: string;
  isActive: boolean;
  createdAt: string;
  licenseNumber: string | null;
  agency: string | null;
  qualityScore: number | null;
  serviceAreas: string[];
  isVerified: boolean;
}

Existing Agent API Endpoints (Backend)

File: apps/api/src/modules/agents/presentation/controllers/agents.controller.ts

// Endpoints:
GET    /agents/me/dashboard          # Agent dashboard (authenticated)
POST   /agents/:agentId/recalculate-score  # Recalculate quality score (admin)

// Returns:
interface AgentDashboardData {
  agentId: string;
  qualityScore: number;
  totalDeals: number;
  responseTimeAvg: number | null;
  isVerified: boolean;
  totalLeads: number;
  leadsByStatus: Record<string, number>;
  conversionRate: number;
  totalInquiries: number;
  unreadInquiries: number;
  totalListings: number;
  activeListings: number;
  avgReviewRating: number;
  totalReviews: number;
}

Prisma Schema — Agent Model

File: prisma/schema.prisma

model Agent {
  id              String   @id @default(cuid())
  userId          String   @unique
  user            User     @relation(fields: [userId], references: [id])
  licenseNumber   String?
  agency          String?
  qualityScore    Float    @default(0)
  totalDeals      Int      @default(0)
  responseTimeAvg Int?
  bio             String?
  serviceAreas    Json     // JSON array: ["quan-1", "quan-7", "thu-duc"]
  isVerified      Boolean  @default(false)
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  listings Listing[]
  leads    Lead[]

  @@index([qualityScore])
  @@index([isVerified])
}

Related models:

  • User — Agent's user account (fullName, avatarUrl, phone, email, role, kycStatus)
  • Listing — Properties agent represents (has agentId foreign key)
  • Lead — Leads tracked by agent

3. AGENT API ENDPOINTS NEEDED FOR PUBLIC PROFILE

Based on the existing architecture, we need to create a public endpoint to fetch agent profile data:

Proposed Endpoint

GET /agents/:agentId/profile

Response Structure (Public Profile DTO):

interface AgentPublicProfile {
  id: string;
  fullName: string;
  avatarUrl: string | null;
  
  // Agent-specific fields
  licenseNumber: string | null;
  agency: string | null;
  qualityScore: number;
  bio: string | null;
  serviceAreas: string[];
  isVerified: boolean;
  
  // Stats
  totalListings: number;
  activeListings: number;
  avgReviewRating: number;
  totalReviews: number;
  
  // Contact (optional, may require user preferences)
  phone?: string;
  
  // Timestamps
  createdAt: string;
  updatedAt: string;
}

Related Endpoints Needed:

GET /listings?agentId=:agentId&status=ACTIVE     # Agent's active listings
GET /reviews/stats?targetType=AGENT&targetId=:agentId  # Agent reviews stats
GET /reviews?targetType=AGENT&targetId=:agentId&limit=10  # Recent agent reviews

These endpoints already exist and are public (no authentication required).


4. SHARED UI COMPONENTS & DESIGN PATTERNS

Tailwind/Design System

File: apps/web/tailwind.config.ts

// CSS Variables used (dark mode support)
colors: {
  primary, primary-foreground
  secondary, secondary-foreground
  destructive, destructive-foreground
  muted, muted-foreground
  accent, accent-foreground
  card, card-foreground
}

// Radius variable: var(--radius)
borderRadius: {
  lg: 'var(--radius)',
  md: 'calc(var(--radius) - 2px)',
  sm: 'calc(var(--radius) - 4px)',
}

Available UI Components

File: apps/web/components/ui/

  • button.tsx — Styled button with variants
  • card.tsx — Card (CardContent, etc.)
  • badge.tsx — Badge with variants
  • input.tsx, label.tsx — Form controls
  • dialog.tsx — Modal dialog
  • tabs.tsx — Tab navigation
  • table.tsx — Data table

Key Component Patterns

Badge Component

<Badge variant="default">Đã xác minh</Badge>
<Badge variant="secondary">Info badge</Badge>
<Badge variant="outline">Outline badge</Badge>
<Badge variant="destructive">Danger</Badge>

Card Pattern

<Card>
  <CardContent className="p-4">
    {/* Content */}
  </CardContent>
</Card>

Property Card (Listing Display) - REUSE THIS

File: apps/web/components/search/property-card.tsx

Shows a property with:

  • Image gallery
  • Price (formatted)
  • Title & address
  • Type, area, bedrooms badges
  • Transaction type badge

Reusable for: Agent's listings display


5. STYLING & DESIGN PATTERNS

Global CSS

File: apps/web/app/globals.css

Uses CSS variables for theming:

--primary: hsl(var(--primary-h), var(--primary-s), var(--primary-l))
--background: hsl(...)
--card: hsl(...)
/* Dark mode support via [data-theme="dark"] */

Typography

  • Font: Inter (configured in tailwind.config.ts via CSS variable --font-inter)
  • Heading levels: h1, h2, h3, h4
  • Use classes: text-lg font-bold, text-sm text-muted-foreground

Spacing

  • Tailwind standard: p-4, mt-8, gap-3, etc.
  • Card padding: p-4
  • Section padding: py-16, py-24 for hero sections

Example Layout Pattern

<section className="py-16 md:py-24">
  <div className="mx-auto max-w-7xl px-4">
    {/* Content */}
  </div>
</section>

6. STATE MANAGEMENT & DATA FETCHING

API Client Pattern

File: apps/web/lib/api-client.ts

// Usage:
const apiClient = {
  get: <T>(endpoint: string, headers?: HeadersInit) => request<T>(endpoint, { method: 'GET', headers }),
  post: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) => request<T>(endpoint, { method: 'POST', body, headers }),
  patch: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) => request<T>(endpoint, { method: 'PATCH', body, headers }),
  delete: <T>(endpoint: string, headers?: HeadersInit) => request<T>(endpoint, { method: 'DELETE', headers }),
};

// CSRF protection included automatically

Server-Side Data Fetching Pattern

File: apps/web/lib/listings-server.ts

// Example: fetch on server at build time or request time
export async function fetchListingById(id: string) {
  try {
    const res = await fetch(`${API_BASE_URL}/listings/${id}`, {
      next: { revalidate: 3600 } // ISR: revalidate every 1 hour
    });
    if (!res.ok) return null;
    return res.json();
  } catch {
    return null;
  }
}

Client-Side Data Fetching Pattern

// In React component (using 'use client')
const [data, setData] = useState(null);

React.useEffect(() => {
  apiClient
    .get('/endpoint')
    .then((res) => setData(res))
    .catch(() => setError(true))
    .finally(() => setLoading(false));
}, []);

No Global State (Zustand)

  • Currently no Zustand stores for agent data in codebase
  • Pattern: Fetch data in page component → pass to child components
  • Dashboard uses local useState for profile fetch

7. SEO PATTERNS & STRUCTURED DATA

Metadata Generation Pattern

File: apps/web/app/[locale]/(public)/listings/[id]/page.tsx

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const listing = await fetchListingById(params.id);
  if (!listing) {
    return { title: 'Không tìm thấy' };
  }

  return {
    title: `${property.title} - ${formatPrice(listing.priceVND)} VND`,
    description: '...',
    alternates: {
      canonical: `${siteUrl}/${params.locale}/listings/${params.id}`,
      languages: { vi: '...', en: '...' }
    },
    openGraph: {
      type: 'article',
      locale: params.locale === 'vi' ? 'vi_VN' : 'en_US',
      url: canonicalUrl,
      title,
      images: [{ url: firstImage.url, width: 1200, height: 630 }],
    },
    twitter: {
      card: 'summary_large_image',
      title,
      images: [firstImage.url],
    },
  };
}

JSON-LD Structured Data

File: apps/web/components/seo/json-ld.tsx

// Example: RealEstateListing schema
export function generateListingJsonLd(listing, siteUrl) {
  return {
    '@context': 'https://schema.org',
    '@type': 'RealEstateListing',
    name: property.title,
    url: `${siteUrl}/listings/${listing.id}`,
    offers: { '@type': 'Offer', price: priceNum, priceCurrency: 'VND' },
    // ... more properties
  };
}

// Usage in page:
<JsonLd data={listingJsonLd} />
<JsonLd data={breadcrumbJsonLd} />

For Agent Profile (Schema.org)

Appropriate schema: LocalBusiness or ProfessionalService

{
  "@context": "https://schema.org",
  "@type": "LocalBusiness",
  "name": "Agent Full Name",
  "description": "Bio",
  "url": "https://goodgo.vn/en/agents/[id]",
  "image": "avatarUrl",
  "address": {
    "@type": "PostalAddress",
    "addressLocality": "Ho Chi Minh"
  },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": avgReviewRating,
    "reviewCount": totalReviews
  }
}

8. EXISTING LISTING CARD COMPONENTS

Property Card Component

File: apps/web/components/search/property-card.tsx

interface PropertyCardProps {
  listing: ListingDetail;
  compact?: boolean;
}

// Displays:
// - Image gallery (with count badge)
// - Price (formatted)
// - Title & address
// - Badges: Transaction type, property type, area, bedrooms, bathrooms, direction
// - Compare button

Used in:

  • Home page featured listings
  • Search results
  • Comparison page

For Agent Profile: Can reuse this component to display agent's listings!


9. REVIEW & RATING COMPONENTS

Review API Endpoints

File: apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts

// Endpoints:
GET    /reviews                     # List reviews by target (pagination)
GET    /reviews/stats               # Get aggregate rating stats
GET    /reviews/me                  # Get authenticated user's reviews
POST   /reviews                     # Create review (authenticated)
DELETE /reviews/:id                 # Delete own review

// Query params:
GET /reviews?targetType=AGENT&targetId=:id&page=1&limit=20
GET /reviews/stats?targetType=AGENT&targetId=:id

Review DTO

interface ReviewItemData {
  id: string;
  userId: string;
  targetType: string;
  targetId: string;
  rating: number;        // 1-5
  comment: string | null;
  createdAt: string;
  // User info:
  user: {
    id: string;
    fullName: string;
    avatarUrl: string | null;
  };
}

interface ReviewStatsData {
  targetType: string;
  targetId: string;
  totalReviews: number;
  averageRating: number;
  ratingDistribution: {
    "1": number;
    "2": number;
    "3": number;
    "4": number;
    "5": number;
  };
}

No Review Display Component Yet

  • Dashboard profile shows agentProfile.totalReviews and avgReviewRating
  • Opportunity: Create a reusable ReviewCard and RatingStars component for agent profile

10. TYPE DEFINITIONS & INTERFACES

Core Types Used Frontend

// From listings-api.ts
export type TransactionType = 'SALE' | 'RENT';
export type PropertyType = 'APARTMENT' | 'HOUSE' | 'VILLA' | 'LAND' | 'OFFICE' | 'SHOPHOUSE';
export type ListingStatus = 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' | 'RESERVED' | 'SOLD' | 'RENTED' | 'EXPIRED' | 'REJECTED';
export type Direction = 'NORTH' | 'SOUTH' | 'EAST' | 'WEST' | 'NORTHEAST' | 'NORTHWEST' | 'SOUTHEAST' | 'SOUTHWEST';

// From profile-api.ts
export interface AgentProfile {
  id: string;
  email: string | null;
  phone: string;
  fullName: string;
  avatarUrl: string | null;
  role: string;
  kycStatus: string;
  isActive: boolean;
  createdAt: string;
  licenseNumber: string | null;
  agency: string | null;
  qualityScore: number | null;
  serviceAreas: string[];
  isVerified: boolean;
}

// From listings-api.ts
export interface ListingDetail {
  id: string;
  status: ListingStatus;
  transactionType: TransactionType;
  priceVND: string;
  publishedAt: string | null;
  property: {
    id: string;
    propertyType: PropertyType;
    title: string;
    areaM2: number;
    address: string;
    ward: string;
    district: string;
    city: string;
    media: PropertyMedia[];
    // ... 15+ other properties
  };
}

Validation Schemas (Zod)

File: apps/web/lib/validations/listings.ts

export const TRANSACTION_TYPES = [
  { value: 'SALE', label: 'Bán' },
  { value: 'RENT', label: 'Cho thuê' },
] as const;

export const PROPERTY_TYPES = [
  { value: 'APARTMENT', label: 'Căn hộ' },
  { value: 'HOUSE', label: 'Nhà riêng' },
  // ... more
] as const;

export const LISTING_STATUSES = {
  DRAFT: { label: 'Nháp', variant: 'secondary' },
  ACTIVE: { label: 'Đang bán', variant: 'success' },
  // ...
};

11. IMPLEMENTATION CHECKLIST FOR AGENT PROFILE PAGE

Backend (NestJS API)

New DTO & Interfaces:

✓ CreateGetAgentPublicProfileQuery
✓ GetAgentPublicProfileHandler
✓ AgentPublicProfileDto (response)
✓ Update agent.repository.ts with method: getPublicProfile(agentId: string)
✓ Update prisma-agent.repository.ts to fetch via Prisma

New Endpoint:

✓ GET /agents/:agentId/profile (public, no auth)
✓ Returns: AgentPublicProfileDto
✓ Validates agentId exists
✓ Returns 404 if not found

Leverage Existing:

  • Review queries: Already public
  • Listings queries: Already public
  • User profile: Linking to agent.userId

Frontend (Next.js)

New Files:

✓ apps/web/app/[locale]/(public)/agents/ (directory)
✓ apps/web/app/[locale]/(public)/agents/[id]/ (directory)
✓ apps/web/app/[locale]/(public)/agents/[id]/page.tsx (server component)
✓ apps/web/app/[locale]/(public)/agents/[id]/layout.tsx (optional)
✓ apps/web/lib/agents-api.ts (API client)
✓ apps/web/lib/agents-server.ts (server-side fetch for ISR)
✓ apps/web/components/agents/ (new directory)
✓ apps/web/components/agents/agent-detail-client.tsx (client component)
✓ apps/web/components/agents/agent-listings-section.tsx
✓ apps/web/components/agents/agent-reviews-section.tsx

Page Structure (follows listing pattern):

// [id]/page.tsx (Server Component)
export async function generateMetadata({ params }): Promise<Metadata> {
  // Fetch agent
  // Return title, description, OG image, canonical URL
}

export default async function AgentProfilePage({ params }) {
  // Fetch agent profile (server-side, ISR)
  // Render JsonLd breadcrumb
  // Render JsonLd LocalBusiness or ProfessionalService
  // Pass to client component
  return <>
    <JsonLd data={agentJsonLd} />
    <AgentDetailClient agent={agent} />
  </>
}

Client Component:

  • Display agent info (name, avatar, bio, license, agency)
  • Show quality score & badges
  • Render reviews section
  • Render listings section
  • Contact/inquiry button (optional)

API Client:

export const agentsApi = {
  getById: (id: string) => apiClient.get<AgentPublicProfile>(`/agents/${id}/profile`),
};

12. KEY FILES TO REFERENCE/ADAPT

Backend

File Purpose
apps/api/src/modules/agents/presentation/controllers/agents.controller.ts Add new public endpoint
apps/api/src/modules/agents/domain/repositories/agent.repository.ts Add interface for public profile method
apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts Implement public profile fetch
apps/api/src/modules/agents/application/queries/ Create new query handler for public profile
prisma/schema.prisma Reference for Agent model

Frontend — Reference Examples

File Purpose Reuse Pattern
apps/web/app/[locale]/(public)/listings/[id]/page.tsx Listing detail page Use as template for agent page
apps/web/components/search/property-card.tsx Property card Reuse for agent's listings
apps/web/lib/listings-api.ts Listings API client Create similar agents-api.ts
apps/web/lib/listings-server.ts Server-side fetch Create similar agents-server.ts
apps/web/components/seo/json-ld.tsx Structured data Adapt for LocalBusiness schema
apps/web/lib/currency.ts Price formatting Reuse for listing prices
apps/web/tailwind.config.ts Design system Reference for styling

13. SUMMARY OF KEY FINDINGS

What Exists (Reusable)

Agent model in Prisma with all needed fields
API endpoints for listings, reviews (public)
UI components: Card, Badge, Button, etc.
Tailwind design system with dark mode
SEO pattern with metadata generation & JSON-LD
Image gallery component for listings
PropertyCard component for listings display
API client with CSRF protection
Server-side data fetching with ISR pattern

What Needs Building (Agent Profile)

🔨 /agents/[id] page (server component with metadata)
🔨 AgentDetailClient component (client-side rendering)
🔨 Public endpoint: GET /agents/:agentId/profile
🔨 Agent listings section (reuse PropertyCard)
🔨 Agent reviews section (fetch & display reviews)
🔨 Rating stars/aggregate display component
🔨 agents-api.ts (fetch agent profile)
🔨 agents-server.ts (server-side fetch for ISR)
🔨 JSON-LD LocalBusiness schema for agent

Architecture Decisions

  • Routing: Place at apps/web/app/[locale]/(public)/agents/[id]/page.tsx
  • Pattern: Follow listing detail page pattern exactly
  • Metadata: Use generateMetadata() server function
  • Components: Split into Server Component (page) + Client Component (interactive)
  • SEO: Include breadcrumb + LocalBusiness JSON-LD
  • Styling: Use existing Tailwind tokens + components
  • Data Fetching: Server-side fetch with ISR revalidation (3600s)

14. NEXT STEPS

  1. Design API DTO for public agent profile
  2. Create backend query handler for fetching public agent profile
  3. Create frontend API client (agents-api.ts)
  4. Build page structure following listing detail pattern
  5. Create agent detail client component with sections
  6. Add reviews display section with star ratings
  7. Add listings display section reusing PropertyCard
  8. Generate JSON-LD structured data for SEO
  9. Test ISR & metadata generation
  10. Add international routes (e.g., /en/agents/[id] via locale)