feat: scaffold monorepo with Turborepo + NestJS + Next.js

- Turborepo monorepo with pnpm workspaces
- apps/api: NestJS 11.x with CQRS module
- apps/web: Next.js 14 App Router + TailwindCSS
- src/modules/shared: base entities, Result pattern, value objects
- TypeScript 5.7+ strict mode, shared tsconfig base
- Build pipeline: dev, build, lint, test, typecheck

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-07 23:52:33 +07:00
commit e1e5fa6252
52 changed files with 6110 additions and 0 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

83
.env.example Normal file
View File

@@ -0,0 +1,83 @@
# =============================================================================
# GoodGo Platform — Environment Variables
# Copy this file to .env and update values for your local environment
# =============================================================================
# -----------------------------------------------------------------------------
# PostgreSQL + PostGIS
# -----------------------------------------------------------------------------
DB_HOST=localhost
DB_PORT=5432
DB_NAME=goodgo
DB_USER=goodgo
DB_PASSWORD=goodgo_secret
DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=public
# -----------------------------------------------------------------------------
# Redis
# -----------------------------------------------------------------------------
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT}
# -----------------------------------------------------------------------------
# Typesense
# -----------------------------------------------------------------------------
TYPESENSE_HOST=localhost
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=http
TYPESENSE_API_KEY=ts_dev_key_change_me
# -----------------------------------------------------------------------------
# MinIO (S3-compatible Object Storage)
# -----------------------------------------------------------------------------
MINIO_ENDPOINT=localhost
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
MINIO_USER=minioadmin
MINIO_PASSWORD=minioadmin_secret
MINIO_BUCKET=goodgo-media
MINIO_USE_SSL=false
# -----------------------------------------------------------------------------
# NestJS API
# -----------------------------------------------------------------------------
API_PORT=3000
NODE_ENV=development
# -----------------------------------------------------------------------------
# JWT / Auth
# -----------------------------------------------------------------------------
JWT_SECRET=your_jwt_secret_change_me
JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=your_refresh_secret_change_me
JWT_REFRESH_EXPIRES_IN=7d
# -----------------------------------------------------------------------------
# Next.js Web
# -----------------------------------------------------------------------------
NEXT_PUBLIC_API_URL=http://localhost:3000
WEB_PORT=3001
# -----------------------------------------------------------------------------
# AI Service (Python/FastAPI)
# -----------------------------------------------------------------------------
AI_SERVICE_PORT=8000
CLAUDE_API_KEY=
# -----------------------------------------------------------------------------
# Mapbox
# -----------------------------------------------------------------------------
NEXT_PUBLIC_MAPBOX_TOKEN=
# -----------------------------------------------------------------------------
# Payment Gateways (VNPay, MoMo, ZaloPay)
# -----------------------------------------------------------------------------
VNPAY_TMN_CODE=
VNPAY_HASH_SECRET=
MOMO_PARTNER_CODE=
MOMO_ACCESS_KEY=
MOMO_SECRET_KEY=
ZALOPAY_APP_ID=
ZALOPAY_KEY1=
ZALOPAY_KEY2=

33
.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# dependencies
node_modules/
.pnpm-store/
# build
dist/
.next/
.turbo/
out/
# env
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# testing
coverage/
# misc
*.log
npm-debug.log*
pnpm-debug.log*

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

104
IMPLEMENTATION_PLAN.md Normal file
View File

