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:
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
83
.env.example
Normal 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
33
.gitignore
vendored
Normal 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*
|
||||||
104
IMPLEMENTATION_PLAN.md
Normal file
104
IMPLEMENTATION_PLAN.md
Normal 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
60
PROJECT_TRACKER.md
Normal 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
8
apps/api/nest-cli.json
Normal 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
31
apps/api/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app.controller.ts
Normal file
9
apps/api/src/app.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
@Get()
|
||||||
|
healthCheck() {
|
||||||
|
return { status: 'ok', service: 'goodgo-api' };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app.module.ts
Normal file
9
apps/api/src/app.module.ts
Normal 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
11
apps/api/src/main.ts
Normal 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
15
apps/api/tsconfig.json
Normal 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
3
apps/web/app/globals.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
19
apps/web/app/layout.tsx
Normal file
19
apps/web/app/layout.tsx
Normal 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
8
apps/web/app/page.tsx
Normal 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
5
apps/web/next-env.d.ts
vendored
Normal 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
6
apps/web/next.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
26
apps/web/package.json
Normal file
26
apps/web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
14
apps/web/tailwind.config.ts
Normal file
14
apps/web/tailwind.config.ts
Normal 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
41
apps/web/tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
97
docker-compose.yml
Normal file
97
docker-compose.yml
Normal 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
86
docs/dev-environment.md
Normal 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
24
package.json
Normal 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
4478
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
455
prisma/schema.prisma
Normal file
455
prisma/schema.prisma
Normal 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])
|
||||||
|
}
|
||||||
20
src/modules/shared/domain/aggregate-root.ts
Normal file
20
src/modules/shared/domain/aggregate-root.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/modules/shared/domain/base-entity.ts
Normal file
13
src/modules/shared/domain/base-entity.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/modules/shared/domain/domain-event.ts
Normal file
5
src/modules/shared/domain/domain-event.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface DomainEvent {
|
||||||
|
readonly eventName: string;
|
||||||
|
readonly occurredAt: Date;
|
||||||
|
readonly aggregateId: string;
|
||||||
|
}
|
||||||
22
src/modules/shared/domain/entities/aggregate-root.ts
Normal file
22
src/modules/shared/domain/entities/aggregate-root.ts
Normal 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/modules/shared/domain/entities/base.entity.ts
Normal file
37
src/modules/shared/domain/entities/base.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/modules/shared/domain/index.ts
Normal file
5
src/modules/shared/domain/index.ts
Normal 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';
|
||||||
58
src/modules/shared/domain/result.ts
Normal file
58
src/modules/shared/domain/result.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/modules/shared/domain/value-object.ts
Normal file
12
src/modules/shared/domain/value-object.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/modules/shared/domain/value-objects/value-object.ts
Normal file
12
src/modules/shared/domain/value-objects/value-object.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/modules/shared/index.ts
Normal file
4
src/modules/shared/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './domain';
|
||||||
|
export * from './infrastructure';
|
||||||
|
export * from './utils';
|
||||||
|
export { SharedModule } from './shared.module';
|
||||||
22
src/modules/shared/infrastructure/event-bus.service.ts
Normal file
22
src/modules/shared/infrastructure/event-bus.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/modules/shared/infrastructure/index.ts
Normal file
4
src/modules/shared/infrastructure/index.ts
Normal 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';
|
||||||
41
src/modules/shared/infrastructure/logger.service.ts
Normal file
41
src/modules/shared/infrastructure/logger.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/modules/shared/infrastructure/prisma.service.ts
Normal file
16
src/modules/shared/infrastructure/prisma.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/modules/shared/infrastructure/redis.service.ts
Normal file
40
src/modules/shared/infrastructure/redis.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/modules/shared/shared.module.ts
Normal file
14
src/modules/shared/shared.module.ts
Normal 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 {}
|
||||||
9
src/modules/shared/tsconfig.json
Normal file
9
src/modules/shared/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["./**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
11
src/modules/shared/types/result.ts
Normal file
11
src/modules/shared/types/result.ts
Normal 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 };
|
||||||
|
}
|
||||||
26
src/modules/shared/utils/currency.formatter.ts
Normal file
26
src/modules/shared/utils/currency.formatter.ts
Normal 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;
|
||||||
|
}
|
||||||
3
src/modules/shared/utils/index.ts
Normal file
3
src/modules/shared/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { isValidVietnamPhone, normalizeVietnamPhone } from './vietnam-phone.validator';
|
||||||
|
export { formatVND, formatVNDCompact, parseVND } from './currency.formatter';
|
||||||
|
export { generateSlug } from './slug.generator';
|
||||||
31
src/modules/shared/utils/slug.generator.ts
Normal file
31
src/modules/shared/utils/slug.generator.ts
Normal 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, '');
|
||||||
|
}
|
||||||
16
src/modules/shared/utils/vietnam-phone.validator.ts
Normal file
16
src/modules/shared/utils/vietnam-phone.validator.ts
Normal 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
19
tsconfig.base.json
Normal 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
22
turbo.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user