@@ -0,0 +1,104 @@
# GoodGo Platform AI — Implementation Plan
**Last Updated:** 2026-04-07
---
## Milestones
### Milestone 1: Walking Skeleton (Phase 0)
**Goal:** Any engineer can clone, install, and start developing.
**Execution Order:**
1. **[TEC-1415] Monorepo Scaffolding** + **[TEC-1416] Docker Compose** (parallel — no deps)
2. **[TEC-1420] ESLint/Prettier** (after F1)
3. **[TEC-1417] Prisma Schema** (after F1 + F2)
4. **[TEC-1418] Shared Module** (after F1)
5. **[TEC-1419] CI/CD Pipeline** (after F1)
```
F1 (Monorepo) ──┬── F6 (Lint/Prettier)
├── F3 (Prisma Schema) ←── F2 (Docker)
├── F4 (Shared Module)
└── F5 (CI/CD)
F2 (Docker) ─────┘
```
### Milestone 2: Core Product (Phase 1)
**Goal:** Users can register, post listings, and search properties.
**Execution Order:**
1. **[TEC-1421] Auth Backend** (after F3, F4)
2. **[TEC-1425] Security Hardening** + **[TEC-1426] Error Handling** (parallel, after F1/F4)
3. **[TEC-1422] Auth Frontend** (after C1)
4. **[TEC-1423] Listings Backend** (after C1)
5. **[TEC-1424] Search Backend** (after C3)
6. **[TEC-1427] Listings Frontend** (after C3)
7. **[TEC-1428] Search + Landing Frontend** (after C5)
```
F3 + F4 ──→ C1 (Auth BE) ──┬── C2 (Auth FE)
├── C3 (Listings BE) ──┬── C5 (Search BE) ──→ C6 (Search FE)
│ └── C4 (Listings FE)
├── X1 (Security)
└── X3 (Error Handling)
```
### Milestone 3: Monetization (Phase 2)
**Goal:** Revenue-generating MVP with payments, subscriptions, and admin tools.
```
C1 ──→ M1 (Payments) ──→ M2 (Subscriptions)
C1 ──→ M3 (Notifications)
C1 + C3 ──→ M4 (Admin)
Phase 1 ──→ X4 (E2E Tests)
```
### Milestone 4: AI-Powered (Phase 3)
**Goal:** Differentiated product with AI features.
```
F2 ──→ A1 (AI/ML Container) ──→ A2 (Analytics)
C5 + A2 ──→ A3 (MCP Servers)
```
---
## Dependency Map
| Task | Depends On |
|------|-----------|
| TEC-1415 (F1) | None |
| TEC-1416 (F2) | None |
| TEC-1417 (F3) | F1, F2 |
| TEC-1418 (F4) | F1 |
| TEC-1419 (F5) | F1 |
| TEC-1420 (F6) | F1 |
| TEC-1421 (C1) | F3, F4 |
| TEC-1422 (C2) | C1 |
| TEC-1423 (C3) | C1, F3 |
| TEC-1424 (C5) | C3, F2 |
| TEC-1425 (X1) | F1 |
| TEC-1426 (X3) | F4 |
| TEC-1427 (C4) | C3 |
| TEC-1428 (C6) | C5 |
| TEC-1429 (M1) | C1 |
| TEC-1430 (M2) | M1 |
| TEC-1431 (M3) | C1 |
| TEC-1432 (M4) | C1, C3 |
| TEC-1433 (X4) | Phase 1 |
---
## Rollout Notes
- **Phase 0 tasks F1 và F2 có thể chạy song song** — không có dependency lẫn nhau
- **F3 (Prisma) cần cả F1 và F2** — monorepo structure + running PostgreSQL
- **Phase 1 bắt đầu ngay khi Phase 0 core done** (F1, F2, F3, F4)
- **Phase 2 issues ở backlog** — activate khi Phase 1 Auth + Listings done
- **Phase 3 chưa tạo issues** — sẽ tạo khi Phase 2 gần hoàn thành
- **Critical path:** F1 → F3 → C1 → C3 → C5 → C6 (longest dependency chain)

60
PROJECT_TRACKER.md Normal file
View File

@@ -0,0 +1,60 @@
# GoodGo Platform AI — Project Tracker
**Last Updated:** 2026-04-07
**Project:** Goodgo Platform AI
**Status:** Phase 0 — Foundation
---
## Phase 0: Foundation (P0 — Critical)
| Issue | Title | Owner | Priority | Status | Blockers |
|-------|-------|-------|----------|--------|----------|
| [TEC-1415](/TEC/issues/TEC-1415) | Monorepo Scaffolding (Turborepo + NestJS + Next.js) | Founding Engineer | Critical | todo | None |
| [TEC-1416](/TEC/issues/TEC-1416) | Docker Compose Dev Environment | DevOps Engineer | Critical | todo | None |
| [TEC-1417](/TEC/issues/TEC-1417) | Prisma Schema + Initial Migration + Seed Scripts | Database Architect | Critical | todo | F1, F2 |
| [TEC-1418](/TEC/issues/TEC-1418) | Shared Module (Domain Primitives + Infrastructure) | Architect | Critical | todo | F1 |
| [TEC-1419](/TEC/issues/TEC-1419) | CI/CD Pipeline (GitHub Actions) | DevOps Engineer | High | todo | F1 |
| [TEC-1420](/TEC/issues/TEC-1420) | ESLint + Prettier + Module Boundary Rules | Founding Engineer | High | todo | F1 |
## Phase 1: Core Auth & Listings (P1)
| Issue | Title | Owner | Priority | Status | Blockers |
|-------|-------|-------|----------|--------|----------|
| [TEC-1421](/TEC/issues/TEC-1421) | Auth Module Backend (Register, Login, JWT, OAuth) | Senior Backend Engineer | Critical | backlog | F3, F4 |
| [TEC-1422](/TEC/issues/TEC-1422) | Auth Frontend (Login/Register + OAuth) | Senior Frontend Engineer | High | backlog | C1 |
| [TEC-1423](/TEC/issues/TEC-1423) | Listings Module Backend (CRUD, Media, Moderation) | Senior Backend Engineer | High | backlog | C1, F3 |
| [TEC-1424](/TEC/issues/TEC-1424) | Search Module Backend (Typesense + Geo) | API Architect | High | backlog | C3, F2 |
| [TEC-1425](/TEC/issues/TEC-1425) | Security Hardening (Rate Limiting, CORS, Helmet) | Security Engineer | High | backlog | F1 |
| [TEC-1426](/TEC/issues/TEC-1426) | Error Handling & Logging Strategy | Architect | High | backlog | F4 |
| [TEC-1427](/TEC/issues/TEC-1427) | Listings Frontend (Create/Edit + Detail) | Senior Frontend Engineer | High | backlog | C3 |
| [TEC-1428](/TEC/issues/TEC-1428) | Search + Landing Page Frontend | Senior Frontend Engineer | High | backlog | C5 |
## Phase 2: Monetization & Operations (P2)
| Issue | Title | Owner | Priority | Status | Blockers |
|-------|-------|-------|----------|--------|----------|
| [TEC-1429](/TEC/issues/TEC-1429) | Payments Module (VNPay + MoMo + ZaloPay) | Senior Backend Engineer | Medium | backlog | C1 |
| [TEC-1430](/TEC/issues/TEC-1430) | Subscriptions Module (Plans, Quotas, Billing) | Senior Backend Engineer | Medium | backlog | M1 |
| [TEC-1431](/TEC/issues/TEC-1431) | Notifications Module (Email, SMS, Zalo OA, FCM) | Founding Engineer | Medium | backlog | C1 |
| [TEC-1432](/TEC/issues/TEC-1432) | Admin Module (Backend + Frontend) | Senior Backend Engineer | Medium | backlog | C1, C3 |
| [TEC-1433](/TEC/issues/TEC-1433) | E2E Testing Setup (Playwright) | QA Engineer | Medium | backlog | Phase 1 |
## Phase 3: AI & Advanced (P3) — Not yet created
- AI/ML Services Container (Python FastAPI + XGBoost)
- Analytics Module (Market reports, AVM)
- MCP Server Integration
- Performance Monitoring (Prometheus + Grafana)
---
## Summary
| Phase | Total | Done | In Progress | Blocked | Backlog/Todo |
|-------|-------|------|-------------|---------|--------------|
| Phase 0 | 6 | 0 | 0 | 0 | 6 |
| Phase 1 | 8 | 0 | 0 | 0 | 8 |
| Phase 2 | 5 | 0 | 0 | 0 | 5 |
| Phase 3 | 4 | — | — | — | Not created |
| **Total** | **19** | **0** | **0** | **0** | **19** |

8
apps/api/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

31
apps/api/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@goodgo/api",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main",
"start:prod": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\"",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/cqrs": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class AppController {
@Get()
healthCheck() {
return { status: 'ok', service: 'goodgo-api' };
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { AppController } from './app.controller';
@Module({
imports: [CqrsModule.forRoot()],
controllers: [AppController],
})
export class AppModule {}

11
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const port = process.env['PORT'] ?? 3001;
await app.listen(port);
console.log(`API running on http://localhost:${port}`);
}
bootstrap();

15
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"outDir": "./dist",
"rootDir": "./src",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"declaration": false,
"declarationMap": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

3
apps/web/app/globals.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

19
apps/web/app/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'GoodGo Platform',
description: 'Vietnam Real Estate Platform',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="vi">
<body>{children}</body>
</html>
);
}

8
apps/web/app/page.tsx Normal file
View File

@@ -0,0 +1,8 @@
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
<h1 className="text-4xl font-bold">GoodGo Platform</h1>
<p className="mt-4 text-lg text-gray-600">Vietnam Real Estate Platform</p>
</main>
);
}

5
apps/web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

6
apps/web/next.config.js Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = nextConfig;

26
apps/web/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "@goodgo/web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,14 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./app/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
],
theme: {
extend: {},
},
plugins: [],
};
export default config;

41
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2017",
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"declaration": false,
"declarationMap": false,
"sourceMap": false,
"noEmit": true,
"allowJs": true,
"isolatedModules": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules",
".next"
]
}

File diff suppressed because one or more lines are too long

97
docker-compose.yml Normal file
View File

@@ -0,0 +1,97 @@
services:
postgres:
image: postgis/postgis:16-3.4
container_name: goodgo-postgres
restart: unless-stopped
ports:
- "${DB_PORT:-5432}:5432"
environment:
POSTGRES_DB: ${DB_NAME:-goodgo}
POSTGRES_USER: ${DB_USER:-goodgo}
POSTGRES_PASSWORD: ${DB_PASSWORD:-goodgo_secret}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-goodgo} -d ${DB_NAME:-goodgo}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- goodgo-net
redis:
image: redis:7-alpine
container_name: goodgo-redis
restart: unless-stopped
ports:
- "${REDIS_PORT:-6379}:6379"
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- goodgo-net
typesense:
image: typesense/typesense:27.1
container_name: goodgo-typesense
restart: unless-stopped
ports:
- "${TYPESENSE_PORT:-8108}:8108"
environment:
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-ts_dev_key_change_me}
TYPESENSE_DATA_DIR: /data
TYPESENSE_ENABLE_CORS: "true"
volumes:
- typesense_data:/data
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8108/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
networks:
- goodgo-net
minio:
image: minio/minio:latest
container_name: goodgo-minio
restart: unless-stopped
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minioadmin_secret}
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
networks:
- goodgo-net
volumes:
pgdata:
driver: local
redis_data:
driver: local
typesense_data:
driver: local
minio_data:
driver: local
networks:
goodgo-net:
driver: bridge
name: goodgo-net

86
docs/dev-environment.md Normal file
View File

@@ -0,0 +1,86 @@
# Development Environment
## Prerequisites
- Docker Engine 24+ & Docker Compose v2
- Node.js 22 LTS (for running app services locally)
## Quick Start
```bash
# 1. Copy environment variables
cp .env.example .env
# 2. Start all infrastructure services
docker compose up -d
# 3. Verify all services are healthy
docker compose ps
```
## Services
| Service | Port(s) | Description | Dashboard/UI |
| ---------- | ------------ | ------------------------------ | ------------------------------- |
| PostgreSQL | 5432 | Database with PostGIS | — |
| Redis | 6379 | Cache, sessions, queue | — |
| Typesense | 8108 | Full-text search engine | http://localhost:8108/health |
| MinIO | 9000 / 9001 | S3-compatible object storage | http://localhost:9001 (console) |
## Common Commands
```bash
# Start services
docker compose up -d
# View logs (all or specific service)
docker compose logs -f
docker compose logs -f postgres
# Stop services (data preserved in volumes)
docker compose down
# Stop and remove all data
docker compose down -v
# Restart a single service
docker compose restart redis
# Check service health
docker compose ps
```
## Connecting to Services
### PostgreSQL
```bash
# Via psql
psql postgresql://goodgo:goodgo_secret@localhost:5432/goodgo
# Verify PostGIS
psql postgresql://goodgo:goodgo_secret@localhost:5432/goodgo -c "SELECT PostGIS_Version();"
```
### Redis
```bash
redis-cli -p 6379 ping
```
### Typesense
```bash
curl http://localhost:8108/health
```
### MinIO
- API: `http://localhost:9000`
- Console: `http://localhost:9001` (login: minioadmin / minioadmin_secret)
## Troubleshooting
- **Port conflict**: Change ports in `.env` (e.g., `DB_PORT=5433`)
- **Permission issues**: Run `docker compose down -v` and restart
- **PostGIS not available**: Ensure using `postgis/postgis:16-3.4` image

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "goodgo-platform",
"private": true,
"packageManager": "pnpm@10.27.0",
"engines": {
"node": ">=22.0.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"@nestjs/core",
"esbuild"
]
},
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test",
"typecheck": "turbo run typecheck"
},
"devDependencies": {
"turbo": "^2.9.4"
}
}

4478
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

455
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,455 @@
// =============================================================================
// GoodGo Platform — Prisma Schema
// PostgreSQL 16 + PostGIS
// =============================================================================
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [postgis]
}
// =============================================================================
// AUTH
// =============================================================================
enum UserRole {
BUYER
SELLER
AGENT
ADMIN
}
enum KYCStatus {
NONE
PENDING
VERIFIED
REJECTED
}
model User {
id String @id @default(cuid())
email String? @unique
phone String @unique
passwordHash String?
fullName String
avatarUrl String?
role UserRole @default(BUYER)
kycStatus KYCStatus @default(NONE)
kycData Json?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agent Agent?
listings Listing[]
savedSearches SavedSearch[]
subscription Subscription?
payments Payment[]
reviews Review[]
inquiriesSent Inquiry[]
@@index([phone])
@@index([role])
}
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 // ["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])
}
// =============================================================================
// LISTINGS
// =============================================================================
enum PropertyType {
APARTMENT
VILLA
TOWNHOUSE
LAND
OFFICE
SHOPHOUSE
}
enum TransactionType {
SALE
RENT
}
enum ListingStatus {
DRAFT
PENDING_REVIEW
ACTIVE
RESERVED
SOLD
RENTED
EXPIRED
REJECTED
}
enum Direction {
NORTH
SOUTH
EAST
WEST
NORTHEAST
NORTHWEST
SOUTHEAST
SOUTHWEST
}
model Property {
id String @id @default(cuid())
propertyType PropertyType
title String
description String @db.Text
address String
ward String
district String
city String
location Unsupported("geometry(Point, 4326)")
areaM2 Float
usableAreaM2 Float?
bedrooms Int?
bathrooms Int?
floors Int?
floor Int?
totalFloors Int?
direction Direction?
yearBuilt Int?
legalStatus String?
amenities Json?
nearbyPOIs Json?
metroDistanceM Float?
projectName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
listings Listing[]
valuations Valuation[]
media PropertyMedia[]
@@index([propertyType])
@@index([district, city])
@@index([location], type: Gist)
}
model PropertyMedia {
id String @id @default(cuid())
propertyId String
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
url String
type String // "image" | "video"
order Int @default(0)
caption String?
aiTags Json?
createdAt DateTime @default(now())
@@index([propertyId])
}
model Listing {
id String @id @default(cuid())
propertyId String
property Property @relation(fields: [propertyId], references: [id])
agentId String?
agent Agent? @relation(fields: [agentId], references: [id])
sellerId String
seller User @relation(fields: [sellerId], references: [id])
transactionType TransactionType
status ListingStatus @default(DRAFT)
priceVND BigInt
pricePerM2 Float?
rentPriceMonthly BigInt?
commissionPct Float? @default(2.0)
aiPriceEstimate BigInt?
aiConfidence Float?
moderationScore Float?
moderationNotes String?
viewCount Int @default(0)
saveCount Int @default(0)
inquiryCount Int @default(0)
featuredUntil DateTime?
expiresAt DateTime?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions Transaction[]
inquiries Inquiry[]
@@index([status])
@@index([transactionType])
@@index([priceVND])
@@index([agentId])
@@index([publishedAt])
}
// =============================================================================
// SEARCH
// =============================================================================
model SavedSearch {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
filters Json
alertEnabled Boolean @default(true)
lastAlertAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
// =============================================================================
// TRANSACTIONS
// =============================================================================
enum TransactionStatus {
INQUIRY
VIEWING_SCHEDULED
OFFER_MADE
DEPOSIT_PAID
CONTRACT_SIGNING
COMPLETED
CANCELLED
}
model Transaction {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id])
buyerId String
status TransactionStatus @default(INQUIRY)
agreedPrice BigInt?
depositAmount BigInt?
timeline Json?
contractUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
payments Payment[]
@@index([listingId])
@@index([buyerId])
@@index([status])
}
model Inquiry {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
message String @db.Text
phone String?
isRead Boolean @default(false)
createdAt DateTime @default(now())
@@index([listingId])
@@index([userId])
}
model Lead {
id String @id @default(cuid())
agentId String
agent Agent @relation(fields: [agentId], references: [id])
name String
phone String
email String?
source String
score Float?
notes Json?
status String @default("new")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([agentId])
@@index([status])
}
// =============================================================================
// PAYMENTS
// =============================================================================
enum PaymentProvider {
VNPAY
MOMO
ZALOPAY
BANK_TRANSFER
}
enum PaymentStatus {
PENDING
PROCESSING
COMPLETED
FAILED
REFUNDED
}
enum PaymentType {
SUBSCRIPTION
LISTING_FEE
DEPOSIT
FEATURED_LISTING
}
model Payment {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
transactionId String?
transaction Transaction? @relation(fields: [transactionId], references: [id])
provider PaymentProvider
type PaymentType
amountVND BigInt
status PaymentStatus @default(PENDING)
providerTxId String?
callbackData Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
@@index([providerTxId])
}
// =============================================================================
// SUBSCRIPTIONS
// =============================================================================
enum PlanTier {
FREE
AGENT_PRO
INVESTOR
ENTERPRISE
}
enum SubscriptionStatus {
ACTIVE
PAST_DUE
CANCELLED
EXPIRED
}
model Plan {
id String @id @default(cuid())
tier PlanTier @unique
name String
priceMonthlyVND BigInt
priceYearlyVND BigInt
maxListings Int?
maxSavedSearches Int?
features Json
isActive Boolean @default(true)
subscriptions Subscription[]
}
model Subscription {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
planId String
plan Plan @relation(fields: [planId], references: [id])
status SubscriptionStatus @default(ACTIVE)
currentPeriodStart DateTime
currentPeriodEnd DateTime
cancelledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
usageRecords UsageRecord[]
@@index([status])
}
model UsageRecord {
id String @id @default(cuid())
subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id])
metric String
count Int
periodStart DateTime
periodEnd DateTime
@@index([subscriptionId, metric])
}
// =============================================================================
// ANALYTICS
// =============================================================================
model Valuation {
id String @id @default(cuid())
propertyId String
property Property @relation(fields: [propertyId], references: [id])
estimatedPrice BigInt
confidence Float
pricePerM2 Float
comparables Json
features Json
modelVersion String
createdAt DateTime @default(now())
@@index([propertyId])
}
model MarketIndex {
id String @id @default(cuid())
district String
city String
propertyType PropertyType
period String
medianPrice BigInt
avgPriceM2 Float
totalListings Int
daysOnMarket Float
inventoryLevel Int
absorptionRate Float?
yoyChange Float?
createdAt DateTime @default(now())
@@unique([district, city, propertyType, period])
@@index([city, period])
}
// =============================================================================
// REVIEWS
// =============================================================================
model Review {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
targetType String
targetId String
rating Int
comment String? @db.Text
createdAt DateTime @default(now())
@@index([targetType, targetId])
}

View File

@@ -0,0 +1,20 @@
import { BaseEntity } from './base-entity';
import { DomainEvent } from './domain-event';
export abstract class AggregateRoot<TId = string> extends BaseEntity<TId> {
private _domainEvents: DomainEvent[] = [];
get domainEvents(): ReadonlyArray<DomainEvent> {
return [...this._domainEvents];
}
protected addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
clearDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
}

View File

@@ -0,0 +1,13 @@
export abstract class BaseEntity<TId = string> {
constructor(
public readonly id: TId,
public readonly createdAt: Date = new Date(),
public updatedAt: Date = new Date(),
) {}
equals(other: BaseEntity<TId>): boolean {
if (other === null || other === undefined) return false;
if (this === other) return true;
return this.id === other.id;
}
}

View File

@@ -0,0 +1,5 @@
export interface DomainEvent {
readonly eventName: string;
readonly occurredAt: Date;
readonly aggregateId: string;
}

View File

@@ -0,0 +1,22 @@
import { BaseEntity } from './base.entity';
export interface DomainEvent {
readonly occurredAt: Date;
readonly eventName: string;
}
export abstract class AggregateRoot<TProps> extends BaseEntity<TProps> {
private _domainEvents: DomainEvent[] = [];
get domainEvents(): ReadonlyArray<DomainEvent> {
return this._domainEvents;
}
protected addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
clearDomainEvents(): void {
this._domainEvents = [];
}
}

View File

@@ -0,0 +1,37 @@
import { randomUUID } from 'node:crypto';
export abstract class BaseEntity<TProps> {
private readonly _id: string;
private readonly _createdAt: Date;
private _updatedAt: Date;
protected props: TProps;
constructor(props: TProps, id?: string) {
this._id = id ?? randomUUID();
this._createdAt = new Date();
this._updatedAt = new Date();
this.props = props;
}
get id(): string {
return this._id;
}
get createdAt(): Date {
return this._createdAt;
}
get updatedAt(): Date {
return this._updatedAt;
}
protected markUpdated(): void {
this._updatedAt = new Date();
}
equals(other: BaseEntity<TProps>): boolean {
if (other === null || other === undefined) return false;
if (this === other) return true;
return this._id === other._id;
}
}

View File

@@ -0,0 +1,5 @@
export { BaseEntity } from './base-entity';
export { AggregateRoot } from './aggregate-root';
export { ValueObject } from './value-object';
export type { DomainEvent } from './domain-event';
export { Result } from './result';

View File

@@ -0,0 +1,58 @@
export class Result<T, E = Error> {
private constructor(
private readonly _isOk: boolean,
private readonly _value?: T,
private readonly _error?: E,
) {}
get isOk(): boolean {
return this._isOk;
}
get isErr(): boolean {
return !this._isOk;
}
static ok<T, E = Error>(value: T): Result<T, E> {
return new Result<T, E>(true, value);
}
static err<T, E = Error>(error: E): Result<T, E> {
return new Result<T, E>(false, undefined, error);
}
unwrap(): T {
if (this._isOk) return this._value as T;
throw this._error;
}
unwrapErr(): E {
if (!this._isOk) return this._error as E;
throw new Error('Called unwrapErr on an Ok result');
}
map<U>(fn: (value: T) => U): Result<U, E> {
if (this._isOk) return Result.ok(fn(this._value as T));
return Result.err(this._error as E);
}
mapErr<F>(fn: (error: E) => F): Result<T, F> {
if (!this._isOk) return Result.err(fn(this._error as E));
return Result.ok(this._value as T);
}
andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
if (this._isOk) return fn(this._value as T);
return Result.err(this._error as E);
}
unwrapOr(defaultValue: T): T {
return this._isOk ? (this._value as T) : defaultValue;
}
match<U>(handlers: { ok: (value: T) => U; err: (error: E) => U }): U {
return this._isOk
? handlers.ok(this._value as T)
: handlers.err(this._error as E);
}
}

View File

@@ -0,0 +1,12 @@
export abstract class ValueObject<TProps> {
protected readonly props: Readonly<TProps>;
constructor(props: TProps) {
this.props = Object.freeze({ ...props });
}
equals(other: ValueObject<TProps>): boolean {
if (other === null || other === undefined) return false;
return JSON.stringify(this.props) === JSON.stringify(other.props);
}
}

View File

@@ -0,0 +1,12 @@
export abstract class ValueObject<TProps> {
protected readonly props: TProps;
constructor(props: TProps) {
this.props = Object.freeze(props);
}
equals(other: ValueObject<TProps>): boolean {
if (other === null || other === undefined) return false;
return JSON.stringify(this.props) === JSON.stringify(other.props);
}
}

View File

@@ -0,0 +1,4 @@
export * from './domain';
export * from './infrastructure';
export * from './utils';
export { SharedModule } from './shared.module';

View File

@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { DomainEvent } from '../domain/domain-event';
@Injectable()
export class EventBusService {
constructor(private readonly eventEmitter: EventEmitter2) {}
publish(event: DomainEvent): void {
this.eventEmitter.emit(event.eventName, event);
}
publishAll(events: DomainEvent[]): void {
for (const event of events) {
this.publish(event);
}
}
async publishAsync(event: DomainEvent): Promise<void> {
await this.eventEmitter.emitAsync(event.eventName, event);
}
}

View File

@@ -0,0 +1,4 @@
export { PrismaService } from './prisma.service';
export { RedisService } from './redis.service';
export { LoggerService } from './logger.service';
export { EventBusService } from './event-bus.service';

View File

@@ -0,0 +1,41 @@
import { Injectable, LoggerService as NestLoggerService } from '@nestjs/common';
import pino, { Logger } from 'pino';
@Injectable()
export class LoggerService implements NestLoggerService {
private readonly logger: Logger;
constructor() {
this.logger = pino({
level: process.env['LOG_LEVEL'] ?? 'info',
transport:
process.env['NODE_ENV'] !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
});
}
log(message: string, context?: string): void {
this.logger.info({ context }, message);
}
error(message: string, trace?: string, context?: string): void {
this.logger.error({ context, trace }, message);
}
warn(message: string, context?: string): void {
this.logger.warn({ context }, message);
}
debug(message: string, context?: string): void {
this.logger.debug({ context }, message);
}
verbose(message: string, context?: string): void {
this.logger.trace({ context }, message);
}
child(bindings: Record<string, unknown>): Logger {
return this.logger.child(bindings);
}
}

View File

@@ -0,0 +1,16 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit(): Promise<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}

View File

@@ -0,0 +1,40 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly client: Redis;
constructor() {
this.client = new Redis({
host: process.env['REDIS_HOST'] ?? 'localhost',
port: Number(process.env['REDIS_PORT'] ?? 6379),
password: process.env['REDIS_PASSWORD'] ?? undefined,
lazyConnect: true,
});
}
async onModuleDestroy(): Promise<void> {
await this.client.quit();
}
getClient(): Redis {
return this.client;
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) {
await this.client.set(key, value, 'EX', ttlSeconds);
} else {
await this.client.set(key, value);
}
}
async del(key: string): Promise<void> {
await this.client.del(key);
}
}

View File

@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PrismaService } from './infrastructure/prisma.service';
import { RedisService } from './infrastructure/redis.service';
import { LoggerService } from './infrastructure/logger.service';
import { EventBusService } from './infrastructure/event-bus.service';
@Global()
@Module({
imports: [EventEmitterModule.forRoot()],
providers: [PrismaService, RedisService, LoggerService, EventBusService],
exports: [PrismaService, RedisService, LoggerService, EventBusService],
})
export class SharedModule {}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "."
},
"include": ["./**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,11 @@
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}

View File

@@ -0,0 +1,26 @@
const VND_FORMATTER = new Intl.NumberFormat('vi-VN', {
style: 'currency',
currency: 'VND',
maximumFractionDigits: 0,
});
const VND_COMPACT_FORMATTER = new Intl.NumberFormat('vi-VN', {
style: 'currency',
currency: 'VND',
notation: 'compact',
maximumFractionDigits: 1,
});
export function formatVND(amount: number): string {
return VND_FORMATTER.format(amount);
}
export function formatVNDCompact(amount: number): string {
return VND_COMPACT_FORMATTER.format(amount);
}
export function parseVND(formatted: string): number | null {
const cleaned = formatted.replace(/[^\d]/g, '');
const value = Number(cleaned);
return Number.isNaN(value) ? null : value;
}

View File

@@ -0,0 +1,3 @@
export { isValidVietnamPhone, normalizeVietnamPhone } from './vietnam-phone.validator';
export { formatVND, formatVNDCompact, parseVND } from './currency.formatter';
export { generateSlug } from './slug.generator';

View File

@@ -0,0 +1,31 @@
const VIETNAMESE_MAP: Record<string, string> = {
à: 'a', á: 'a', : 'a', ã: 'a', : 'a',
ă: 'a', : 'a', : 'a', : 'a', : 'a', : 'a',
â: 'a', : 'a', : 'a', : 'a', : 'a', : 'a',
đ: 'd',
è: 'e', é: 'e', : 'e', : 'e', : 'e',
ê: 'e', ế: 'e', : 'e', : 'e', : 'e', : 'e',
ì: 'i', í: 'i', : 'i', ĩ: 'i', : 'i',
ò: 'o', ó: 'o', : 'o', õ: 'o', : 'o',
ô: 'o', : 'o', : 'o', : 'o', : 'o', : 'o',
ơ: 'o', : 'o', : 'o', : 'o', : 'o', : 'o',
ù: 'u', ú: 'u', : 'u', ũ: 'u', : 'u',
ư: 'u', : 'u', : 'u', : 'u', : 'u', : 'u',
: 'y', ý: 'y', : 'y', : 'y', : 'y',
};
function removeVietnameseTones(str: string): string {
return str
.split('')
.map((char) => VIETNAMESE_MAP[char] ?? VIETNAMESE_MAP[char.toLowerCase()]?.toUpperCase() ?? char)
.join('');
}
export function generateSlug(text: string): string {
return removeVietnameseTones(text)
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/[\s]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}

View File

@@ -0,0 +1,16 @@
const VN_PHONE_REGEX = /^(?:\+84|84|0)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/;
export function isValidVietnamPhone(phone: string): boolean {
const cleaned = phone.replace(/[\s.-]/g, '');
return VN_PHONE_REGEX.test(cleaned);
}
export function normalizeVietnamPhone(phone: string): string | null {
const cleaned = phone.replace(/[\s.-]/g, '');
if (!VN_PHONE_REGEX.test(cleaned)) return null;
if (cleaned.startsWith('+84')) return cleaned;
if (cleaned.startsWith('84')) return `+${cleaned}`;
if (cleaned.startsWith('0')) return `+84${cleaned.slice(1)}`;
return null;
}

19
tsconfig.base.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true
}
}

22
turbo.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"]
},
"typecheck": {
"dependsOn": ["^build"]
}
}
}