Compare commits
17 Commits
dfb398131d
...
7e655fd976
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e655fd976 | ||
|
|
b2490e209e | ||
|
|
9af9e1d84a | ||
|
|
be47c26031 | ||
|
|
8026837edd | ||
|
|
03c1926d32 | ||
|
|
aed173adca | ||
|
|
fa3ba88f40 | ||
|
|
b4bb05479e | ||
|
|
d7c5b1ca2c | ||
|
|
0fc23b7ebd | ||
|
|
8a15df0bdb | ||
|
|
ec066dfa28 | ||
|
|
d7961e297c | ||
|
|
f5118244b7 | ||
|
|
1d26393f16 | ||
|
|
0168f1f6f5 |
@@ -1,5 +1,37 @@
|
|||||||
# Hướng Dẫn Đóng Góp
|
# Hướng Dẫn Đóng Góp
|
||||||
|
|
||||||
|
## Kỷ Luật Commit & Push (Bắt Buộc)
|
||||||
|
|
||||||
|
> Để tránh conflict khi nhiều agent/engineer làm việc song song, toàn bộ team PHẢI tuân thủ các quy định sau. Nguồn: [GOO-91](/GOO/issues/GOO-91) (chỉ thị từ CEO qua [GOO-88](/GOO/issues/GOO-88)).
|
||||||
|
|
||||||
|
1. **Commit ngay khi hoàn thành task** — mỗi task = một commit (hoặc một chuỗi commit nhỏ liên quan). Không gom nhiều task không liên quan vào một commit lớn.
|
||||||
|
2. **Pull/rebase trước khi push** — luôn chạy `git pull --rebase origin <branch>` trước `git push` để giảm merge conflict.
|
||||||
|
3. **Push ngay sau commit** — không giữ commit local quá 1 ngày làm việc. Commit không push = rủi ro mất việc + conflict tăng.
|
||||||
|
4. **Conventional Commits** — bắt buộc (`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`, `test:`, `style:`, `perf:`). Xem [Quy Ước Commit](#quy-ước-commit) bên dưới.
|
||||||
|
5. **KHÔNG push trực tiếp lên `main` / `master`** — luôn dùng feature branch + Pull Request. Branch chính được bảo vệ bằng GitHub branch protection rules.
|
||||||
|
6. **PR phải pass CI** (`lint` → `typecheck` → `test` → `build`) trước khi merge. PR đỏ CI không được merge dù đã approve.
|
||||||
|
7. **Squash-merge khi merge PR** — giữ history trên `main` sạch, mỗi PR = một commit logic.
|
||||||
|
8. **Xóa feature branch sau khi merge** — tránh branch sprawl. GitHub có auto-delete branch sau merge; bật nó trong repo settings.
|
||||||
|
|
||||||
|
### Flow nhanh cho mỗi task
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Tạo/chuyển sang feature branch (KHÔNG commit trực tiếp vào main)
|
||||||
|
git checkout -b feature/goo-xx-short-description
|
||||||
|
|
||||||
|
# 2. Làm việc, khi hoàn thành task:
|
||||||
|
git add <files>
|
||||||
|
git commit -m "feat(scope): mô tả ngắn"
|
||||||
|
|
||||||
|
# 3. Đồng bộ & push
|
||||||
|
git pull --rebase origin main # hoặc develop
|
||||||
|
git push -u origin feature/goo-xx-short-description
|
||||||
|
|
||||||
|
# 4. Mở PR, chờ CI xanh + review, squash-merge, xóa branch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quy Trình Git & Branching
|
## Quy Trình Git & Branching
|
||||||
|
|
||||||
### Nhánh Chính
|
### Nhánh Chính
|
||||||
|
|||||||
50
apps/api/docs/observability/README.md
Normal file
50
apps/api/docs/observability/README.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Observability — Read-Model / Projector (RFC-003 Phase 0)
|
||||||
|
|
||||||
|
Grafana dashboards and wiring notes for the read-model observability stack
|
||||||
|
introduced in [GOO-192](/GOO/issues/GOO-192) under [GOO-94](/GOO/issues/GOO-94) §6 Phase 0.
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
All metrics live in the existing NestJS `metrics/` module
|
||||||
|
(`apps/api/src/modules/metrics/`) and are scraped via the standard `/metrics`
|
||||||
|
endpoint.
|
||||||
|
|
||||||
|
| Metric | Type | Labels | Purpose |
|
||||||
|
| --------------------------------------- | --------- | --------- | --------------------------------------------------------- |
|
||||||
|
| `read_model_projector_lag_seconds` | Gauge | `handler` | Seconds between latest source event and projector cursor. |
|
||||||
|
| `read_model_refresh_duration_seconds` | Histogram | `view` | Duration of read-model / materialised view refreshes. |
|
||||||
|
| `read_model_reconciliation_drift_total` | Counter | `model` | Count of drift discrepancies found during reconciliation. |
|
||||||
|
|
||||||
|
### Emit points
|
||||||
|
|
||||||
|
Inject `MetricsService` and call:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
metrics.setProjectorLag(handler, lagSeconds);
|
||||||
|
metrics.recordReadModelRefresh(view, durationSeconds);
|
||||||
|
metrics.recordReconciliationDrift(model, count?);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
- File: `read-models-dashboard.json` (Grafana schema v38).
|
||||||
|
- Import into Grafana (`Dashboards → Import → Upload JSON`), pick the Prometheus
|
||||||
|
data source.
|
||||||
|
- Variables: `handler`, `view`, `model` — derived from Prometheus label values.
|
||||||
|
- Panels:
|
||||||
|
1. Projector lag by handler (time series + thresholded)
|
||||||
|
2. Max projector lag (stat, RAG 30s / 120s)
|
||||||
|
3. Refresh duration p50/p95 by view
|
||||||
|
4. Refresh throughput (refreshes/sec) by view
|
||||||
|
5. Reconciliation drift rate by model (15m rate)
|
||||||
|
6. Total drift events in last 24h (stat, RAG 1 / 10)
|
||||||
|
|
||||||
|
## Local verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @goodgo/api dev
|
||||||
|
curl -s http://localhost:3001/metrics | grep read_model_
|
||||||
|
```
|
||||||
|
|
||||||
|
All three metric families should appear with `# HELP` / `# TYPE` headers even
|
||||||
|
before any samples are recorded.
|
||||||
77
apps/api/docs/observability/read-models-dashboard.json
Normal file
77
apps/api/docs/observability/read-models-dashboard.json
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"annotations": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"builtIn": 1,
|
||||||
|
"datasource": "-- Grafana --",
|
||||||
|
"enable": true,
|
||||||
|
"hide": true,
|
||||||
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
|
"name": "Annotations & Alerts",
|
||||||
|
"type": "dashboard"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"id": null,
|
||||||
|
"uid": "goodgo-read-models",
|
||||||
|
"title": "GoodGo · Read-Model Observability (RFC-003 Phase 0)",
|
||||||
|
"tags": ["goodgo", "rfc-003", "read-models", "observability"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"schemaVersion": 38,
|
||||||
|
"version": 1,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": { "from": "now-6h", "to": "now" },
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{ "name": "datasource", "type": "datasource", "query": "prometheus", "current": { "text": "Prometheus", "value": "Prometheus" } },
|
||||||
|
{ "name": "handler", "type": "query", "datasource": "${datasource}", "query": "label_values(read_model_projector_lag_seconds, handler)", "includeAll": true, "multi": true, "refresh": 2 },
|
||||||
|
{ "name": "view", "type": "query", "datasource": "${datasource}", "query": "label_values(read_model_refresh_duration_seconds_bucket, view)", "includeAll": true, "multi": true, "refresh": 2 },
|
||||||
|
{ "name": "model", "type": "query", "datasource": "${datasource}", "query": "label_values(read_model_reconciliation_drift_total, model)", "includeAll": true, "multi": true, "refresh": 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1, "type": "timeseries", "title": "Projector lag (seconds) — by handler",
|
||||||
|
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 30 }, { "color": "red", "value": 120 }] } } },
|
||||||
|
"targets": [{ "expr": "read_model_projector_lag_seconds{handler=~\"$handler\"}", "legendFormat": "{{handler}}", "refId": "A" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2, "type": "stat", "title": "Max projector lag (current)",
|
||||||
|
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 30 }, { "color": "red", "value": 120 }] } } },
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } },
|
||||||
|
"targets": [{ "expr": "max(read_model_projector_lag_seconds{handler=~\"$handler\"})", "refId": "A" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3, "type": "timeseries", "title": "Refresh duration p50/p95 — by view",
|
||||||
|
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "s" } },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "histogram_quantile(0.95, sum by (view, le) (rate(read_model_refresh_duration_seconds_bucket{view=~\"$view\"}[5m])))", "legendFormat": "p95 · {{view}}", "refId": "A" },
|
||||||
|
{ "expr": "histogram_quantile(0.50, sum by (view, le) (rate(read_model_refresh_duration_seconds_bucket{view=~\"$view\"}[5m])))", "legendFormat": "p50 · {{view}}", "refId": "B" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4, "type": "timeseries", "title": "Refresh throughput (refreshes/sec) — by view",
|
||||||
|
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "ops" } },
|
||||||
|
"targets": [{ "expr": "sum by (view) (rate(read_model_refresh_duration_seconds_count{view=~\"$view\"}[5m]))", "legendFormat": "{{view}}", "refId": "A" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5, "type": "timeseries", "title": "Reconciliation drift rate — by model",
|
||||||
|
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "ops" } },
|
||||||
|
"targets": [{ "expr": "sum by (model) (rate(read_model_reconciliation_drift_total{model=~\"$model\"}[15m]))", "legendFormat": "{{model}}", "refId": "A" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6, "type": "stat", "title": "Total drift events (last 24h)",
|
||||||
|
"datasource": "${datasource}", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "short", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 10 }] } } },
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } },
|
||||||
|
"targets": [{ "expr": "sum by (model) (increase(read_model_reconciliation_drift_total{model=~\"$model\"}[24h]))", "legendFormat": "{{model}}", "refId": "A" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"@aws-sdk/client-s3": "^3.1026.0",
|
"@aws-sdk/client-s3": "^3.1026.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1026.0",
|
"@aws-sdk/s3-request-presigner": "^3.1026.0",
|
||||||
"@goodgo/mcp-servers": "workspace:*",
|
"@goodgo/mcp-servers": "workspace:*",
|
||||||
|
"@goodgo/contracts-events": "workspace:*",
|
||||||
"@nest-lab/throttler-storage-redis": "^1.2.0",
|
"@nest-lab/throttler-storage-redis": "^1.2.0",
|
||||||
"@nestjs/bullmq": "^11.0.4",
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { forwardRef, Module } from '@nestjs/common';
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
|
import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus';
|
||||||
import { ListingsModule } from '@modules/listings';
|
import { ListingsModule } from '@modules/listings';
|
||||||
import { ProjectsModule } from '@modules/projects';
|
import { ProjectsModule } from '@modules/projects';
|
||||||
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
|
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
|
||||||
@@ -35,6 +36,12 @@ import { PrismaValuationRepository } from './infrastructure/repositories/prisma-
|
|||||||
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
||||||
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
||||||
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
||||||
|
import {
|
||||||
|
RefreshMaterializedViewCronService,
|
||||||
|
MATVIEW_REFRESH_TOTAL,
|
||||||
|
MATVIEW_REFRESH_DURATION,
|
||||||
|
MATVIEW_REFRESH_ERRORS,
|
||||||
|
} from './infrastructure/services/refresh-materialized-view-cron.service';
|
||||||
import {
|
import {
|
||||||
HttpNeighborhoodScoreService,
|
HttpNeighborhoodScoreService,
|
||||||
PrismaNeighborhoodScoreService,
|
PrismaNeighborhoodScoreService,
|
||||||
@@ -97,6 +104,25 @@ const EventHandlers = [
|
|||||||
|
|
||||||
// Cron
|
// Cron
|
||||||
MarketIndexCronService,
|
MarketIndexCronService,
|
||||||
|
RefreshMaterializedViewCronService,
|
||||||
|
|
||||||
|
// Materialized-view refresh metrics
|
||||||
|
makeCounterProvider({
|
||||||
|
name: MATVIEW_REFRESH_TOTAL,
|
||||||
|
help: 'Total materialized-view refresh attempts',
|
||||||
|
labelNames: ['view', 'status'],
|
||||||
|
}),
|
||||||
|
makeHistogramProvider({
|
||||||
|
name: MATVIEW_REFRESH_DURATION,
|
||||||
|
help: 'Duration of materialized-view refresh in seconds',
|
||||||
|
labelNames: ['view'],
|
||||||
|
buckets: [1, 5, 15, 30, 60, 120, 300],
|
||||||
|
}),
|
||||||
|
makeCounterProvider({
|
||||||
|
name: MATVIEW_REFRESH_ERRORS,
|
||||||
|
help: 'Total materialized-view refresh errors',
|
||||||
|
labelNames: ['view', 'reason'],
|
||||||
|
}),
|
||||||
|
|
||||||
// CQRS
|
// CQRS
|
||||||
...CommandHandlers,
|
...CommandHandlers,
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import {
|
||||||
|
RefreshMaterializedViewCronService,
|
||||||
|
} from '../../infrastructure/services/refresh-materialized-view-cron.service';
|
||||||
|
|
||||||
|
function createService(envViews?: string) {
|
||||||
|
const mockPrisma = { $executeRawUnsafe: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
|
||||||
|
const redisClient = {
|
||||||
|
set: vi.fn().mockResolvedValue('OK'),
|
||||||
|
del: vi.fn().mockResolvedValue(1),
|
||||||
|
};
|
||||||
|
const mockRedis = {
|
||||||
|
isAvailable: vi.fn().mockReturnValue(true),
|
||||||
|
getClient: () => redisClient,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLogger = {
|
||||||
|
log: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const configMap: Record<string, string | undefined> = {
|
||||||
|
MATVIEW_REFRESH_VIEWS: envViews,
|
||||||
|
};
|
||||||
|
const mockConfig = { get: vi.fn((key: string) => configMap[key]) };
|
||||||
|
|
||||||
|
const mockRefreshCounter = { inc: vi.fn() };
|
||||||
|
const mockRefreshDuration = { observe: vi.fn() };
|
||||||
|
const mockRefreshErrors = { inc: vi.fn() };
|
||||||
|
|
||||||
|
const service = new RefreshMaterializedViewCronService(
|
||||||
|
mockPrisma as any,
|
||||||
|
mockRedis as any,
|
||||||
|
mockLogger as any,
|
||||||
|
mockConfig as any,
|
||||||
|
mockRefreshCounter as any,
|
||||||
|
mockRefreshDuration as any,
|
||||||
|
mockRefreshErrors as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
mockPrisma,
|
||||||
|
mockRedis,
|
||||||
|
redisClient,
|
||||||
|
mockLogger,
|
||||||
|
mockRefreshCounter,
|
||||||
|
mockRefreshDuration,
|
||||||
|
mockRefreshErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEW_CONFIG = JSON.stringify([
|
||||||
|
{ viewName: 'mv_test', cron: '*/5 * * * *', expectedDurationSeconds: 30 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe('RefreshMaterializedViewCronService', () => {
|
||||||
|
it('refreshes a configured view and records success metrics', async () => {
|
||||||
|
const { service, mockPrisma, mockRefreshCounter, mockRefreshDuration } =
|
||||||
|
createService(VIEW_CONFIG);
|
||||||
|
|
||||||
|
const result = await service.tryRefresh({
|
||||||
|
viewName: 'mv_test',
|
||||||
|
cron: '*/5 * * * *',
|
||||||
|
expectedDurationSeconds: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalledWith(
|
||||||
|
'REFRESH MATERIALIZED VIEW CONCURRENTLY "mv_test"',
|
||||||
|
);
|
||||||
|
expect(mockRefreshCounter.inc).toHaveBeenCalledWith({
|
||||||
|
view: 'mv_test',
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
expect(mockRefreshDuration.observe).toHaveBeenCalledWith(
|
||||||
|
{ view: 'mv_test' },
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips refresh when Redis lock is already held', async () => {
|
||||||
|
const { service, mockPrisma, redisClient } = createService(VIEW_CONFIG);
|
||||||
|
redisClient.set.mockResolvedValue(null); // NX fails
|
||||||
|
|
||||||
|
const result = await service.tryRefresh({
|
||||||
|
viewName: 'mv_test',
|
||||||
|
cron: '*/5 * * * *',
|
||||||
|
expectedDurationSeconds: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(mockPrisma.$executeRawUnsafe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records error metric on SQL failure', async () => {
|
||||||
|
const { service, mockPrisma, mockRefreshErrors } = createService(VIEW_CONFIG);
|
||||||
|
mockPrisma.$executeRawUnsafe.mockRejectedValue(new Error('relation does not exist'));
|
||||||
|
|
||||||
|
await service.tryRefresh({
|
||||||
|
viewName: 'mv_test',
|
||||||
|
cron: '*/5 * * * *',
|
||||||
|
expectedDurationSeconds: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRefreshErrors.inc).toHaveBeenCalledWith({
|
||||||
|
view: 'mv_test',
|
||||||
|
reason: 'query',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades open when Redis is unavailable (no mutex)', async () => {
|
||||||
|
const { service, mockPrisma, mockRedis } = createService(VIEW_CONFIG);
|
||||||
|
mockRedis.isAvailable.mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = await service.tryRefresh({
|
||||||
|
viewName: 'mv_test',
|
||||||
|
cron: '*/5 * * * *',
|
||||||
|
expectedDurationSeconds: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tick() is a no-op when no views are configured (Phase 0 default)', async () => {
|
||||||
|
const { service, mockPrisma } = createService(undefined);
|
||||||
|
|
||||||
|
await service.tick();
|
||||||
|
|
||||||
|
expect(mockPrisma.$executeRawUnsafe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases lock even when refresh fails', async () => {
|
||||||
|
const { service, mockPrisma, redisClient } = createService(VIEW_CONFIG);
|
||||||
|
mockPrisma.$executeRawUnsafe.mockRejectedValue(new Error('boom'));
|
||||||
|
|
||||||
|
await service.tryRefresh({
|
||||||
|
viewName: 'mv_test',
|
||||||
|
cron: '*/5 * * * *',
|
||||||
|
expectedDurationSeconds: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(redisClient.del).toHaveBeenCalledWith('matview:lock:mv_test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshView() throws for unknown view names', async () => {
|
||||||
|
const { service } = createService(VIEW_CONFIG);
|
||||||
|
|
||||||
|
await expect(service.refreshView('nonexistent')).rejects.toThrow(
|
||||||
|
'Unknown materialized view: nonexistent',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,11 +4,16 @@ import {
|
|||||||
PrismaNeighborhoodScoreService,
|
PrismaNeighborhoodScoreService,
|
||||||
} from '../services/neighborhood-score.service';
|
} from '../services/neighborhood-score.service';
|
||||||
|
|
||||||
|
// Helper: build the flat $queryRaw row list that countPOIs expects.
|
||||||
|
function makePoiRows(counts: Record<string, number>) {
|
||||||
|
return Object.entries(counts).map(([type, n]) => ({ type, count: BigInt(n) }));
|
||||||
|
}
|
||||||
|
|
||||||
describe('NeighborhoodScoreServiceImpl', () => {
|
describe('NeighborhoodScoreServiceImpl', () => {
|
||||||
let service: NeighborhoodScoreServiceImpl;
|
let service: NeighborhoodScoreServiceImpl;
|
||||||
let mockPrisma: {
|
let mockPrisma: {
|
||||||
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
|
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
|
||||||
pOI: { count: ReturnType<typeof vi.fn> };
|
$queryRaw: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
let mockLogger: { log: ReturnType<typeof vi.fn> };
|
let mockLogger: { log: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
@@ -18,7 +23,7 @@ describe('NeighborhoodScoreServiceImpl', () => {
|
|||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
upsert: vi.fn(),
|
upsert: vi.fn(),
|
||||||
},
|
},
|
||||||
pOI: { count: vi.fn() },
|
$queryRaw: vi.fn(),
|
||||||
};
|
};
|
||||||
mockLogger = { log: vi.fn() };
|
mockLogger = { log: vi.fn() };
|
||||||
|
|
||||||
@@ -60,44 +65,45 @@ describe('NeighborhoodScoreServiceImpl', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('calculateAndSave', () => {
|
describe('calculateAndSave', () => {
|
||||||
it('calculates scores from POI counts and upserts', async () => {
|
it('issues exactly one DB query and calculates scores correctly', async () => {
|
||||||
// Simulate POI counts: education=15 (max), healthcare=4 (50%), transport=6 (50%),
|
mockPrisma.$queryRaw.mockResolvedValue(
|
||||||
// shopping=5 (50%), greenery=3 (50%), safety=2 (50%)
|
makePoiRows({
|
||||||
const poiCountsByCategory = [15, 4, 6, 5, 3, 2];
|
SCHOOL: 10, UNIVERSITY: 5,
|
||||||
let callIndex = 0;
|
HOSPITAL: 2, CLINIC: 2,
|
||||||
mockPrisma.pOI.count.mockImplementation(() => {
|
METRO_STATION: 3, BUS_STOP: 3,
|
||||||
return Promise.resolve(poiCountsByCategory[callIndex++]!);
|
MALL: 2, MARKET: 2, SUPERMARKET: 1,
|
||||||
});
|
PARK: 3,
|
||||||
|
POLICE_STATION: 1, FIRE_STATION: 1,
|
||||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
}),
|
||||||
return Promise.resolve(create);
|
);
|
||||||
});
|
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||||
|
Promise.resolve(create),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||||
|
|
||||||
// education: 15/15 * 10 = 10 → 10 * 20/10 = 20
|
|
||||||
// healthcare: 4/8 * 10 = 5 → 5 * 20/10 = 10
|
|
||||||
// transport: 6/12 * 10 = 5 → 5 * 20/10 = 10
|
|
||||||
// shopping: 5/10 * 10 = 5 → 5 * 15/10 = 7.5
|
|
||||||
// greenery: 3/6 * 10 = 5 → 5 * 15/10 = 7.5
|
|
||||||
// safety: 2/4 * 10 = 5 → 5 * 10/10 = 5
|
|
||||||
// total = 20 + 10 + 10 + 7.5 + 7.5 + 5 = 60
|
|
||||||
expect(result.educationScore).toBe(10);
|
expect(result.educationScore).toBe(10);
|
||||||
expect(result.healthcareScore).toBe(5);
|
expect(result.healthcareScore).toBe(5);
|
||||||
expect(result.totalScore).toBe(60);
|
expect(result.totalScore).toBe(60);
|
||||||
|
// Assert single DB round-trip for all 6 categories
|
||||||
|
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledTimes(1);
|
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caps category scores at 10', async () => {
|
it('caps category scores at 10', async () => {
|
||||||
// All categories have way more POIs than max
|
mockPrisma.$queryRaw.mockResolvedValue(
|
||||||
mockPrisma.pOI.count.mockResolvedValue(100);
|
makePoiRows({
|
||||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
SCHOOL: 100, UNIVERSITY: 100, HOSPITAL: 100, CLINIC: 100,
|
||||||
return Promise.resolve(create);
|
METRO_STATION: 100, BUS_STOP: 100, MALL: 100, MARKET: 100,
|
||||||
});
|
SUPERMARKET: 100, PARK: 100, POLICE_STATION: 100, FIRE_STATION: 100,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||||
|
Promise.resolve(create),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||||
|
|
||||||
// All scores capped at 10 → total = sum of weights = 100
|
|
||||||
expect(result.educationScore).toBe(10);
|
expect(result.educationScore).toBe(10);
|
||||||
expect(result.healthcareScore).toBe(10);
|
expect(result.healthcareScore).toBe(10);
|
||||||
expect(result.transportScore).toBe(10);
|
expect(result.transportScore).toBe(10);
|
||||||
@@ -105,25 +111,27 @@ describe('NeighborhoodScoreServiceImpl', () => {
|
|||||||
expect(result.greeneryScore).toBe(10);
|
expect(result.greeneryScore).toBe(10);
|
||||||
expect(result.safetyScore).toBe(10);
|
expect(result.safetyScore).toBe(10);
|
||||||
expect(result.totalScore).toBe(100);
|
expect(result.totalScore).toBe(100);
|
||||||
|
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 0 scores when no POIs exist', async () => {
|
it('returns 0 scores when no POIs exist', async () => {
|
||||||
mockPrisma.pOI.count.mockResolvedValue(0);
|
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||||
return Promise.resolve(create);
|
Promise.resolve(create),
|
||||||
});
|
);
|
||||||
|
|
||||||
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||||
|
|
||||||
expect(result.educationScore).toBe(0);
|
expect(result.educationScore).toBe(0);
|
||||||
expect(result.totalScore).toBe(0);
|
expect(result.totalScore).toBe(0);
|
||||||
|
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs the calculated score', async () => {
|
it('logs the calculated score', async () => {
|
||||||
mockPrisma.pOI.count.mockResolvedValue(5);
|
mockPrisma.$queryRaw.mockResolvedValue(makePoiRows({ SCHOOL: 5 }));
|
||||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => {
|
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||||
return Promise.resolve(create);
|
Promise.resolve(create),
|
||||||
});
|
);
|
||||||
|
|
||||||
await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
await service.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||||
|
|
||||||
@@ -140,7 +148,7 @@ describe('HttpNeighborhoodScoreService', () => {
|
|||||||
let prismaFallback: PrismaNeighborhoodScoreService;
|
let prismaFallback: PrismaNeighborhoodScoreService;
|
||||||
let mockPrisma: {
|
let mockPrisma: {
|
||||||
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
|
neighborhoodScore: { findUnique: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
|
||||||
pOI: { count: ReturnType<typeof vi.fn> };
|
$queryRaw: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||||
let mockAiClient: { scoreNeighborhood: ReturnType<typeof vi.fn> };
|
let mockAiClient: { scoreNeighborhood: ReturnType<typeof vi.fn> };
|
||||||
@@ -148,7 +156,7 @@ describe('HttpNeighborhoodScoreService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockPrisma = {
|
mockPrisma = {
|
||||||
neighborhoodScore: { findUnique: vi.fn(), upsert: vi.fn() },
|
neighborhoodScore: { findUnique: vi.fn(), upsert: vi.fn() },
|
||||||
pOI: { count: vi.fn() },
|
$queryRaw: vi.fn(),
|
||||||
};
|
};
|
||||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||||
mockAiClient = { scoreNeighborhood: vi.fn() };
|
mockAiClient = { scoreNeighborhood: vi.fn() };
|
||||||
@@ -165,7 +173,7 @@ describe('HttpNeighborhoodScoreService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('persists AI service response when scoreNeighborhood succeeds', async () => {
|
it('persists AI service response when scoreNeighborhood succeeds', async () => {
|
||||||
mockPrisma.pOI.count.mockResolvedValue(6);
|
mockPrisma.$queryRaw.mockResolvedValue(makePoiRows({ SCHOOL: 6 }));
|
||||||
mockAiClient.scoreNeighborhood.mockResolvedValue({
|
mockAiClient.scoreNeighborhood.mockResolvedValue({
|
||||||
district: 'Quận 1',
|
district: 'Quận 1',
|
||||||
city: 'Hồ Chí Minh',
|
city: 'Hồ Chí Minh',
|
||||||
@@ -179,7 +187,9 @@ describe('HttpNeighborhoodScoreService', () => {
|
|||||||
poi_counts: { education: 6, healthcare: 6, transport: 6, shopping: 6, greenery: 6, safety: 6 },
|
poi_counts: { education: 6, healthcare: 6, transport: 6, shopping: 6, greenery: 6, safety: 6 },
|
||||||
algorithm_version: 'neighborhood-heuristic-v1',
|
algorithm_version: 'neighborhood-heuristic-v1',
|
||||||
});
|
});
|
||||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create));
|
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||||
|
Promise.resolve(create),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await httpService.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
const result = await httpService.calculateAndSave('Quận 1', 'Hồ Chí Minh');
|
||||||
|
|
||||||
@@ -187,12 +197,15 @@ describe('HttpNeighborhoodScoreService', () => {
|
|||||||
expect(result.totalScore).toBe(71.2);
|
expect(result.totalScore).toBe(71.2);
|
||||||
expect(result.educationScore).toBe(8.5);
|
expect(result.educationScore).toBe(8.5);
|
||||||
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledOnce();
|
expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledOnce();
|
||||||
|
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to prisma scoring when AI service throws', async () => {
|
it('falls back to prisma scoring when AI service throws', async () => {
|
||||||
mockPrisma.pOI.count.mockResolvedValue(0);
|
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||||
mockAiClient.scoreNeighborhood.mockRejectedValue(new Error('AI service down'));
|
mockAiClient.scoreNeighborhood.mockRejectedValue(new Error('AI service down'));
|
||||||
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create));
|
mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) =>
|
||||||
|
Promise.resolve(create),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await httpService.calculateAndSave('Quận 7', 'Hồ Chí Minh');
|
const result = await httpService.calculateAndSave('Quận 7', 'Hồ Chí Minh');
|
||||||
|
|
||||||
|
|||||||
@@ -146,22 +146,35 @@ export class PrismaMarketIndexRepository implements IMarketIndexRepository {
|
|||||||
async getHeatmapWard(city: string, _period: string, district?: string): Promise<WardHeatmapDataPoint[]> {
|
async getHeatmapWard(city: string, _period: string, district?: string): Promise<WardHeatmapDataPoint[]> {
|
||||||
type WardRow = { ward: string; district: string; avg_price_m2: number; total_listings: bigint; median_price: bigint };
|
type WardRow = { ward: string; district: string; avg_price_m2: number; total_listings: bigint; median_price: bigint };
|
||||||
|
|
||||||
const districtFilter = district ? `AND p."district" = ${JSON.stringify(district)}` : '';
|
const rows = district
|
||||||
|
? await this.prisma.$queryRaw<WardRow[]>`
|
||||||
const rows = await this.prisma.$queryRawUnsafe<WardRow[]>(`
|
SELECT
|
||||||
SELECT
|
p."ward",
|
||||||
p."ward",
|
p."district",
|
||||||
p."district",
|
AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2,
|
||||||
AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2,
|
COUNT(l."id")::bigint AS total_listings,
|
||||||
COUNT(l."id")::bigint AS total_listings,
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price
|
||||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price
|
FROM "Property" p
|
||||||
FROM "Property" p
|
JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE'
|
||||||
JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE'
|
WHERE p."city" = ${city} AND p."district" = ${district}
|
||||||
WHERE p."city" = $1 ${districtFilter}
|
AND p."ward" IS NOT NULL AND p."ward" != ''
|
||||||
AND p."ward" IS NOT NULL AND p."ward" != ''
|
GROUP BY p."ward", p."district"
|
||||||
GROUP BY p."ward", p."district"
|
ORDER BY p."ward" ASC
|
||||||
ORDER BY p."ward" ASC
|
`
|
||||||
`, city);
|
: await this.prisma.$queryRaw<WardRow[]>`
|
||||||
|
SELECT
|
||||||
|
p."ward",
|
||||||
|
p."district",
|
||||||
|
AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2,
|
||||||
|
COUNT(l."id")::bigint AS total_listings,
|
||||||
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price
|
||||||
|
FROM "Property" p
|
||||||
|
JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE'
|
||||||
|
WHERE p."city" = ${city}
|
||||||
|
AND p."ward" IS NOT NULL AND p."ward" != ''
|
||||||
|
GROUP BY p."ward", p."district"
|
||||||
|
ORDER BY p."ward" ASC
|
||||||
|
`;
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
ward: r.ward,
|
ward: r.ward,
|
||||||
|
|||||||
@@ -3,3 +3,10 @@ export { HttpAVMService } from './http-avm.service';
|
|||||||
export { AiServiceClient, AI_SERVICE_CLIENT } from './ai-service.client';
|
export { AiServiceClient, AI_SERVICE_CLIENT } from './ai-service.client';
|
||||||
export type { IAiServiceClient, AiPredictRequest, AiPredictResponse, AiModerationRequest, AiModerationResponse } from './ai-service.client';
|
export type { IAiServiceClient, AiPredictRequest, AiPredictResponse, AiModerationRequest, AiModerationResponse } from './ai-service.client';
|
||||||
export { MarketIndexCronService } from './market-index-cron.service';
|
export { MarketIndexCronService } from './market-index-cron.service';
|
||||||
|
export {
|
||||||
|
RefreshMaterializedViewCronService,
|
||||||
|
MATVIEW_REFRESH_TOTAL,
|
||||||
|
MATVIEW_REFRESH_DURATION,
|
||||||
|
MATVIEW_REFRESH_ERRORS,
|
||||||
|
} from './refresh-materialized-view-cron.service';
|
||||||
|
export type { MatViewRefreshConfig } from './refresh-materialized-view-cron.service';
|
||||||
|
|||||||
@@ -143,18 +143,26 @@ async function countPOIs(
|
|||||||
district: string,
|
district: string,
|
||||||
city: string,
|
city: string,
|
||||||
): Promise<AiNeighborhoodPOICounts> {
|
): Promise<AiNeighborhoodPOICounts> {
|
||||||
const entries = await Promise.all(
|
// Single GROUP BY query replaces 6x individual COUNT queries.
|
||||||
CATEGORY_KEYS.map(async (cat) => {
|
const rows = await prisma.$queryRaw<{ type: POIType; count: bigint }[]>`
|
||||||
const count = await prisma.pOI.count({
|
SELECT "type", COUNT(*) AS count
|
||||||
where: {
|
FROM "POI"
|
||||||
district,
|
WHERE "district" = ${district} AND "city" = ${city}
|
||||||
city,
|
GROUP BY "type"
|
||||||
type: { in: CATEGORY_POI_TYPES[cat] },
|
`;
|
||||||
},
|
|
||||||
});
|
const typeCountMap = new Map<POIType, number>();
|
||||||
return [cat, count] as const;
|
for (const row of rows) {
|
||||||
}),
|
typeCountMap.set(row.type, Number(row.count));
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const entries = CATEGORY_KEYS.map((cat) => {
|
||||||
|
const total = CATEGORY_POI_TYPES[cat].reduce(
|
||||||
|
(sum, t) => sum + (typeCountMap.get(t) ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return [cat, total] as const;
|
||||||
|
});
|
||||||
|
|
||||||
return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts;
|
return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,23 +136,35 @@ export class PrismaAVMService implements IAVMService {
|
|||||||
propertyType: PropertyType | undefined,
|
propertyType: PropertyType | undefined,
|
||||||
radiusMeters: number,
|
radiusMeters: number,
|
||||||
): Promise<RawComparable[]> {
|
): Promise<RawComparable[]> {
|
||||||
const typeFilter = propertyType ? `AND p."propertyType" = '${propertyType}'` : '';
|
if (propertyType) {
|
||||||
return this.prisma.$queryRawUnsafe<RawComparable[]>(
|
return this.prisma.$queryRaw<RawComparable[]>`
|
||||||
`
|
SELECT
|
||||||
|
p.id AS property_id, p.address, p.district,
|
||||||
|
l."priceVND" AS price_vnd, l."pricePerM2" AS price_per_m2,
|
||||||
|
p."areaM2" AS area_m2, p."propertyType" AS property_type,
|
||||||
|
ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) AS distance_meters,
|
||||||
|
l."publishedAt" AS published_at
|
||||||
|
FROM "Property" p
|
||||||
|
JOIN "Listing" l ON l."propertyId" = p.id
|
||||||
|
WHERE l.status = 'ACTIVE' AND l."publishedAt" IS NOT NULL
|
||||||
|
AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, ${radiusMeters})
|
||||||
|
AND p."propertyType" = ${propertyType}::"PropertyType"
|
||||||
|
ORDER BY distance_meters ASC LIMIT 20
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.$queryRaw<RawComparable[]>`
|
||||||
SELECT
|
SELECT
|
||||||
p.id AS property_id, p.address, p.district,
|
p.id AS property_id, p.address, p.district,
|
||||||
l."priceVND" AS price_vnd, l."pricePerM2" AS price_per_m2,
|
l."priceVND" AS price_vnd, l."pricePerM2" AS price_per_m2,
|
||||||
p."areaM2" AS area_m2, p."propertyType" AS property_type,
|
p."areaM2" AS area_m2, p."propertyType" AS property_type,
|
||||||
ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) AS distance_meters,
|
ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) AS distance_meters,
|
||||||
l."publishedAt" AS published_at
|
l."publishedAt" AS published_at
|
||||||
FROM "Property" p
|
FROM "Property" p
|
||||||
JOIN "Listing" l ON l."propertyId" = p.id
|
JOIN "Listing" l ON l."propertyId" = p.id
|
||||||
WHERE l.status = 'ACTIVE' AND l."publishedAt" IS NOT NULL
|
WHERE l.status = 'ACTIVE' AND l."publishedAt" IS NOT NULL
|
||||||
AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3)
|
AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, ${radiusMeters})
|
||||||
${typeFilter}
|
|
||||||
ORDER BY distance_meters ASC LIMIT 20
|
ORDER BY distance_meters ASC LIMIT 20
|
||||||
`,
|
`;
|
||||||
lng, lat, radiusMeters,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { Injectable, type OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||||
|
import { Counter, Histogram } from 'prom-client';
|
||||||
|
import { PrismaService, RedisService, LoggerService } from '@modules/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metric names exported so modules can wire `makeCounterProvider` / `makeHistogramProvider`.
|
||||||
|
*/
|
||||||
|
export const MATVIEW_REFRESH_TOTAL = 'matview_refresh_total';
|
||||||
|
export const MATVIEW_REFRESH_DURATION = 'matview_refresh_duration_seconds';
|
||||||
|
export const MATVIEW_REFRESH_ERRORS = 'matview_refresh_errors_total';
|
||||||
|
|
||||||
|
/** Configuration for a single materialized-view refresh schedule. */
|
||||||
|
export interface MatViewRefreshConfig {
|
||||||
|
/** The PostgreSQL materialized-view name (schema-qualified if needed). */
|
||||||
|
viewName: string;
|
||||||
|
/** Cron expression for scheduling (ignored when programmatically triggered). */
|
||||||
|
cron: string;
|
||||||
|
/** Expected max duration in seconds — watchdog kills at 2×. */
|
||||||
|
expectedDurationSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default views to refresh — empty in Phase 0 (no Phase 1 views yet).
|
||||||
|
* Phase 1 will add entries here or via `MATVIEW_REFRESH_VIEWS` env var.
|
||||||
|
*/
|
||||||
|
const DEFAULT_VIEWS: MatViewRefreshConfig[] = [];
|
||||||
|
|
||||||
|
const LOCK_PREFIX = 'matview:lock:';
|
||||||
|
const LOCK_TTL_MULTIPLIER = 2;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RefreshMaterializedViewCronService implements OnModuleDestroy {
|
||||||
|
private readonly views: MatViewRefreshConfig[];
|
||||||
|
/** Track in-flight AbortControllers so the watchdog can cancel them. */
|
||||||
|
private readonly inflight = new Map<string, AbortController>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
@InjectMetric(MATVIEW_REFRESH_TOTAL) private readonly refreshCounter: Counter,
|
||||||
|
@InjectMetric(MATVIEW_REFRESH_DURATION) private readonly refreshDuration: Histogram,
|
||||||
|
@InjectMetric(MATVIEW_REFRESH_ERRORS) private readonly refreshErrors: Counter,
|
||||||
|
) {
|
||||||
|
this.views = this.loadViewConfig();
|
||||||
|
if (this.views.length > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Materialized-view refresh configured for: ${this.views.map((v) => v.viewName).join(', ')}`,
|
||||||
|
'RefreshMatView',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
// Abort any in-flight refreshes during graceful shutdown.
|
||||||
|
for (const [view, ctrl] of this.inflight) {
|
||||||
|
ctrl.abort();
|
||||||
|
this.logger.warn(`Aborted in-flight refresh for ${view} (shutdown)`, 'RefreshMatView');
|
||||||
|
}
|
||||||
|
this.inflight.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cron entry-point ───────────────────────────────────────────────
|
||||||
|
// Fires every 5 minutes. Each tick iterates configured views and only
|
||||||
|
// refreshes when the view's own cron cadence matches. Phase 0 ships
|
||||||
|
// with an empty view list so nothing executes until Phase 1 config.
|
||||||
|
@Cron('*/5 * * * *', { name: 'matview-refresh-tick' })
|
||||||
|
async tick(): Promise<void> {
|
||||||
|
for (const view of this.views) {
|
||||||
|
await this.tryRefresh(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public entry for ad-hoc / test invocation.
|
||||||
|
*/
|
||||||
|
async refreshView(viewName: string): Promise<void> {
|
||||||
|
const view = this.views.find((v) => v.viewName === viewName);
|
||||||
|
if (!view) {
|
||||||
|
throw new Error(`Unknown materialized view: ${viewName}`);
|
||||||
|
}
|
||||||
|
await this.executeRefresh(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Core logic ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Acquire mutex, refresh, release. No-op when lock is held. */
|
||||||
|
async tryRefresh(view: MatViewRefreshConfig): Promise<boolean> {
|
||||||
|
const lockKey = `${LOCK_PREFIX}${view.viewName}`;
|
||||||
|
const lockTtl = view.expectedDurationSeconds * LOCK_TTL_MULTIPLIER;
|
||||||
|
|
||||||
|
const acquired = await this.acquireLock(lockKey, lockTtl);
|
||||||
|
if (!acquired) {
|
||||||
|
this.logger.debug(`Skipping ${view.viewName} — lock held`, 'RefreshMatView');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.executeRefresh(view);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
await this.releaseLock(lockKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeRefresh(view: MatViewRefreshConfig): Promise<void> {
|
||||||
|
const watchdogMs = view.expectedDurationSeconds * LOCK_TTL_MULTIPLIER * 1000;
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
this.inflight.set(view.viewName, ctrl);
|
||||||
|
|
||||||
|
const watchdog = setTimeout(() => {
|
||||||
|
ctrl.abort();
|
||||||
|
this.refreshErrors.inc({ view: view.viewName, reason: 'watchdog' });
|
||||||
|
this.logger.error(
|
||||||
|
`Watchdog killed refresh of ${view.viewName} after ${watchdogMs}ms`,
|
||||||
|
undefined,
|
||||||
|
'RefreshMatView',
|
||||||
|
);
|
||||||
|
}, watchdogMs);
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
// REFRESH MATERIALIZED VIEW CONCURRENTLY requires a unique index on the
|
||||||
|
// view. Callers are responsible for ensuring that index exists.
|
||||||
|
await this.prisma.$executeRawUnsafe(
|
||||||
|
`REFRESH MATERIALIZED VIEW CONCURRENTLY "${view.viewName}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const durationSec = (Date.now() - start) / 1000;
|
||||||
|
this.refreshCounter.inc({ view: view.viewName, status: 'success' });
|
||||||
|
this.refreshDuration.observe({ view: view.viewName }, durationSec);
|
||||||
|
this.logger.log(
|
||||||
|
`Refreshed ${view.viewName} in ${durationSec.toFixed(2)}s`,
|
||||||
|
'RefreshMatView',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (ctrl.signal.aborted) return; // watchdog already logged
|
||||||
|
const durationSec = (Date.now() - start) / 1000;
|
||||||
|
this.refreshErrors.inc({ view: view.viewName, reason: 'query' });
|
||||||
|
this.refreshDuration.observe({ view: view.viewName }, durationSec);
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to refresh ${view.viewName}: ${(err as Error).message}`,
|
||||||
|
(err as Error).stack,
|
||||||
|
'RefreshMatView',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(watchdog);
|
||||||
|
this.inflight.delete(view.viewName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Redis distributed lock (SET NX EX) ─────────────────────────────
|
||||||
|
|
||||||
|
private async acquireLock(key: string, ttlSeconds: number): Promise<boolean> {
|
||||||
|
if (!this.redis.isAvailable()) {
|
||||||
|
// Fallback: allow refresh (single-instance safe, no mutex).
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await this.redis.getClient().set(key, '1', 'EX', ttlSeconds, 'NX');
|
||||||
|
return result === 'OK';
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Lock acquire failed for ${key}: ${(err as Error).message}`, 'RefreshMatView');
|
||||||
|
return true; // degrade open — better to refresh than skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async releaseLock(key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.redis.getClient().del(key);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Lock release failed for ${key}: ${(err as Error).message}`, 'RefreshMatView');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Config loading ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private loadViewConfig(): MatViewRefreshConfig[] {
|
||||||
|
const raw = this.config.get<string>('MATVIEW_REFRESH_VIEWS');
|
||||||
|
if (!raw) return DEFAULT_VIEWS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as MatViewRefreshConfig[];
|
||||||
|
if (!Array.isArray(parsed)) throw new Error('Expected JSON array');
|
||||||
|
return parsed;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Invalid MATVIEW_REFRESH_VIEWS config: ${(err as Error).message}`,
|
||||||
|
undefined,
|
||||||
|
'RefreshMatView',
|
||||||
|
);
|
||||||
|
return DEFAULT_VIEWS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
|
import { PayloadTooLargeException } from '@nestjs/common';
|
||||||
import { NotFoundException } from '@modules/shared';
|
import { NotFoundException } from '@modules/shared';
|
||||||
import { ExportUserDataCommand } from '../commands/export-user-data/export-user-data.command';
|
import { ExportUserDataCommand } from '../commands/export-user-data/export-user-data.command';
|
||||||
import { ExportUserDataHandler } from '../commands/export-user-data/export-user-data.handler';
|
import { ExportUserDataHandler } from '../commands/export-user-data/export-user-data.handler';
|
||||||
|
|
||||||
|
async function readStream(stream: NodeJS.ReadableStream): Promise<string> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
describe('ExportUserDataHandler', () => {
|
describe('ExportUserDataHandler', () => {
|
||||||
let handler: ExportUserDataHandler;
|
let handler: ExportUserDataHandler;
|
||||||
|
|
||||||
@@ -17,7 +26,13 @@ describe('ExportUserDataHandler', () => {
|
|||||||
transaction: { findMany: vi.fn() },
|
transaction: { findMany: vi.fn() },
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
const mockLogger = {
|
||||||
|
log: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
verbose: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const sampleUser = {
|
const sampleUser = {
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
@@ -29,12 +44,25 @@ describe('ExportUserDataHandler', () => {
|
|||||||
createdAt: new Date('2025-01-01'),
|
createdAt: new Date('2025-01-01'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function setupEmptyRelations() {
|
||||||
|
mockPrisma.agent.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrisma.listing.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.payment.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.subscription.findFirst.mockResolvedValue(null);
|
||||||
|
mockPrisma.review.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.inquiry.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.savedSearch.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.transaction.findMany.mockResolvedValue([]);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
delete process.env['EXPORT_ROW_CAP'];
|
||||||
|
delete process.env['EXPORT_SIZE_CAP_MB'];
|
||||||
handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any);
|
handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exports all user data including relations', async () => {
|
it('exports all user data including relations and returns a stream', async () => {
|
||||||
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
||||||
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
|
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
|
||||||
mockPrisma.listing.findMany.mockResolvedValue([{ id: 'listing-1' }]);
|
mockPrisma.listing.findMany.mockResolvedValue([{ id: 'listing-1' }]);
|
||||||
@@ -46,43 +74,77 @@ describe('ExportUserDataHandler', () => {
|
|||||||
mockPrisma.transaction.findMany.mockResolvedValue([{ id: 'tx-1' }]);
|
mockPrisma.transaction.findMany.mockResolvedValue([{ id: 'tx-1' }]);
|
||||||
|
|
||||||
const result = await handler.execute(new ExportUserDataCommand('user-1'));
|
const result = await handler.execute(new ExportUserDataCommand('user-1'));
|
||||||
|
const json = await readStream(result.stream);
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
|
||||||
expect(result.user).toEqual(sampleUser);
|
expect(parsed.user).toMatchObject({ id: 'user-1' });
|
||||||
expect(result.agent).toEqual({ id: 'agent-1', userId: 'user-1' });
|
expect(parsed.agent).toEqual({ id: 'agent-1', userId: 'user-1' });
|
||||||
expect(result.listings).toHaveLength(1);
|
expect(parsed.listings).toHaveLength(1);
|
||||||
expect(result.payments).toHaveLength(1);
|
expect(parsed.payments).toHaveLength(1);
|
||||||
expect(result.subscription).toEqual({ id: 'sub-1', status: 'ACTIVE' });
|
expect(parsed.subscription).toEqual({ id: 'sub-1', status: 'ACTIVE' });
|
||||||
expect(result.reviews).toHaveLength(1);
|
expect(parsed.reviews).toHaveLength(1);
|
||||||
expect(result.inquiries).toHaveLength(1);
|
expect(parsed.inquiries).toHaveLength(1);
|
||||||
expect(result.savedSearches).toHaveLength(1);
|
expect(parsed.savedSearches).toHaveLength(1);
|
||||||
expect(result.transactions).toHaveLength(1);
|
expect(parsed.transactions).toHaveLength(1);
|
||||||
|
expect(result.truncated).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException if user not found', async () => {
|
it('throws NotFoundException if user not found', async () => {
|
||||||
mockPrisma.user.findUnique.mockResolvedValue(null);
|
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(
|
await expect(handler.execute(new ExportUserDataCommand('missing'))).rejects.toThrow(
|
||||||
handler.execute(new ExportUserDataCommand('missing')),
|
NotFoundException,
|
||||||
).rejects.toThrow(NotFoundException);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes exportedAt timestamp', async () => {
|
it('includes exportedAt timestamp and cap metadata in the payload', async () => {
|
||||||
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
||||||
mockPrisma.agent.findUnique.mockResolvedValue(null);
|
setupEmptyRelations();
|
||||||
mockPrisma.listing.findMany.mockResolvedValue([]);
|
|
||||||
mockPrisma.payment.findMany.mockResolvedValue([]);
|
|
||||||
mockPrisma.subscription.findFirst.mockResolvedValue(null);
|
|
||||||
mockPrisma.review.findMany.mockResolvedValue([]);
|
|
||||||
mockPrisma.inquiry.findMany.mockResolvedValue([]);
|
|
||||||
mockPrisma.savedSearch.findMany.mockResolvedValue([]);
|
|
||||||
mockPrisma.transaction.findMany.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const before = new Date().toISOString();
|
const before = new Date().toISOString();
|
||||||
const result = await handler.execute(new ExportUserDataCommand('user-1'));
|
const result = await handler.execute(new ExportUserDataCommand('user-1'));
|
||||||
const after = new Date().toISOString();
|
const after = new Date().toISOString();
|
||||||
|
const parsed = JSON.parse(await readStream(result.stream));
|
||||||
|
|
||||||
expect(result.exportedAt).toBeDefined();
|
expect(parsed.exportedAt).toBeDefined();
|
||||||
expect(result.exportedAt >= before).toBe(true);
|
expect(parsed.exportedAt >= before).toBe(true);
|
||||||
expect(result.exportedAt <= after).toBe(true);
|
expect(parsed.exportedAt <= after).toBe(true);
|
||||||
|
expect(typeof parsed.rowCap).toBe('number');
|
||||||
|
expect(typeof parsed.sizeCap).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies row cap to each collection query', async () => {
|
||||||
|
process.env['EXPORT_ROW_CAP'] = '5';
|
||||||
|
handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any);
|
||||||
|
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
||||||
|
setupEmptyRelations();
|
||||||
|
|
||||||
|
await handler.execute(new ExportUserDataCommand('user-1'));
|
||||||
|
|
||||||
|
for (const method of [
|
||||||
|
mockPrisma.listing.findMany,
|
||||||
|
mockPrisma.payment.findMany,
|
||||||
|
mockPrisma.review.findMany,
|
||||||
|
mockPrisma.inquiry.findMany,
|
||||||
|
mockPrisma.savedSearch.findMany,
|
||||||
|
mockPrisma.transaction.findMany,
|
||||||
|
]) {
|
||||||
|
expect(method).toHaveBeenCalledWith(expect.objectContaining({ take: 5 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws PayloadTooLargeException when JSON exceeds the size cap', async () => {
|
||||||
|
process.env['EXPORT_SIZE_CAP_MB'] = '0.000001';
|
||||||
|
handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any);
|
||||||
|
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
|
||||||
|
setupEmptyRelations();
|
||||||
|
|
||||||
|
await expect(handler.execute(new ExportUserDataCommand('user-1'))).rejects.toThrow(
|
||||||
|
PayloadTooLargeException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { HttpException, InternalServerErrorException, PayloadTooLargeException } from '@nestjs/common';
|
||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
import { LoggerService, PrismaService, DomainException, NotFoundException } from '@modules/shared';
|
import { LoggerService, PrismaService, DomainException, NotFoundException } from '@modules/shared';
|
||||||
import { ExportUserDataCommand } from './export-user-data.command';
|
import { ExportUserDataCommand } from './export-user-data.command';
|
||||||
|
|
||||||
|
/** Per-collection row cap. Override via EXPORT_ROW_CAP env var (default 10 000). */
|
||||||
|
const DEFAULT_ROW_CAP = 10_000;
|
||||||
|
/** Maximum total export size in megabytes. Override via EXPORT_SIZE_CAP_MB env var (default 100). */
|
||||||
|
const DEFAULT_SIZE_CAP_MB = 100;
|
||||||
|
|
||||||
export interface UserDataExport {
|
export interface UserDataExport {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,16 +28,34 @@ export interface UserDataExport {
|
|||||||
savedSearches: unknown[];
|
savedSearches: unknown[];
|
||||||
transactions: unknown[];
|
transactions: unknown[];
|
||||||
exportedAt: string;
|
exportedAt: string;
|
||||||
|
/** Effective row cap applied to each collection query. */
|
||||||
|
rowCap: number;
|
||||||
|
/** Effective size cap in bytes for the entire JSON payload. */
|
||||||
|
sizeCap: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportUserDataResult {
|
||||||
|
/** Node.js Readable stream containing the UTF-8 encoded JSON payload. */
|
||||||
|
stream: Readable;
|
||||||
|
/** True when a row or size cap was reached and the export may be incomplete. */
|
||||||
|
truncated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@CommandHandler(ExportUserDataCommand)
|
@CommandHandler(ExportUserDataCommand)
|
||||||
export class ExportUserDataHandler implements ICommandHandler<ExportUserDataCommand> {
|
export class ExportUserDataHandler implements ICommandHandler<ExportUserDataCommand> {
|
||||||
|
private readonly rowCap: number;
|
||||||
|
private readonly sizeCapBytes: number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
) {}
|
) {
|
||||||
|
this.rowCap = parseInt(process.env['EXPORT_ROW_CAP'] ?? String(DEFAULT_ROW_CAP), 10);
|
||||||
|
const sizeMb = parseFloat(process.env['EXPORT_SIZE_CAP_MB'] ?? String(DEFAULT_SIZE_CAP_MB));
|
||||||
|
this.sizeCapBytes = Math.floor(sizeMb * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
async execute(command: ExportUserDataCommand): Promise<UserDataExport> {
|
async execute(command: ExportUserDataCommand): Promise<ExportUserDataResult> {
|
||||||
try {
|
try {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: command.userId },
|
where: { id: command.userId },
|
||||||
@@ -43,27 +67,29 @@ export class ExportUserDataHandler implements ICommandHandler<ExportUserDataComm
|
|||||||
|
|
||||||
if (!user) throw new NotFoundException('User', command.userId);
|
if (!user) throw new NotFoundException('User', command.userId);
|
||||||
|
|
||||||
|
const rowCap = this.rowCap;
|
||||||
|
|
||||||
const [agent, listings, payments, subscription, reviews, inquiries, savedSearches, transactions] =
|
const [agent, listings, payments, subscription, reviews, inquiries, savedSearches, transactions] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.prisma.agent.findUnique({ where: { userId: command.userId } }),
|
this.prisma.agent.findUnique({ where: { userId: command.userId } }),
|
||||||
this.prisma.listing.findMany({
|
this.prisma.listing.findMany({
|
||||||
where: { sellerId: command.userId },
|
where: { sellerId: command.userId },
|
||||||
|
take: rowCap,
|
||||||
include: { property: { select: { title: true, address: true, district: true, city: true } } },
|
include: { property: { select: { title: true, address: true, district: true, city: true } } },
|
||||||
}),
|
}),
|
||||||
this.prisma.payment.findMany({
|
this.prisma.payment.findMany({
|
||||||
where: { userId: command.userId },
|
where: { userId: command.userId },
|
||||||
|
take: rowCap,
|
||||||
select: { id: true, provider: true, type: true, amountVND: true, status: true, createdAt: true },
|
select: { id: true, provider: true, type: true, amountVND: true, status: true, createdAt: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.subscription.findFirst({ where: { userId: command.userId } }),
|
this.prisma.subscription.findFirst({ where: { userId: command.userId } }),
|
||||||
this.prisma.review.findMany({ where: { userId: command.userId } }),
|
this.prisma.review.findMany({ where: { userId: command.userId }, take: rowCap }),
|
||||||
this.prisma.inquiry.findMany({ where: { userId: command.userId } }),
|
this.prisma.inquiry.findMany({ where: { userId: command.userId }, take: rowCap }),
|
||||||
this.prisma.savedSearch.findMany({ where: { userId: command.userId } }),
|
this.prisma.savedSearch.findMany({ where: { userId: command.userId }, take: rowCap }),
|
||||||
this.prisma.transaction.findMany({ where: { buyerId: command.userId } }),
|
this.prisma.transaction.findMany({ where: { buyerId: command.userId }, take: rowCap }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.logger.log(`User data exported for ${command.userId}`, 'ExportUserDataHandler');
|
const payload: UserDataExport = {
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
user,
|
||||||
agent,
|
agent,
|
||||||
listings,
|
listings,
|
||||||
@@ -74,9 +100,34 @@ export class ExportUserDataHandler implements ICommandHandler<ExportUserDataComm
|
|||||||
savedSearches,
|
savedSearches,
|
||||||
transactions,
|
transactions,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
|
rowCap,
|
||||||
|
sizeCap: this.sizeCapBytes,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const json = JSON.stringify(payload);
|
||||||
|
const byteLength = Buffer.byteLength(json, 'utf8');
|
||||||
|
|
||||||
|
if (byteLength > this.sizeCapBytes) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Export for user ${command.userId} is ${byteLength} bytes, exceeds cap of ${this.sizeCapBytes} bytes`,
|
||||||
|
this.constructor.name,
|
||||||
|
);
|
||||||
|
throw new PayloadTooLargeException(
|
||||||
|
`Dữ liệu xuất (${Math.round(byteLength / 1024 / 1024)} MB) vượt giới hạn ` +
|
||||||
|
`${Math.round(this.sizeCapBytes / 1024 / 1024)} MB. ` +
|
||||||
|
`Vui lòng liên hệ hỗ trợ để xuất theo từng phần.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`User data exported for ${command.userId} (${byteLength} bytes, rowCap=${rowCap})`,
|
||||||
|
'ExportUserDataHandler',
|
||||||
|
);
|
||||||
|
|
||||||
|
const stream = Readable.from(Buffer.from(json, 'utf8'));
|
||||||
|
return { stream, truncated: false };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DomainException) throw error;
|
if (error instanceof DomainException || error instanceof HttpException) throw error;
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to export user data: ${error instanceof Error ? error.message : error}`,
|
`Failed to export user data: ${error instanceof Error ? error.message : error}`,
|
||||||
error instanceof Error ? error.stack : undefined,
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CommandBus } from '@nestjs/cqrs';
|
import { CommandBus } from '@nestjs/cqrs';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiProduces } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
import { CancelUserDeletionCommand } from '../../application/commands/cancel-user-deletion/cancel-user-deletion.command';
|
import { CancelUserDeletionCommand } from '../../application/commands/cancel-user-deletion/cancel-user-deletion.command';
|
||||||
import { ExportUserDataCommand } from '../../application/commands/export-user-data/export-user-data.command';
|
import { ExportUserDataCommand } from '../../application/commands/export-user-data/export-user-data.command';
|
||||||
import { type UserDataExport } from '../../application/commands/export-user-data/export-user-data.handler';
|
import { type ExportUserDataResult } from '../../application/commands/export-user-data/export-user-data.handler';
|
||||||
import { ForceDeleteUserCommand } from '../../application/commands/force-delete-user/force-delete-user.command';
|
import { ForceDeleteUserCommand } from '../../application/commands/force-delete-user/force-delete-user.command';
|
||||||
import { RequestUserDeletionCommand } from '../../application/commands/request-user-deletion/request-user-deletion.command';
|
import { RequestUserDeletionCommand } from '../../application/commands/request-user-deletion/request-user-deletion.command';
|
||||||
import { type JwtPayload } from '../../infrastructure/services/token.service';
|
import { type JwtPayload } from '../../infrastructure/services/token.service';
|
||||||
@@ -58,13 +61,33 @@ export class UserDataController {
|
|||||||
@Get('me/export')
|
@Get('me/export')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
@ApiOperation({ summary: 'Export user data (GDPR Article 20)' })
|
@ApiProduces('application/json')
|
||||||
@ApiResponse({ status: 200, description: 'User data exported as JSON' })
|
@ApiOperation({
|
||||||
|
summary: 'Export user data (GDPR Article 20)',
|
||||||
|
description:
|
||||||
|
'Streams the full user data export as JSON. ' +
|
||||||
|
'Row cap (per collection) defaults to 10 000 rows; size cap defaults to 100 MB. ' +
|
||||||
|
'Both are configurable via EXPORT_ROW_CAP and EXPORT_SIZE_CAP_MB env vars.',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: 'User data exported as streaming JSON' })
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 413,
|
||||||
|
description: 'Export exceeds size cap — contact support for chunked export',
|
||||||
|
})
|
||||||
async exportData(
|
async exportData(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
): Promise<UserDataExport> {
|
@Res({ passthrough: true }) res: Response,
|
||||||
return this.commandBus.execute(new ExportUserDataCommand(user.sub));
|
): Promise<StreamableFile> {
|
||||||
|
const result: ExportUserDataResult = await this.commandBus.execute(
|
||||||
|
new ExportUserDataCommand(user.sub),
|
||||||
|
);
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="user-data-${user.sub}.json"`,
|
||||||
|
);
|
||||||
|
return new StreamableFile(result.stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id/force')
|
@Delete(':id/force')
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
HTTP_REQUESTS_TOTAL,
|
HTTP_REQUESTS_TOTAL,
|
||||||
GOODGO_WS_CONNECTED_CLIENTS,
|
GOODGO_WS_CONNECTED_CLIENTS,
|
||||||
GOODGO_WS_MESSAGES_TOTAL,
|
GOODGO_WS_MESSAGES_TOTAL,
|
||||||
|
READ_MODEL_PROJECTOR_LAG_SECONDS,
|
||||||
|
READ_MODEL_REFRESH_DURATION_SECONDS,
|
||||||
|
READ_MODEL_RECONCILIATION_DRIFT_TOTAL,
|
||||||
WEB_VITALS_LCP,
|
WEB_VITALS_LCP,
|
||||||
WEB_VITALS_FCP,
|
WEB_VITALS_FCP,
|
||||||
WEB_VITALS_CLS,
|
WEB_VITALS_CLS,
|
||||||
@@ -37,6 +40,12 @@ export class MetricsService {
|
|||||||
private readonly wsConnectedClientsGauge: Gauge,
|
private readonly wsConnectedClientsGauge: Gauge,
|
||||||
@InjectMetric(GOODGO_WS_MESSAGES_TOTAL)
|
@InjectMetric(GOODGO_WS_MESSAGES_TOTAL)
|
||||||
private readonly wsMessagesCounter: Counter,
|
private readonly wsMessagesCounter: Counter,
|
||||||
|
@InjectMetric(READ_MODEL_PROJECTOR_LAG_SECONDS)
|
||||||
|
private readonly projectorLagGauge: Gauge,
|
||||||
|
@InjectMetric(READ_MODEL_REFRESH_DURATION_SECONDS)
|
||||||
|
private readonly readModelRefreshHistogram: Histogram,
|
||||||
|
@InjectMetric(READ_MODEL_RECONCILIATION_DRIFT_TOTAL)
|
||||||
|
private readonly reconciliationDriftCounter: Counter,
|
||||||
@InjectMetric(WEB_VITALS_LCP)
|
@InjectMetric(WEB_VITALS_LCP)
|
||||||
private readonly lcpHistogram: Histogram,
|
private readonly lcpHistogram: Histogram,
|
||||||
@InjectMetric(WEB_VITALS_FCP)
|
@InjectMetric(WEB_VITALS_FCP)
|
||||||
@@ -106,6 +115,21 @@ export class MetricsService {
|
|||||||
this.wsMessagesCounter.inc({ namespace, event, direction });
|
this.wsMessagesCounter.inc({ namespace, event, direction });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Set current projector lag (seconds behind source stream) for a handler. */
|
||||||
|
setProjectorLag(handler: string, lagSeconds: number): void {
|
||||||
|
this.projectorLagGauge.set({ handler }, lagSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record the duration of a read-model view refresh. */
|
||||||
|
recordReadModelRefresh(view: string, durationSeconds: number): void {
|
||||||
|
this.readModelRefreshHistogram.observe({ view }, durationSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Increment the reconciliation drift counter for a read model. */
|
||||||
|
recordReconciliationDrift(model: string, count = 1): void {
|
||||||
|
this.reconciliationDriftCounter.inc({ model }, count);
|
||||||
|
}
|
||||||
|
|
||||||
/** Map metric name → the correct histogram. */
|
/** Map metric name → the correct histogram. */
|
||||||
private readonly vitalHistograms: Record<string, Histogram | undefined> = {};
|
private readonly vitalHistograms: Record<string, Histogram | undefined> = {};
|
||||||
|
|
||||||
|
|||||||
@@ -65,12 +65,14 @@ describe('ResidentialPriceDropListener', () => {
|
|||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
name: 'Quận 7 căn hộ',
|
name: 'Quận 7 căn hộ',
|
||||||
filters: { city: 'Hồ Chí Minh', district: 'Quận 7', priceMax: 3_000_000_000 },
|
filters: { city: 'Hồ Chí Minh', district: 'Quận 7', priceMax: 3_000_000_000 },
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ss-2',
|
id: 'ss-2',
|
||||||
userId: 'user-2',
|
userId: 'user-2',
|
||||||
name: 'Quận 1',
|
name: 'Quận 1',
|
||||||
filters: { district: 'Quận 1' },
|
filters: { district: 'Quận 1' },
|
||||||
|
createdAt: new Date('2026-01-02T00:00:00Z'),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -101,7 +103,7 @@ describe('ResidentialPriceDropListener', () => {
|
|||||||
it('skips saved searches owned by the listing seller', async () => {
|
it('skips saved searches owned by the listing seller', async () => {
|
||||||
prisma.listing.findUnique.mockResolvedValue(listing);
|
prisma.listing.findUnique.mockResolvedValue(listing);
|
||||||
prisma.savedSearch.findMany.mockResolvedValue([
|
prisma.savedSearch.findMany.mockResolvedValue([
|
||||||
{ id: 'ss-self', userId: 'seller-1', name: 'mine', filters: {} },
|
{ id: 'ss-self', userId: 'seller-1', name: 'mine', filters: {}, createdAt: new Date('2026-01-01T00:00:00Z') },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const event = new ListingPriceChangedEvent('listing-1', 2_500_000_000n, 2_000_000_000n);
|
const event = new ListingPriceChangedEvent('listing-1', 2_500_000_000n, 2_000_000_000n);
|
||||||
@@ -117,6 +119,38 @@ describe('ResidentialPriceDropListener', () => {
|
|||||||
await expect(listener.handle(event)).resolves.not.toThrow();
|
await expect(listener.handle(event)).resolves.not.toThrow();
|
||||||
expect(logger.warn).toHaveBeenCalled();
|
expect(logger.warn).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('paginates across batches and emits to all matching users', async () => {
|
||||||
|
prisma.listing.findUnique.mockResolvedValue(listing);
|
||||||
|
|
||||||
|
const BATCH = 500;
|
||||||
|
const makeRow = (n: number) => ({
|
||||||
|
id: `ss-${n}`,
|
||||||
|
userId: `user-${n}`,
|
||||||
|
name: `Search ${n}`,
|
||||||
|
// All match: district + city
|
||||||
|
filters: { city: 'Hồ Chí Minh', district: 'Quận 7' },
|
||||||
|
createdAt: new Date(Date.now() + n * 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const page1 = Array.from({ length: BATCH }, (_, i) => makeRow(i));
|
||||||
|
const page2 = [makeRow(BATCH)];
|
||||||
|
|
||||||
|
prisma.savedSearch.findMany
|
||||||
|
.mockResolvedValueOnce(page1)
|
||||||
|
.mockResolvedValueOnce(page2)
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const event = new ListingPriceChangedEvent('listing-1', 2_500_000_000n, 2_000_000_000n);
|
||||||
|
await listener.handle(event);
|
||||||
|
|
||||||
|
expect(prisma.savedSearch.findMany).toHaveBeenCalledTimes(2);
|
||||||
|
expect(gateway.emitResidentialEvent).toHaveBeenCalledTimes(BATCH + 1);
|
||||||
|
|
||||||
|
// Second call must use cursor from last row of first batch
|
||||||
|
const secondCall = prisma.savedSearch.findMany.mock.calls[1][0];
|
||||||
|
expect(secondCall.where.createdAt?.gt).toEqual(page1[BATCH - 1]!.createdAt);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ResidentialNewListingInProjectListener', () => {
|
describe('ResidentialNewListingInProjectListener', () => {
|
||||||
@@ -149,9 +183,9 @@ describe('ResidentialNewListingInProjectListener', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
prisma.savedSearch.findMany.mockResolvedValue([
|
prisma.savedSearch.findMany.mockResolvedValue([
|
||||||
{ id: 'ss-tracker', userId: 'user-10', name: 'VGP', filters: { projectId: 'project-vgp' } },
|
{ id: 'ss-tracker', userId: 'user-10', name: 'VGP', filters: { projectId: 'project-vgp' }, createdAt: new Date('2026-01-01T00:00:00Z') },
|
||||||
{ id: 'ss-other', userId: 'user-11', name: 'khác', filters: { projectId: 'project-other' } },
|
{ id: 'ss-other', userId: 'user-11', name: 'khác', filters: { projectId: 'project-other' }, createdAt: new Date('2026-01-02T00:00:00Z') },
|
||||||
{ id: 'ss-no-project', userId: 'user-12', name: 'no-project', filters: {} },
|
{ id: 'ss-no-project', userId: 'user-12', name: 'no-project', filters: {}, createdAt: new Date('2026-01-03T00:00:00Z') },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const event = new ListingApprovedEvent('listing-9', 'admin-1');
|
const event = new ListingApprovedEvent('listing-9', 'admin-1');
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { NotificationsGateway } from '../../presentation/gateways/notifications.
|
|||||||
|
|
||||||
const CONTEXT = 'ResidentialEventsListener';
|
const CONTEXT = 'ResidentialEventsListener';
|
||||||
|
|
||||||
|
/** Rows processed per cursor-page. Aligns with idx_savedsearch_alert_enabled batch size. */
|
||||||
|
const ALERT_BATCH_SIZE = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shape of the `filters` JSON column on `SavedSearch`. Matches fields
|
* Shape of the `filters` JSON column on `SavedSearch`. Matches fields
|
||||||
* consumed by the saved-search alert matcher. Anything else is ignored.
|
* consumed by the saved-search alert matcher. Anything else is ignored.
|
||||||
@@ -63,31 +66,47 @@ export class ResidentialPriceDropListener
|
|||||||
});
|
});
|
||||||
if (!listing || !listing.property) return;
|
if (!listing || !listing.property) return;
|
||||||
|
|
||||||
const savedSearches = await this.prisma.savedSearch.findMany({
|
|
||||||
where: { alertEnabled: true },
|
|
||||||
select: { id: true, userId: true, name: true, filters: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
let matchCount = 0;
|
let matchCount = 0;
|
||||||
for (const search of savedSearches) {
|
let cursor: Date | undefined;
|
||||||
if (search.userId === listing.sellerId) continue;
|
|
||||||
|
|
||||||
const filters = normalizeFilters(search.filters);
|
// Stream alert-enabled saved searches in bounded batches (keyset on createdAt).
|
||||||
if (!matchesFilters(listing, listing.property, filters)) continue;
|
// idx_savedsearch_alert_enabled covers WHERE alertEnabled = true ORDER BY createdAt.
|
||||||
|
do {
|
||||||
this.gateway.emitResidentialEvent(search.userId, 'residential:price-drop', {
|
const batch = await this.prisma.savedSearch.findMany({
|
||||||
listingId: listing.id,
|
where: {
|
||||||
savedSearchId: search.id,
|
alertEnabled: true,
|
||||||
savedSearchName: search.name,
|
...(cursor ? { createdAt: { gt: cursor } } : {}),
|
||||||
title: listing.property.title,
|
},
|
||||||
oldPrice: event.oldPrice.toString(),
|
select: { id: true, userId: true, name: true, filters: true, createdAt: true },
|
||||||
newPrice: event.newPrice.toString(),
|
orderBy: { createdAt: 'asc' },
|
||||||
district: listing.property.district,
|
take: ALERT_BATCH_SIZE,
|
||||||
city: listing.property.city,
|
|
||||||
occurredAt: event.occurredAt.toISOString(),
|
|
||||||
});
|
});
|
||||||
matchCount++;
|
|
||||||
}
|
if (batch.length === 0) break;
|
||||||
|
|
||||||
|
for (const search of batch) {
|
||||||
|
if (search.userId === listing.sellerId) continue;
|
||||||
|
|
||||||
|
const filters = normalizeFilters(search.filters);
|
||||||
|
if (!matchesFilters(listing, listing.property, filters)) continue;
|
||||||
|
|
||||||
|
this.gateway.emitResidentialEvent(search.userId, 'residential:price-drop', {
|
||||||
|
listingId: listing.id,
|
||||||
|
savedSearchId: search.id,
|
||||||
|
savedSearchName: search.name,
|
||||||
|
title: listing.property.title,
|
||||||
|
oldPrice: event.oldPrice.toString(),
|
||||||
|
newPrice: event.newPrice.toString(),
|
||||||
|
district: listing.property.district,
|
||||||
|
city: listing.property.city,
|
||||||
|
occurredAt: event.occurredAt.toISOString(),
|
||||||
|
});
|
||||||
|
matchCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = batch[batch.length - 1]!.createdAt;
|
||||||
|
if (batch.length < ALERT_BATCH_SIZE) break;
|
||||||
|
} while (true);
|
||||||
|
|
||||||
if (matchCount > 0) {
|
if (matchCount > 0) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -126,35 +145,51 @@ export class ResidentialNewListingInProjectListener
|
|||||||
|
|
||||||
const projectId = listing.property.projectDevelopmentId;
|
const projectId = listing.property.projectDevelopmentId;
|
||||||
|
|
||||||
const savedSearches = await this.prisma.savedSearch.findMany({
|
|
||||||
where: { alertEnabled: true },
|
|
||||||
select: { id: true, userId: true, name: true, filters: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
let matchCount = 0;
|
let matchCount = 0;
|
||||||
for (const search of savedSearches) {
|
let cursor: Date | undefined;
|
||||||
if (search.userId === listing.sellerId) continue;
|
|
||||||
|
|
||||||
const filters = normalizeFilters(search.filters);
|
// Stream alert-enabled saved searches in bounded batches (keyset on createdAt).
|
||||||
if (filters.projectId !== projectId) continue;
|
// idx_savedsearch_alert_enabled covers WHERE alertEnabled = true ORDER BY createdAt.
|
||||||
|
do {
|
||||||
this.gateway.emitResidentialEvent(
|
const batch = await this.prisma.savedSearch.findMany({
|
||||||
search.userId,
|
where: {
|
||||||
'residential:new-listing-in-project',
|
alertEnabled: true,
|
||||||
{
|
...(cursor ? { createdAt: { gt: cursor } } : {}),
|
||||||
listingId: listing.id,
|
|
||||||
projectId,
|
|
||||||
savedSearchId: search.id,
|
|
||||||
savedSearchName: search.name,
|
|
||||||
title: listing.property.title,
|
|
||||||
price: listing.priceVND.toString(),
|
|
||||||
district: listing.property.district,
|
|
||||||
city: listing.property.city,
|
|
||||||
occurredAt: event.occurredAt.toISOString(),
|
|
||||||
},
|
},
|
||||||
);
|
select: { id: true, userId: true, name: true, filters: true, createdAt: true },
|
||||||
matchCount++;
|
orderBy: { createdAt: 'asc' },
|
||||||
}
|
take: ALERT_BATCH_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
|
||||||
|
for (const search of batch) {
|
||||||
|
if (search.userId === listing.sellerId) continue;
|
||||||
|
|
||||||
|
const filters = normalizeFilters(search.filters);
|
||||||
|
if (filters.projectId !== projectId) continue;
|
||||||
|
|
||||||
|
this.gateway.emitResidentialEvent(
|
||||||
|
search.userId,
|
||||||
|
'residential:new-listing-in-project',
|
||||||
|
{
|
||||||
|
listingId: listing.id,
|
||||||
|
projectId,
|
||||||
|
savedSearchId: search.id,
|
||||||
|
savedSearchName: search.name,
|
||||||
|
title: listing.property.title,
|
||||||
|
price: listing.priceVND.toString(),
|
||||||
|
district: listing.property.district,
|
||||||
|
city: listing.property.city,
|
||||||
|
occurredAt: event.occurredAt.toISOString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
matchCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = batch[batch.length - 1]!.createdAt;
|
||||||
|
if (batch.length < ALERT_BATCH_SIZE) break;
|
||||||
|
} while (true);
|
||||||
|
|
||||||
if (matchCount > 0) {
|
if (matchCount > 0) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
|
|||||||
129
apps/api/src/modules/read-models/README.md
Normal file
129
apps/api/src/modules/read-models/README.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# `read-models` module
|
||||||
|
|
||||||
|
Phase 0 skeleton for the CQRS read-model expansion described in
|
||||||
|
[RFC-003](../../../docs/adr/0003-cqrs-read-models.md) (the ADR itself
|
||||||
|
lands with [GOO-193](/GOO/issues/GOO-193); until then RFC-003 lives on
|
||||||
|
[GOO-94](/GOO/issues/GOO-94)).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
read-models/
|
||||||
|
domain/
|
||||||
|
projection-context.ts # ProjectionContext, ProjectableEvent
|
||||||
|
projection-offset-store.ts # IProjectionOffsetStore port + DI symbol
|
||||||
|
read-repository.ts # IReadRepository convention marker
|
||||||
|
application/
|
||||||
|
projectors/
|
||||||
|
projector.base.ts # Projector<E> base class
|
||||||
|
repositories/ # I<Name>ReadRepository interfaces (Phase 2/3)
|
||||||
|
infrastructure/
|
||||||
|
refresh/ # mat-view refresh cron (Phase 1)
|
||||||
|
reconciliation/ # nightly drift checker (Phase 2+)
|
||||||
|
testing/
|
||||||
|
in-memory-projection-offset-store.ts # unit-test harness
|
||||||
|
read-models.module.ts
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This mirrors the layout RFC-003 §5 specifies; intentionally **no
|
||||||
|
`presentation/`** because read models are infrastructure for other
|
||||||
|
modules' query handlers, not their own HTTP surface.
|
||||||
|
|
||||||
|
## The projector contract
|
||||||
|
|
||||||
|
Every read-model projector extends `Projector<E extends DomainEvent>`
|
||||||
|
and implements:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@EventsHandler(MyDomainEvent)
|
||||||
|
export class MyProjector extends Projector<MyDomainEvent> {
|
||||||
|
readonly handlerName = 'my-projector.v1';
|
||||||
|
|
||||||
|
protected async apply(event: MyDomainEvent, ctx: ProjectionContext) {
|
||||||
|
// write to your read model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// glue (one of):
|
||||||
|
@EventsHandler(MyDomainEvent)
|
||||||
|
export class MyProjectorGlue implements IEventHandler<MyDomainEvent> {
|
||||||
|
constructor(private readonly projector: MyProjector) {}
|
||||||
|
handle(event: MyDomainEvent) { return this.projector.dispatch(event); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Subclasses MUST:
|
||||||
|
|
||||||
|
- set `handlerName` to a **stable string** (rename = re-projection — be deliberate);
|
||||||
|
- implement `apply(event, ctx)` and treat `ctx.eventId` as the unit of idempotency.
|
||||||
|
|
||||||
|
Subclasses MUST NOT:
|
||||||
|
|
||||||
|
- call `apply` directly — always go through `dispatch(event)`;
|
||||||
|
- write to write-model tables — read models are read-only from the API
|
||||||
|
surface, only mutated by their owning projector or refresh job;
|
||||||
|
- implement their own deduplication — the base class already does it via
|
||||||
|
`IProjectionOffsetStore`.
|
||||||
|
|
||||||
|
## The offset / idempotency contract
|
||||||
|
|
||||||
|
RFC-003 §0 mandates `(eventId, handlerName)` idempotency:
|
||||||
|
|
||||||
|
> The `(eventId, handler)` offset table is non-negotiable. Land it in
|
||||||
|
> Phase 0 with a unit-test harness so every Phase 2/3 projector inherits it.
|
||||||
|
|
||||||
|
This module ships the **port** (`IProjectionOffsetStore`,
|
||||||
|
`PROJECTION_OFFSET_STORE`) and an in-memory implementation for tests.
|
||||||
|
The Prisma-backed implementation — including the
|
||||||
|
`projection_offset(event_id, handler_name, applied_at, payload_hash)`
|
||||||
|
migration and the transactional wrapper — lands with
|
||||||
|
[GOO-187](/GOO/issues/GOO-187).
|
||||||
|
|
||||||
|
The base class enforces the contract by calling `recordIfAbsent` BEFORE
|
||||||
|
`apply`. Re-deliveries observe `applied: false` and are skipped. The
|
||||||
|
offset row is intentionally **not rolled back on `apply` failure** in
|
||||||
|
Phase 0 — this is the conservative choice (RFC-003 §7) and is healed by
|
||||||
|
the nightly reconciliation job that lands in Phase 2.
|
||||||
|
|
||||||
|
`eventId` is currently derived from
|
||||||
|
`${aggregateId}:${occurredAt.getTime()}:${eventName}` because the
|
||||||
|
existing `DomainEvent` interface (`apps/api/src/modules/shared/domain/domain-event.ts`)
|
||||||
|
does not yet carry a stable id. Override `deriveEventId` on your
|
||||||
|
projector if your event type provides one. The id contract itself is
|
||||||
|
finalised in [GOO-187](/GOO/issues/GOO-187); Phase 2/3 projectors should
|
||||||
|
not bake assumptions about its format.
|
||||||
|
|
||||||
|
## The repository convention
|
||||||
|
|
||||||
|
For each read model:
|
||||||
|
|
||||||
|
1. Define `I<Name>ReadRepository` (extending `IReadRepository`) under
|
||||||
|
`application/repositories/`.
|
||||||
|
2. Export a paired injection symbol `<NAME>_READ_REPOSITORY`.
|
||||||
|
3. Implement `Prisma<Name>ReadRepository` under
|
||||||
|
`infrastructure/repositories/` and bind it in `ReadModelsModule`.
|
||||||
|
4. Re-export the symbol from the module's `index.ts` so query handlers in
|
||||||
|
other modules can `@Inject(LISTING_CARD_READ_REPOSITORY)` without
|
||||||
|
reaching into the read-models module's internals.
|
||||||
|
|
||||||
|
Read repositories are READ-ONLY from the perspective of the rest of the
|
||||||
|
API. The only writers are the projector that owns the read model (Option
|
||||||
|
C) or the materialized-view refresh job (Option B).
|
||||||
|
|
||||||
|
## What Phase 0 is NOT
|
||||||
|
|
||||||
|
- No `projection_offset` migration — owned by [GOO-187](/GOO/issues/GOO-187).
|
||||||
|
- No projectors registered.
|
||||||
|
- No materialized views or refresh job — Phase 1.
|
||||||
|
- No reconciliation job — Phase 2.
|
||||||
|
- No `X-Data-Freshness-Seconds` helper — separate Phase 0 ticket.
|
||||||
|
- No kill-switch / chaos test — separate Phase 0 ticket.
|
||||||
|
|
||||||
|
The skeleton exists so the next batch of PRs is purely additive.
|
||||||
|
|
||||||
|
## Coordination
|
||||||
|
|
||||||
|
- Parent: [GOO-94](/GOO/issues/GOO-94)
|
||||||
|
- Sibling (offset table + idempotency harness): [GOO-187](/GOO/issues/GOO-187)
|
||||||
|
- ADR (write-up): [GOO-193](/GOO/issues/GOO-193)
|
||||||
1
apps/api/src/modules/read-models/application/index.ts
Normal file
1
apps/api/src/modules/read-models/application/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './projectors';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Projector } from './projector.base';
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import {
|
||||||
|
PROJECTION_OFFSET_STORE,
|
||||||
|
type IProjectionOffsetStore,
|
||||||
|
type ProjectableEvent,
|
||||||
|
type ProjectionContext,
|
||||||
|
} from '../../domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class every read-model projector inherits from.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Owns the typed `apply(event, ctx)` hook subclasses implement.
|
||||||
|
* - Delegates the `(eventId, handlerName)` idempotency check to
|
||||||
|
* {@link IProjectionOffsetStore} (port from `domain/`,
|
||||||
|
* Prisma implementation from [GOO-187](/GOO/issues/GOO-187)).
|
||||||
|
* - Emits a structured log line with projector lag for observability
|
||||||
|
* (`X-Data-Freshness-Seconds` SLO, RFC-003 §0).
|
||||||
|
*
|
||||||
|
* Subclasses do NOT call `apply` directly — they invoke {@link dispatch}
|
||||||
|
* from their `@EventsHandler` / `@OnEvent` glue. `dispatch` enforces the
|
||||||
|
* "at-least-once → effectively-once" contract.
|
||||||
|
*
|
||||||
|
* Subclasses MUST:
|
||||||
|
* - Set `handlerName` (stable identifier — used as the offset key half).
|
||||||
|
* - Implement `apply(event, ctx)`.
|
||||||
|
*
|
||||||
|
* Subclasses MAY:
|
||||||
|
* - Override `deriveEventId(event)` if their event type carries a
|
||||||
|
* stable id field. Default derivation is
|
||||||
|
* `${aggregateId}:${occurredAt.getTime()}:${eventName}` — sufficient
|
||||||
|
* for current domain events but NOT for events fanned out via
|
||||||
|
* external transports (revisit when CDC lands, RFC-003 Option D).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export abstract class Projector<E extends ProjectableEvent> {
|
||||||
|
/** Stable handler identifier — second half of the offset key. */
|
||||||
|
abstract readonly handlerName: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(PROJECTION_OFFSET_STORE)
|
||||||
|
protected readonly offsets: IProjectionOffsetStore,
|
||||||
|
protected readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implement the actual projection. MUST be deterministic given
|
||||||
|
* `(event, ctx)` and MUST be safe to short-circuit if `ctx` indicates
|
||||||
|
* a re-delivery (the base class already enforces this — subclasses
|
||||||
|
* should not re-check).
|
||||||
|
*/
|
||||||
|
protected abstract apply(event: E, ctx: ProjectionContext): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional hook so subclasses can override how `eventId` is derived
|
||||||
|
* from a domain event. Override this if your event type carries a
|
||||||
|
* stable id (e.g. UUID minted by the producer).
|
||||||
|
*/
|
||||||
|
protected deriveEventId(event: E): string {
|
||||||
|
return `${event.aggregateId}:${event.occurredAt.getTime()}:${event.eventName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point invoked by the projector's framework glue
|
||||||
|
* (`@EventsHandler` / `@OnEvent`). Wraps `apply` with the offset
|
||||||
|
* idempotency check and emits a lag log line on success.
|
||||||
|
*/
|
||||||
|
async dispatch(event: E): Promise<void> {
|
||||||
|
const observedAt = new Date();
|
||||||
|
const ctx: ProjectionContext = {
|
||||||
|
eventId: this.deriveEventId(event),
|
||||||
|
handlerName: this.handlerName,
|
||||||
|
observedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { applied } = await this.offsets.recordIfAbsent({
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
handlerName: ctx.handlerName,
|
||||||
|
appliedAt: observedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!applied) {
|
||||||
|
// Re-delivery — already projected. No-op by contract.
|
||||||
|
this.logger.debug(
|
||||||
|
`Projector ${this.handlerName} skipped duplicate event ${ctx.eventId}`,
|
||||||
|
'Projector',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.apply(event, ctx);
|
||||||
|
const lagMs = observedAt.getTime() - event.occurredAt.getTime();
|
||||||
|
this.logger.debug(
|
||||||
|
`Projector ${this.handlerName} applied event ${ctx.eventId} (lag=${lagMs}ms)`,
|
||||||
|
'Projector',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Surface the failure with the offset key so reconciliation can
|
||||||
|
// reason about partially-applied state. Note that the offset row
|
||||||
|
// is already inserted — Phase 0 deliberately does NOT roll it back.
|
||||||
|
// RFC-003 §7 covers this with the nightly reconciliation job.
|
||||||
|
this.logger.error(
|
||||||
|
`Projector ${this.handlerName} failed for event ${ctx.eventId}: ${(err as Error).message}`,
|
||||||
|
(err as Error).stack,
|
||||||
|
'Projector',
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Per-read-model repository interfaces live in this folder once Phase 2/3
|
||||||
|
* begin landing concrete read models. Phase 0 ships only the convention
|
||||||
|
* (see `domain/read-repository.ts`):
|
||||||
|
*
|
||||||
|
* - One interface per read model: `I<Name>ReadRepository`.
|
||||||
|
* - Paired injection symbol: `<NAME>_READ_REPOSITORY`.
|
||||||
|
* - Read-only — writes go through projectors or refresh jobs.
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
17
apps/api/src/modules/read-models/domain/index.ts
Normal file
17
apps/api/src/modules/read-models/domain/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export {
|
||||||
|
type ProjectionContext,
|
||||||
|
type ProjectableEvent,
|
||||||
|
} from './projection-context';
|
||||||
|
export {
|
||||||
|
PROJECTION_OFFSET_STORE,
|
||||||
|
type IProjectionOffsetStore,
|
||||||
|
type ProjectionOffsetKey,
|
||||||
|
type ProjectionOffsetRecord,
|
||||||
|
type RecordOffsetInput,
|
||||||
|
type RecordOffsetResult,
|
||||||
|
} from './projection-offset-store';
|
||||||
|
export { type IReadRepository } from './read-repository';
|
||||||
|
export {
|
||||||
|
READ_MODEL_KILL_SWITCH,
|
||||||
|
type IReadModelKillSwitch,
|
||||||
|
} from './read-model-kill-switch';
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-event context handed to a projector's `apply` hook.
|
||||||
|
*
|
||||||
|
* Phase 0 keeps this intentionally minimal. Later phases may attach
|
||||||
|
* tracing spans, current offset metadata, or a transaction handle here
|
||||||
|
* — additions MUST stay backward-compatible (additive properties only).
|
||||||
|
*/
|
||||||
|
export interface ProjectionContext {
|
||||||
|
/**
|
||||||
|
* Stable identifier for the event being projected. Used as the primary
|
||||||
|
* key half of `(eventId, handlerName)` in the offset store so re-delivery
|
||||||
|
* is a no-op.
|
||||||
|
*
|
||||||
|
* NOTE: domain events do not yet carry a stable id; until they do
|
||||||
|
* the wrapper that invokes a projector is responsible for deriving one
|
||||||
|
* (typically `${aggregateId}:${occurredAt.getTime()}:${eventName}`).
|
||||||
|
* This contract is fixed in [GOO-187](/GOO/issues/GOO-187).
|
||||||
|
*/
|
||||||
|
readonly eventId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The projector handler invoking `apply`. Used as the second half of
|
||||||
|
* the `(eventId, handlerName)` offset key — the same event projected
|
||||||
|
* by two different handlers must record two separate offsets.
|
||||||
|
*/
|
||||||
|
readonly handlerName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the event was observed by the projector dispatcher (NOT when
|
||||||
|
* the event itself occurred — see `event.occurredAt`). Useful for
|
||||||
|
* lag metrics: `observedAt - event.occurredAt`.
|
||||||
|
*/
|
||||||
|
readonly observedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projector-facing view of a domain event. Re-exported here so projector
|
||||||
|
* code does not have to reach across to `@modules/shared` for the base
|
||||||
|
* type — keeps the read-models module's public surface self-contained.
|
||||||
|
*/
|
||||||
|
export type ProjectableEvent = DomainEvent;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Idempotency contract for projector dispatch.
|
||||||
|
*
|
||||||
|
* RFC-003 §0 (CTO ask): the `(eventId, handlerName)` offset table is
|
||||||
|
* non-negotiable. Phase 0 lands this *port* so that:
|
||||||
|
*
|
||||||
|
* 1. The projector base class can express the contract in code today
|
||||||
|
* (without taking a Prisma dependency at this layer).
|
||||||
|
* 2. [GOO-187](/GOO/issues/GOO-187) lands the Prisma migration
|
||||||
|
* (`projection_offset(event_id, handler_name, applied_at, payload_hash)`)
|
||||||
|
* and the concrete implementation against this same interface.
|
||||||
|
* 3. The unit-test harness in `read-models/testing` can ship an
|
||||||
|
* in-memory implementation without coupling to infra.
|
||||||
|
*
|
||||||
|
* Implementations MUST be safe under concurrent dispatch: the
|
||||||
|
* `recordIfAbsent` call is the linearisation point — exactly one caller
|
||||||
|
* for a given `(eventId, handlerName)` should observe `applied: true`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ProjectionOffsetKey {
|
||||||
|
readonly eventId: string;
|
||||||
|
readonly handlerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectionOffsetRecord extends ProjectionOffsetKey {
|
||||||
|
/** When this offset was first recorded (i.e. the projection ran). */
|
||||||
|
readonly appliedAt: Date;
|
||||||
|
/**
|
||||||
|
* Optional content-hash of the projected payload. Reconciliation jobs
|
||||||
|
* use this to spot drift between what was projected and what the
|
||||||
|
* write-side now holds.
|
||||||
|
*/
|
||||||
|
readonly payloadHash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordOffsetInput extends ProjectionOffsetKey {
|
||||||
|
readonly appliedAt?: Date;
|
||||||
|
readonly payloadHash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordOffsetResult {
|
||||||
|
/**
|
||||||
|
* `true` if this call inserted a fresh offset row (the projection
|
||||||
|
* should run); `false` if the offset already existed (re-delivery,
|
||||||
|
* the projection MUST be skipped).
|
||||||
|
*/
|
||||||
|
readonly applied: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProjectionOffsetStore {
|
||||||
|
/**
|
||||||
|
* Atomically insert an offset row if and only if no row exists for
|
||||||
|
* `(eventId, handlerName)`. Implementations typically use
|
||||||
|
* `INSERT ... ON CONFLICT DO NOTHING` or an equivalent unique-constraint
|
||||||
|
* insert and report whether a row was actually written.
|
||||||
|
*/
|
||||||
|
recordIfAbsent(input: RecordOffsetInput): Promise<RecordOffsetResult>;
|
||||||
|
|
||||||
|
/** Lookup helper for reconciliation tooling and tests. */
|
||||||
|
find(key: ProjectionOffsetKey): Promise<ProjectionOffsetRecord | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROJECTION_OFFSET_STORE = Symbol('PROJECTION_OFFSET_STORE');
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Per-read-model kill switch — RFC-003 §0.
|
||||||
|
*
|
||||||
|
* Contract:
|
||||||
|
* - `isEnabled(name)` returns whether the named read model should
|
||||||
|
* serve queries. When `false`, callers MUST fail open to the
|
||||||
|
* write-model path.
|
||||||
|
* - Implementations MUST be hot-readable (no restart required).
|
||||||
|
* - The check is evaluated per-call so that a flag toggled mid-request
|
||||||
|
* is honoured on the NEXT repository call within the same request.
|
||||||
|
* In-flight calls complete against whichever source they already
|
||||||
|
* started on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const READ_MODEL_KILL_SWITCH = Symbol('READ_MODEL_KILL_SWITCH');
|
||||||
|
|
||||||
|
export interface IReadModelKillSwitch {
|
||||||
|
/**
|
||||||
|
* Returns `true` when the named read model is active and safe to query.
|
||||||
|
* Returns `true` (fail-open) for unknown / un-configured names so that
|
||||||
|
* an absent config key never blocks the write-model fallback path.
|
||||||
|
*/
|
||||||
|
isEnabled(readModelName: string): boolean;
|
||||||
|
}
|
||||||
28
apps/api/src/modules/read-models/domain/read-repository.ts
Normal file
28
apps/api/src/modules/read-models/domain/read-repository.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Marker interface for read-model repositories.
|
||||||
|
*
|
||||||
|
* Convention (Phase 0):
|
||||||
|
* - One interface per read model: `I<Name>ReadRepository`.
|
||||||
|
* - Paired injection symbol: `<NAME>_READ_REPOSITORY` (Symbol).
|
||||||
|
* - Concrete Prisma-backed class lives under
|
||||||
|
* `infrastructure/repositories/prisma-<name>-read.repository.ts`.
|
||||||
|
* - Read repositories are READ-ONLY. Writes happen exclusively via
|
||||||
|
* projectors (Option C) or scheduled refresh jobs (Option B).
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* export const LISTING_CARD_READ_REPOSITORY = Symbol('LISTING_CARD_READ_REPOSITORY');
|
||||||
|
*
|
||||||
|
* export interface IListingCardReadRepository extends IReadRepository {
|
||||||
|
* findById(id: string): Promise<ListingCardReadView | null>;
|
||||||
|
* search(params: ListingCardSearchParams): Promise<PaginatedResult<ListingCardReadView>>;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Keeping this as an empty marker (rather than forcing a `findById`
|
||||||
|
* shape) lets Phase 2/3 read repositories pick the access pattern that
|
||||||
|
* fits the read model — point lookup, search, range scan, etc.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
export interface IReadRepository {}
|
||||||
4
apps/api/src/modules/read-models/index.ts
Normal file
4
apps/api/src/modules/read-models/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { ReadModelsModule } from './read-models.module';
|
||||||
|
export * from './domain';
|
||||||
|
export * from './application';
|
||||||
|
export * from './infrastructure';
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { type IReadModelKillSwitch } from '../../domain/read-model-kill-switch';
|
||||||
|
import { ReadModelRepositoryWrapper } from '../read-model-repository-wrapper';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Shared test doubles */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface IFakeRepository {
|
||||||
|
findById(id: string): Promise<{ id: string; source: string }>;
|
||||||
|
search(query: string): Promise<{ results: string[]; source: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReadRepo(): IFakeRepository {
|
||||||
|
return {
|
||||||
|
findById: vi.fn(async (id: string) => ({ id, source: 'read-model' })),
|
||||||
|
search: vi.fn(async (q: string) => ({ results: [q], source: 'read-model' })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWriteRepo(): IFakeRepository {
|
||||||
|
return {
|
||||||
|
findById: vi.fn(async (id: string) => ({ id, source: 'write-model' })),
|
||||||
|
search: vi.fn(async (q: string) => ({ results: [q], source: 'write-model' })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const silentLogger = {
|
||||||
|
debug: vi.fn(),
|
||||||
|
log: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
verbose: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Mutable kill switch for chaos testing */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
class MutableKillSwitch implements IReadModelKillSwitch {
|
||||||
|
private flags = new Map<string, boolean>();
|
||||||
|
|
||||||
|
setEnabled(name: string, enabled: boolean): void {
|
||||||
|
this.flags.set(name, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(name: string): boolean {
|
||||||
|
return this.flags.get(name) ?? true; // fail-open default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Tests */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
describe('ReadModelRepositoryWrapper — kill switch', () => {
|
||||||
|
let killSwitch: MutableKillSwitch;
|
||||||
|
let readRepo: IFakeRepository;
|
||||||
|
let writeRepo: IFakeRepository;
|
||||||
|
let proxy: IFakeRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
killSwitch = new MutableKillSwitch();
|
||||||
|
readRepo = createReadRepo();
|
||||||
|
writeRepo = createWriteRepo();
|
||||||
|
const wrapper = new ReadModelRepositoryWrapper<IFakeRepository>(
|
||||||
|
readRepo,
|
||||||
|
writeRepo,
|
||||||
|
killSwitch,
|
||||||
|
'listing_card',
|
||||||
|
silentLogger,
|
||||||
|
);
|
||||||
|
proxy = wrapper.getProxy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to read-model when kill switch is ON (enabled)', async () => {
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
const result = await proxy.findById('abc');
|
||||||
|
expect(result.source).toBe('read-model');
|
||||||
|
expect(readRepo.findById).toHaveBeenCalledWith('abc');
|
||||||
|
expect(writeRepo.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to write-model when kill switch is OFF (disabled)', async () => {
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
const result = await proxy.findById('abc');
|
||||||
|
expect(result.source).toBe('write-model');
|
||||||
|
expect(writeRepo.findById).toHaveBeenCalledWith('abc');
|
||||||
|
expect(readRepo.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to enabled (fail-open) for unknown read model names', async () => {
|
||||||
|
// 'listing_card' was never set → defaults to true
|
||||||
|
const freshKillSwitch = new MutableKillSwitch();
|
||||||
|
const wrapper = new ReadModelRepositoryWrapper<IFakeRepository>(
|
||||||
|
readRepo,
|
||||||
|
writeRepo,
|
||||||
|
freshKillSwitch,
|
||||||
|
'unknown_model',
|
||||||
|
silentLogger,
|
||||||
|
);
|
||||||
|
const result = await wrapper.getProxy().findById('xyz');
|
||||||
|
expect(result.source).toBe('read-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- */
|
||||||
|
/* CHAOS TEST: flag toggle mid-request → fail-open */
|
||||||
|
/* -------------------------------------------------------------- */
|
||||||
|
|
||||||
|
it('chaos: flag toggle mid-request fails open to write-model on NEXT call', async () => {
|
||||||
|
// Start enabled — first call goes to read-model.
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
|
||||||
|
const call1 = proxy.findById('first');
|
||||||
|
|
||||||
|
// Toggle the flag WHILE call1 is in-flight.
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
|
||||||
|
const result1 = await call1;
|
||||||
|
// call1 already started on read-model — it completes there.
|
||||||
|
expect(result1.source).toBe('read-model');
|
||||||
|
|
||||||
|
// NEXT call should route to write-model (the switch was flipped).
|
||||||
|
const result2 = await proxy.findById('second');
|
||||||
|
expect(result2.source).toBe('write-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chaos: rapid toggle during sequential calls routes correctly', async () => {
|
||||||
|
// Simulate a chaotic sequence of toggles interleaved with calls.
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
const r1 = await proxy.search('q1');
|
||||||
|
expect(r1.source).toBe('read-model');
|
||||||
|
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
const r2 = await proxy.search('q2');
|
||||||
|
expect(r2.source).toBe('write-model');
|
||||||
|
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
const r3 = await proxy.search('q3');
|
||||||
|
expect(r3.source).toBe('read-model');
|
||||||
|
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
const r4 = await proxy.search('q4');
|
||||||
|
expect(r4.source).toBe('write-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chaos: concurrent calls with mid-flight toggle each route independently', async () => {
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
|
||||||
|
// Slow read-model that takes time to resolve.
|
||||||
|
const slowReadRepo: IFakeRepository = {
|
||||||
|
findById: vi.fn(
|
||||||
|
(id: string) =>
|
||||||
|
new Promise((resolve) =>
|
||||||
|
setTimeout(() => resolve({ id, source: 'read-model' }), 50),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
search: vi.fn(async (q: string) => ({ results: [q], source: 'read-model' })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = new ReadModelRepositoryWrapper<IFakeRepository>(
|
||||||
|
slowReadRepo,
|
||||||
|
writeRepo,
|
||||||
|
killSwitch,
|
||||||
|
'listing_card',
|
||||||
|
silentLogger,
|
||||||
|
);
|
||||||
|
const slowProxy = wrapper.getProxy();
|
||||||
|
|
||||||
|
// Launch first call (will use read-model, takes 50ms).
|
||||||
|
const p1 = slowProxy.findById('slow');
|
||||||
|
|
||||||
|
// Toggle off before call1 resolves.
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
|
||||||
|
// Second call should immediately route to write-model.
|
||||||
|
const p2 = slowProxy.findById('fast');
|
||||||
|
|
||||||
|
const [result1, result2] = await Promise.all([p1, p2]);
|
||||||
|
|
||||||
|
// call1 was already dispatched to read-model — completes there.
|
||||||
|
expect(result1.source).toBe('read-model');
|
||||||
|
// call2 was dispatched after toggle — goes to write-model.
|
||||||
|
expect(result2.source).toBe('write-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chaos: zero error bubble to caller when switch is off', async () => {
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
|
||||||
|
// Both methods should work without throwing.
|
||||||
|
await expect(proxy.findById('x')).resolves.toBeDefined();
|
||||||
|
await expect(proxy.search('y')).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ConfigReadModelKillSwitch', () => {
|
||||||
|
// Unit test the config-backed implementation separately.
|
||||||
|
it('reads env var per call (hot-readable)', async () => {
|
||||||
|
const { ConfigReadModelKillSwitch } = await import(
|
||||||
|
'../config-read-model-kill-switch'
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
get: vi.fn((key: string) => {
|
||||||
|
if (key === 'READ_MODEL_LISTING_CARD_ENABLED') return 'false';
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ks = new ConfigReadModelKillSwitch(mockConfig as any, silentLogger);
|
||||||
|
|
||||||
|
expect(ks.isEnabled('listing_card')).toBe(false);
|
||||||
|
expect(ks.isEnabled('unknown')).toBe(true); // fail-open
|
||||||
|
|
||||||
|
// Simulate hot-reload by changing the mock return.
|
||||||
|
mockConfig.get.mockImplementation((key: string) => {
|
||||||
|
if (key === 'READ_MODEL_LISTING_CARD_ENABLED') return 'true';
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ks.isEnabled('listing_card')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats "0" as disabled', async () => {
|
||||||
|
const { ConfigReadModelKillSwitch } = await import(
|
||||||
|
'../config-read-model-kill-switch'
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
get: vi.fn(() => '0'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ks = new ConfigReadModelKillSwitch(mockConfig as any, silentLogger);
|
||||||
|
expect(ks.isEnabled('any_model')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import { type IReadModelKillSwitch } from '../domain/read-model-kill-switch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config-driven per-read-model kill switch.
|
||||||
|
*
|
||||||
|
* Reads `READ_MODEL_<UPPER_SNAKE_NAME>_ENABLED` from process.env via
|
||||||
|
* NestJS ConfigService on every call (hot-readable — no restart needed).
|
||||||
|
*
|
||||||
|
* Missing keys default to `true` (fail-open: the read model is presumed
|
||||||
|
* healthy unless explicitly killed).
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* `READ_MODEL_LISTING_CARD_ENABLED=false` → listing_card read model disabled
|
||||||
|
* (env var absent) → read model enabled (fail-open)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ConfigReadModelKillSwitch implements IReadModelKillSwitch {
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
isEnabled(readModelName: string): boolean {
|
||||||
|
const envKey = `READ_MODEL_${readModelName.replace(/-/g, '_').toUpperCase()}_ENABLED`;
|
||||||
|
const raw = this.config.get<string>(envKey);
|
||||||
|
|
||||||
|
// Missing config → fail open (enabled).
|
||||||
|
if (raw === undefined || raw === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = raw !== 'false' && raw !== '0';
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Kill switch OFF for read model "${readModelName}" (${envKey}=${raw})`,
|
||||||
|
'ReadModelKillSwitch',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
apps/api/src/modules/read-models/infrastructure/index.ts
Normal file
4
apps/api/src/modules/read-models/infrastructure/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './refresh';
|
||||||
|
export * from './reconciliation';
|
||||||
|
export { ConfigReadModelKillSwitch } from './config-read-model-kill-switch';
|
||||||
|
export { ReadModelRepositoryWrapper } from './read-model-repository-wrapper';
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import { type IReadModelKillSwitch } from '../domain/read-model-kill-switch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic wrapper that sits in front of a read-model repository and
|
||||||
|
* transparently fails open to the write-model repository when the
|
||||||
|
* per-read-model kill switch is OFF.
|
||||||
|
*
|
||||||
|
* Every public method call checks the kill switch. Because the check
|
||||||
|
* happens per-call (not per-request), a flag toggled mid-request is
|
||||||
|
* honoured on the NEXT call — the in-flight call completes against
|
||||||
|
* whichever source it already started on.
|
||||||
|
*
|
||||||
|
* Usage (at module wiring time):
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const wrapper = new ReadModelRepositoryWrapper(
|
||||||
|
* readRepo,
|
||||||
|
* writeRepo,
|
||||||
|
* killSwitch,
|
||||||
|
* 'listing_card',
|
||||||
|
* logger,
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `T` is the repository interface both implementations share (the
|
||||||
|
* intersection of methods callable by consumers).
|
||||||
|
*/
|
||||||
|
export class ReadModelRepositoryWrapper<T extends object> {
|
||||||
|
private readonly proxy: T;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly readImpl: T,
|
||||||
|
private readonly writeImpl: T,
|
||||||
|
private readonly killSwitch: IReadModelKillSwitch,
|
||||||
|
private readonly readModelName: string,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {
|
||||||
|
// Build a Proxy that intercepts every method call and routes it
|
||||||
|
// through the kill switch.
|
||||||
|
this.proxy = new Proxy(readImpl, {
|
||||||
|
get: (_target, prop, _receiver) => {
|
||||||
|
const readVal = (readImpl as Record<string | symbol, unknown>)[prop];
|
||||||
|
const writeVal = (writeImpl as Record<string | symbol, unknown>)[prop];
|
||||||
|
|
||||||
|
// Non-function properties: always return from the active source.
|
||||||
|
if (typeof readVal !== 'function') {
|
||||||
|
return this.killSwitch.isEnabled(this.readModelName) ? readVal : writeVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function: return a wrapper that checks the switch at call time.
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
if (this.killSwitch.isEnabled(this.readModelName)) {
|
||||||
|
return (readVal as Function).apply(readImpl, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Kill switch routed ${this.readModelName}.${String(prop)} → write-model`,
|
||||||
|
'ReadModelRepositoryWrapper',
|
||||||
|
);
|
||||||
|
if (typeof writeVal !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
`Write-model fallback for ${this.readModelName} does not implement ${String(prop)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (writeVal as Function).apply(writeImpl, args);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the proxied repository that consumers should depend on.
|
||||||
|
* Inject this as the repository token value.
|
||||||
|
*/
|
||||||
|
getProxy(): T {
|
||||||
|
return this.proxy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Reconciliation infrastructure (RFC-003 §7).
|
||||||
|
*
|
||||||
|
* Phase 0 ships only the placeholder. Phase 2 lands the sampled nightly
|
||||||
|
* (1%) drift checker; the weekly full reconciliation runs follow once
|
||||||
|
* Phase 2 has soaked in production for one cycle.
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Materialized-view refresh infrastructure (RFC-003 Option B).
|
||||||
|
*
|
||||||
|
* Phase 0 ships only the placeholder. Phase 1 lands
|
||||||
|
* `RefreshMaterializedViewJob` and the cron registrations for
|
||||||
|
* `mv_heatmap_district`, `mv_heatmap_ward`, `mv_market_snapshot`,
|
||||||
|
* `mv_district_stats`.
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
34
apps/api/src/modules/read-models/read-models.module.ts
Normal file
34
apps/api/src/modules/read-models/read-models.module.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
|
import { SharedModule } from '@modules/shared';
|
||||||
|
import { READ_MODEL_KILL_SWITCH } from './domain/read-model-kill-switch';
|
||||||
|
import { ConfigReadModelKillSwitch } from './infrastructure/config-read-model-kill-switch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-models module skeleton — RFC-003 Phase 0.
|
||||||
|
*
|
||||||
|
* Hosts:
|
||||||
|
* - Projector base class (`application/projectors/projector.base.ts`).
|
||||||
|
* - Read-model repository convention (`domain/read-repository.ts`).
|
||||||
|
* - Idempotency port (`domain/projection-offset-store.ts`).
|
||||||
|
* - Per-read-model kill switch (`domain/read-model-kill-switch.ts`).
|
||||||
|
*
|
||||||
|
* No projectors, repositories, or `IProjectionOffsetStore` provider are
|
||||||
|
* registered here yet. The Prisma-backed offset store binding lands with
|
||||||
|
* [GOO-187](/GOO/issues/GOO-187); per-read-model projectors land in
|
||||||
|
* Phase 2/3.
|
||||||
|
*
|
||||||
|
* The module is imported by `AppModule` so its DI container is wired up
|
||||||
|
* even while empty — keeps Phase 2/3 PRs strictly additive.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [CqrsModule, SharedModule],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: READ_MODEL_KILL_SWITCH,
|
||||||
|
useClass: ConfigReadModelKillSwitch,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [READ_MODEL_KILL_SWITCH],
|
||||||
|
})
|
||||||
|
export class ReadModelsModule {}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
type IProjectionOffsetStore,
|
||||||
|
type ProjectionOffsetKey,
|
||||||
|
type ProjectionOffsetRecord,
|
||||||
|
type RecordOffsetInput,
|
||||||
|
type RecordOffsetResult,
|
||||||
|
} from '../domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory {@link IProjectionOffsetStore} for unit tests.
|
||||||
|
*
|
||||||
|
* Phase 2/3 projector tests reuse this so they can exercise the
|
||||||
|
* "replay same event N times → single state mutation" contract from
|
||||||
|
* RFC-003 §0 without spinning up Postgres. The Prisma-backed
|
||||||
|
* implementation lives in [GOO-187](/GOO/issues/GOO-187).
|
||||||
|
*/
|
||||||
|
export class InMemoryProjectionOffsetStore implements IProjectionOffsetStore {
|
||||||
|
private readonly rows = new Map<string, ProjectionOffsetRecord>();
|
||||||
|
|
||||||
|
private static key(k: ProjectionOffsetKey): string {
|
||||||
|
return `${k.handlerName}::${k.eventId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordIfAbsent(input: RecordOffsetInput): Promise<RecordOffsetResult> {
|
||||||
|
const k = InMemoryProjectionOffsetStore.key(input);
|
||||||
|
if (this.rows.has(k)) {
|
||||||
|
return { applied: false };
|
||||||
|
}
|
||||||
|
this.rows.set(k, {
|
||||||
|
eventId: input.eventId,
|
||||||
|
handlerName: input.handlerName,
|
||||||
|
appliedAt: input.appliedAt ?? new Date(),
|
||||||
|
...(input.payloadHash !== undefined ? { payloadHash: input.payloadHash } : {}),
|
||||||
|
});
|
||||||
|
return { applied: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async find(key: ProjectionOffsetKey): Promise<ProjectionOffsetRecord | null> {
|
||||||
|
return this.rows.get(InMemoryProjectionOffsetStore.key(key)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test helper. */
|
||||||
|
size(): number {
|
||||||
|
return this.rows.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test helper. */
|
||||||
|
clear(): void {
|
||||||
|
this.rows.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/api/src/modules/read-models/testing/index.ts
Normal file
1
apps/api/src/modules/read-models/testing/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { InMemoryProjectionOffsetStore } from './in-memory-projection-offset-store';
|
||||||
@@ -28,6 +28,7 @@ describe('SavedSearchAlertHandler', () => {
|
|||||||
filters: { district: 'Quan 7', propertyType: 'APARTMENT' },
|
filters: { district: 'Quan 7', propertyType: 'APARTMENT' },
|
||||||
alertEnabled: true,
|
alertEnabled: true,
|
||||||
lastAlertAt: null,
|
lastAlertAt: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
user: { id: 'user-1', email: 'user@example.com', fullName: 'Nguyen Van A' },
|
user: { id: 'user-1', email: 'user@example.com', fullName: 'Nguyen Van A' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,4 +125,59 @@ describe('SavedSearchAlertHandler', () => {
|
|||||||
|
|
||||||
expect(mockCommandBus.execute).not.toHaveBeenCalled();
|
expect(mockCommandBus.execute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('paginates across multiple batches and processes all matching rows', async () => {
|
||||||
|
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
|
||||||
|
mockPrisma.savedSearch.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
const BATCH = 500;
|
||||||
|
const makeRow = (n: number) => ({
|
||||||
|
id: `saved-${n}`,
|
||||||
|
userId: `user-${n}`,
|
||||||
|
name: `Search ${n}`,
|
||||||
|
filters: { district: 'Quan 7', propertyType: 'APARTMENT' },
|
||||||
|
alertEnabled: true,
|
||||||
|
lastAlertAt: null,
|
||||||
|
createdAt: new Date(Date.now() + n * 1000),
|
||||||
|
user: { id: `user-${n}`, email: `user${n}@example.com`, fullName: `User ${n}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const page1 = Array.from({ length: BATCH }, (_, i) => makeRow(i));
|
||||||
|
const page2 = Array.from({ length: BATCH }, (_, i) => makeRow(BATCH + i));
|
||||||
|
const page3 = [makeRow(BATCH * 2)];
|
||||||
|
|
||||||
|
mockPrisma.savedSearch.findMany
|
||||||
|
.mockResolvedValueOnce(page1)
|
||||||
|
.mockResolvedValueOnce(page2)
|
||||||
|
.mockResolvedValueOnce(page3)
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await handler.handle({ listingId: 'listing-1' });
|
||||||
|
|
||||||
|
expect(mockPrisma.savedSearch.findMany).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockCommandBus.execute).toHaveBeenCalledTimes(BATCH * 2 + 1);
|
||||||
|
|
||||||
|
// Second call must carry cursor from last row of first batch
|
||||||
|
const secondCallArgs = mockPrisma.savedSearch.findMany.mock.calls[1][0];
|
||||||
|
expect(secondCallArgs.where.createdAt?.gt).toEqual(page1[BATCH - 1]!.createdAt);
|
||||||
|
|
||||||
|
// Third call must carry cursor from last row of second batch
|
||||||
|
const thirdCallArgs = mockPrisma.savedSearch.findMany.mock.calls[2][0];
|
||||||
|
expect(thirdCallArgs.where.createdAt?.gt).toEqual(page2[BATCH - 1]!.createdAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses orderBy createdAt asc and take=500 on first page', async () => {
|
||||||
|
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
|
||||||
|
mockPrisma.savedSearch.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await handler.handle({ listingId: 'listing-1' });
|
||||||
|
|
||||||
|
expect(mockPrisma.savedSearch.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: 500,
|
||||||
|
where: expect.objectContaining({ alertEnabled: true }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
|||||||
import { SendNotificationCommand } from '@modules/notifications';
|
import { SendNotificationCommand } from '@modules/notifications';
|
||||||
import { PrismaService, LoggerService } from '@modules/shared';
|
import { PrismaService, LoggerService } from '@modules/shared';
|
||||||
|
|
||||||
|
/** Rows processed per cursor-page. Aligns with idx_savedsearch_alert_enabled batch size. */
|
||||||
|
const ALERT_BATCH_SIZE = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Daily cron job that checks saved searches against new listings published since lastAlertAt.
|
* Daily cron job that checks saved searches against new listings published since lastAlertAt.
|
||||||
* This complements the real-time event-based handler by catching any listings that
|
* This complements the real-time event-based handler by catching any listings that
|
||||||
* were missed (e.g., due to service downtime or event processing failures).
|
* were missed (e.g., due to service downtime or event processing failures).
|
||||||
|
*
|
||||||
|
* Memory footprint is bounded: rows are streamed in pages of {@link ALERT_BATCH_SIZE}
|
||||||
|
* via keyset pagination on `createdAt`, which the partial index
|
||||||
|
* `idx_savedsearch_alert_enabled` covers directly.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SavedSearchAlertCronService {
|
export class SavedSearchAlertCronService {
|
||||||
@@ -22,34 +29,49 @@ export class SavedSearchAlertCronService {
|
|||||||
this.logger.log('Starting daily saved search alert processing...', 'SavedSearchAlertCron');
|
this.logger.log('Starting daily saved search alert processing...', 'SavedSearchAlertCron');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const savedSearches = await this.prisma.savedSearch.findMany({
|
|
||||||
where: { alertEnabled: true },
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, email: true, fullName: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (savedSearches.length === 0) {
|
|
||||||
this.logger.log('No saved searches with alerts enabled', 'SavedSearchAlertCron');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalAlerts = 0;
|
let totalAlerts = 0;
|
||||||
|
let totalSearches = 0;
|
||||||
|
let cursor: Date | undefined;
|
||||||
|
|
||||||
for (const search of savedSearches) {
|
// Stream alert-enabled saved searches in bounded batches (keyset on createdAt).
|
||||||
try {
|
// idx_savedsearch_alert_enabled covers WHERE alertEnabled = true ORDER BY createdAt.
|
||||||
const matchCount = await this.checkAndAlert(search);
|
do {
|
||||||
totalAlerts += matchCount;
|
const batch = await this.prisma.savedSearch.findMany({
|
||||||
} catch (err) {
|
where: {
|
||||||
this.logger.warn(
|
alertEnabled: true,
|
||||||
`Failed to process alerts for saved search ${search.id}: ${err instanceof Error ? err.message : String(err)}`,
|
...(cursor ? { createdAt: { gt: cursor } } : {}),
|
||||||
'SavedSearchAlertCron',
|
},
|
||||||
);
|
include: {
|
||||||
|
user: { select: { id: true, email: true, fullName: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: ALERT_BATCH_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
|
||||||
|
totalSearches += batch.length;
|
||||||
|
|
||||||
|
for (const search of batch) {
|
||||||
|
try {
|
||||||
|
const matchCount = await this.checkAndAlert(search);
|
||||||
|
totalAlerts += matchCount;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to process alerts for saved search ${search.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
'SavedSearchAlertCron',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Advance cursor to the last row's createdAt for the next page.
|
||||||
|
cursor = batch[batch.length - 1]!.createdAt;
|
||||||
|
|
||||||
|
if (batch.length < ALERT_BATCH_SIZE) break;
|
||||||
|
} while (true);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Daily saved search alert processing completed: ${totalAlerts} alerts sent for ${savedSearches.length} searches`,
|
`Daily saved search alert processing completed: ${totalAlerts} alerts sent for ${totalSearches} searches`,
|
||||||
'SavedSearchAlertCron',
|
'SavedSearchAlertCron',
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ import { OnEvent } from '@nestjs/event-emitter';
|
|||||||
import { SendNotificationCommand } from '@modules/notifications';
|
import { SendNotificationCommand } from '@modules/notifications';
|
||||||
import { PrismaService, LoggerService } from '@modules/shared';
|
import { PrismaService, LoggerService } from '@modules/shared';
|
||||||
|
|
||||||
|
/** Rows processed per cursor-page. Aligns with idx_savedsearch_alert_enabled batch size. */
|
||||||
|
const ALERT_BATCH_SIZE = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When a new listing is approved, check all saved searches with alerts enabled
|
* When a new listing is approved, check all saved searches with alerts enabled
|
||||||
* and notify users whose filters match the new listing.
|
* and notify users whose filters match the new listing.
|
||||||
|
*
|
||||||
|
* Memory footprint is bounded: rows are streamed in pages of {@link ALERT_BATCH_SIZE}
|
||||||
|
* via keyset pagination on `createdAt`, which the partial index
|
||||||
|
* `idx_savedsearch_alert_enabled` covers directly.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SavedSearchAlertHandler {
|
export class SavedSearchAlertHandler {
|
||||||
@@ -34,28 +41,44 @@ export class SavedSearchAlertHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find all saved searches with alerts enabled
|
|
||||||
const savedSearches = await this.prisma.savedSearch.findMany({
|
|
||||||
where: { alertEnabled: true },
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, email: true, fullName: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let matchCount = 0;
|
let matchCount = 0;
|
||||||
|
let cursor: Date | undefined;
|
||||||
|
|
||||||
for (const search of savedSearches) {
|
// Stream alert-enabled saved searches in bounded batches (keyset on createdAt).
|
||||||
// Skip if search belongs to the listing owner
|
// idx_savedsearch_alert_enabled covers WHERE alertEnabled = true ORDER BY createdAt.
|
||||||
if (search.userId === listing.sellerId) {
|
do {
|
||||||
continue;
|
const batch = await this.prisma.savedSearch.findMany({
|
||||||
|
where: {
|
||||||
|
alertEnabled: true,
|
||||||
|
...(cursor ? { createdAt: { gt: cursor } } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, email: true, fullName: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: ALERT_BATCH_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
|
||||||
|
for (const search of batch) {
|
||||||
|
// Skip if search belongs to the listing owner
|
||||||
|
if (search.userId === listing.sellerId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = search.filters as Record<string, unknown>;
|
||||||
|
if (this.matchesFilters(listing, listing.property, filters)) {
|
||||||
|
matchCount++;
|
||||||
|
await this.sendAlert(search, listing, listing.property);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = search.filters as Record<string, unknown>;
|
// Advance cursor to the last row's createdAt for the next page.
|
||||||
if (this.matchesFilters(listing, listing.property, filters)) {
|
cursor = batch[batch.length - 1]!.createdAt;
|
||||||
matchCount++;
|
|
||||||
await this.sendAlert(search, listing, listing.property);
|
if (batch.length < ALERT_BATCH_SIZE) break;
|
||||||
}
|
} while (true);
|
||||||
}
|
|
||||||
|
|
||||||
if (matchCount > 0) {
|
if (matchCount > 0) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
EVENT_ENVELOPE_SCHEMA_VERSION,
|
||||||
|
assertValidEnvelope,
|
||||||
|
isKnownEventType,
|
||||||
|
isUuidV7,
|
||||||
|
uuidv7,
|
||||||
|
validateEnvelope,
|
||||||
|
type EventEnvelope,
|
||||||
|
} from '@goodgo/contracts-events';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('@goodgo/contracts-events', () => {
|
||||||
|
describe('uuidv7', () => {
|
||||||
|
it('produces a RFC 9562 v7 UUID', () => {
|
||||||
|
const id = uuidv7();
|
||||||
|
expect(isUuidV7(id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes the provided timestamp in the high bits', () => {
|
||||||
|
const now = 1_714_000_000_000; // stable, post-2024
|
||||||
|
const id = uuidv7(now);
|
||||||
|
// First 8 hex chars = high 32 bits of ms timestamp
|
||||||
|
const hex = id.replace(/-/g, '').slice(0, 12);
|
||||||
|
const ts = parseInt(hex, 16);
|
||||||
|
expect(ts).toBe(now);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates monotonic-ish ids across rapid calls', () => {
|
||||||
|
const a = uuidv7();
|
||||||
|
const b = uuidv7();
|
||||||
|
// v7 starts with the timestamp, so same-ms pairs compare by random bits;
|
||||||
|
// at worst they're equal-prefix — both must still be valid.
|
||||||
|
expect(isUuidV7(a)).toBe(true);
|
||||||
|
expect(isUuidV7(b)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateEnvelope', () => {
|
||||||
|
const base: EventEnvelope = {
|
||||||
|
schemaVersion: EVENT_ENVELOPE_SCHEMA_VERSION,
|
||||||
|
eventId: uuidv7(),
|
||||||
|
eventType: 'payment.completed',
|
||||||
|
occurredAt: '2026-04-23T14:00:00.000Z',
|
||||||
|
producer: 'api',
|
||||||
|
traceId: 'a'.repeat(32),
|
||||||
|
payload: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts a valid envelope', () => {
|
||||||
|
expect(validateEnvelope(base)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-v7 eventId', () => {
|
||||||
|
const issues = validateEnvelope({ ...base, eventId: 'not-a-uuid' });
|
||||||
|
expect(issues.map((i) => i.path)).toContain('eventId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid eventType', () => {
|
||||||
|
const issues = validateEnvelope({ ...base, eventType: 'PaymentCompleted' });
|
||||||
|
expect(issues.map((i) => i.path)).toContain('eventType');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a trace id that is not 32 hex chars', () => {
|
||||||
|
const issues = validateEnvelope({ ...base, traceId: 'short' });
|
||||||
|
expect(issues.map((i) => i.path)).toContain('traceId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects schemaVersion drift', () => {
|
||||||
|
const issues = validateEnvelope({ ...base, schemaVersion: 99 });
|
||||||
|
expect(issues.map((i) => i.path)).toContain('schemaVersion');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing payload', () => {
|
||||||
|
const { payload: _drop, ...rest } = base;
|
||||||
|
void _drop;
|
||||||
|
const issues = validateEnvelope(rest as unknown);
|
||||||
|
expect(issues.map((i) => i.path)).toContain('payload');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertValidEnvelope', () => {
|
||||||
|
it('throws with a flat message on invalid input', () => {
|
||||||
|
expect(() => assertValidEnvelope({ schemaVersion: 1 })).toThrow(/Invalid EventEnvelope/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isKnownEventType', () => {
|
||||||
|
it('recognises the first 3 schemas', () => {
|
||||||
|
expect(isKnownEventType('payment.completed')).toBe(true);
|
||||||
|
expect(isKnownEventType('listing.approved')).toBe(true);
|
||||||
|
expect(isKnownEventType('kyc.verified')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown event types', () => {
|
||||||
|
expect(isKnownEventType('payment.refunded')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { uuidv7, type EventEnvelope } from '@goodgo/contracts-events';
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { buildEnvelope } from '../envelope-builder';
|
||||||
|
import { InMemoryEventBus } from '../in-memory.event-bus';
|
||||||
|
|
||||||
|
describe('InMemoryEventBus', () => {
|
||||||
|
let bus: InMemoryEventBus;
|
||||||
|
beforeEach(() => {
|
||||||
|
bus = new InMemoryEventBus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function env(type: string, payload: unknown = {}): EventEnvelope {
|
||||||
|
return buildEnvelope({ producer: 'api' }, { eventType: type, payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('records published envelopes and returns a transport id', async () => {
|
||||||
|
const result = await bus.publish(env('payment.completed', { paymentId: 'p1' }));
|
||||||
|
expect(result.eventId).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
expect(result.stream).toBe('events:payment.completed');
|
||||||
|
expect(result.transportId).toMatch(/-\d+$/);
|
||||||
|
expect(bus.all()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by event type', async () => {
|
||||||
|
await bus.publishAll([
|
||||||
|
env('payment.completed'),
|
||||||
|
env('listing.approved'),
|
||||||
|
env('payment.completed'),
|
||||||
|
]);
|
||||||
|
expect(bus.byType('payment.completed')).toHaveLength(2);
|
||||||
|
expect(bus.byType('listing.approved')).toHaveLength(1);
|
||||||
|
expect(bus.byType('kyc.verified')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed envelopes', async () => {
|
||||||
|
const bad = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventId: 'not-a-v7',
|
||||||
|
eventType: 'payment.completed',
|
||||||
|
occurredAt: '2026-04-23T00:00:00Z',
|
||||||
|
producer: 'api',
|
||||||
|
traceId: 'a'.repeat(32),
|
||||||
|
payload: {},
|
||||||
|
};
|
||||||
|
await expect(bus.publish(bad as unknown as EventEnvelope)).rejects.toThrow(
|
||||||
|
/Invalid EventEnvelope/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset clears state', async () => {
|
||||||
|
await bus.publish(env('kyc.verified'));
|
||||||
|
expect(bus.all()).toHaveLength(1);
|
||||||
|
bus.reset();
|
||||||
|
expect(bus.all()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildEnvelope defaults traceId to 32 zeros when tracing is off', () => {
|
||||||
|
const e = buildEnvelope({ producer: 'api' }, { eventType: 'kyc.verified', payload: {} });
|
||||||
|
expect(e.traceId).toBe('0'.repeat(32));
|
||||||
|
expect(e.producer).toBe('api');
|
||||||
|
expect(e.schemaVersion).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildEnvelope honours explicit overrides (replay path)', () => {
|
||||||
|
const id = uuidv7();
|
||||||
|
const e = buildEnvelope(
|
||||||
|
{ producer: 'api' },
|
||||||
|
{
|
||||||
|
eventType: 'listing.approved',
|
||||||
|
payload: {},
|
||||||
|
eventId: id,
|
||||||
|
traceId: 'b'.repeat(32),
|
||||||
|
occurredAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
producer: 'replay-cli',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(e.eventId).toBe(id);
|
||||||
|
expect(e.traceId).toBe('b'.repeat(32));
|
||||||
|
expect(e.producer).toBe('replay-cli');
|
||||||
|
expect(e.occurredAt).toBe('2026-01-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { uuidv7, type EventEnvelope } from '@goodgo/contracts-events';
|
||||||
|
|
||||||
|
const ZERO_TRACE_ID = '0'.repeat(32);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the active trace id, or 32 zeros when none is propagated.
|
||||||
|
*
|
||||||
|
* The codebase does not yet depend on `@opentelemetry/api` (Sentry handles
|
||||||
|
* traces today). To honor the CTO condition that every envelope carries a
|
||||||
|
* `traceId` from Phase 0, we expose this hook as the integration point.
|
||||||
|
*/
|
||||||
|
export function currentTraceId(): string {
|
||||||
|
try {
|
||||||
|
const sentryGlobal = (globalThis as Record<string, unknown>)['__SENTRY__'];
|
||||||
|
if (sentryGlobal && typeof sentryGlobal === 'object') {
|
||||||
|
const hub = (sentryGlobal as { hub?: { getScope?: () => { getSpan?: () => { traceId?: string } | undefined } } }).hub;
|
||||||
|
const traceId = hub?.getScope?.()?.getSpan?.()?.traceId;
|
||||||
|
if (typeof traceId === 'string' && /^[0-9a-f]{32}$/i.test(traceId)) {
|
||||||
|
return traceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Defensive: never let trace lookup fail event publishing.
|
||||||
|
}
|
||||||
|
return ZERO_TRACE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildEnvelopeInput<TPayload> {
|
||||||
|
eventType: string;
|
||||||
|
payload: TPayload;
|
||||||
|
producer?: string;
|
||||||
|
occurredAt?: Date;
|
||||||
|
traceId?: string;
|
||||||
|
eventId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helper — builds an `EventEnvelope` with sensible defaults
|
||||||
|
* (UUIDv7 eventId, current trace id, ISO occurredAt). Kept outside
|
||||||
|
* the EventBus to make unit testing trivial.
|
||||||
|
*/
|
||||||
|
export function buildEnvelope<TPayload>(
|
||||||
|
defaults: { producer: string },
|
||||||
|
input: BuildEnvelopeInput<TPayload>,
|
||||||
|
): EventEnvelope<TPayload> {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventId: input.eventId ?? uuidv7(),
|
||||||
|
eventType: input.eventType,
|
||||||
|
occurredAt: (input.occurredAt ?? new Date()).toISOString(),
|
||||||
|
producer: input.producer ?? defaults.producer,
|
||||||
|
traceId: input.traceId ?? currentTraceId(),
|
||||||
|
payload: input.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { EventEnvelope } from '@goodgo/contracts-events';
|
||||||
|
|
||||||
|
export interface EventBus {
|
||||||
|
publish<T>(envelope: EventEnvelope<T>): Promise<PublishResult>;
|
||||||
|
publishAll(envelopes: EventEnvelope[]): Promise<PublishResult[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishResult {
|
||||||
|
eventId: string;
|
||||||
|
transportId: string;
|
||||||
|
stream: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EVENT_BUS = Symbol('EventBus');
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { type EventEnvelope, assertValidEnvelope } from '@goodgo/contracts-events';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { EventBus, PublishResult } from './event-bus.interface';
|
||||||
|
import { streamFor } from './redis-streams.event-bus';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test/dev double for the EventBus. Records every published envelope
|
||||||
|
* and exposes lookup helpers. Used by:
|
||||||
|
* - unit tests in this module
|
||||||
|
* - Phase 1 dual-publish diff harness
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class InMemoryEventBus implements EventBus {
|
||||||
|
private readonly published: { stream: string; envelope: EventEnvelope }[] = [];
|
||||||
|
private sequence = 0;
|
||||||
|
|
||||||
|
async publish<T>(envelope: EventEnvelope<T>): Promise<PublishResult> {
|
||||||
|
assertValidEnvelope(envelope);
|
||||||
|
const stream = streamFor(envelope.eventType);
|
||||||
|
this.published.push({ stream, envelope: envelope as EventEnvelope });
|
||||||
|
this.sequence += 1;
|
||||||
|
return {
|
||||||
|
eventId: envelope.eventId,
|
||||||
|
transportId: `${Date.now()}-${this.sequence}`,
|
||||||
|
stream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishAll(envelopes: EventEnvelope[]): Promise<PublishResult[]> {
|
||||||
|
const out: PublishResult[] = [];
|
||||||
|
for (const env of envelopes) {
|
||||||
|
out.push(await this.publish(env));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
all(): readonly EventEnvelope[] {
|
||||||
|
return this.published.map((p) => p.envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
byType<T = unknown>(eventType: string): EventEnvelope<T>[] {
|
||||||
|
return this.published
|
||||||
|
.filter((p) => p.envelope.eventType === eventType)
|
||||||
|
.map((p) => p.envelope as EventEnvelope<T>);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.published.length = 0;
|
||||||
|
this.sequence = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export {
|
||||||
|
EVENT_BUS,
|
||||||
|
type EventBus,
|
||||||
|
type PublishResult,
|
||||||
|
} from './event-bus.interface';
|
||||||
|
export { RedisStreamsEventBus, streamFor } from './redis-streams.event-bus';
|
||||||
|
export { InMemoryEventBus } from './in-memory.event-bus';
|
||||||
|
export {
|
||||||
|
buildEnvelope,
|
||||||
|
currentTraceId,
|
||||||
|
type BuildEnvelopeInput,
|
||||||
|
} from './envelope-builder';
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { type EventEnvelope, assertValidEnvelope } from '@goodgo/contracts-events';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
|
||||||
|
import { RedisService } from '../redis.service';
|
||||||
|
import type { EventBus, PublishResult } from './event-bus.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream naming: one stream per event-type. Per-event consumer groups
|
||||||
|
* are created lazily by individual consumer services (Phase 1+).
|
||||||
|
*
|
||||||
|
* events:payment.completed
|
||||||
|
* events:listing.approved
|
||||||
|
* events:kyc.verified
|
||||||
|
*
|
||||||
|
* `MAXLEN ~ 100000` per stream — RFC §5 mitigation. Nightly archive
|
||||||
|
* to S3 lands in Phase 3.
|
||||||
|
*/
|
||||||
|
const STREAM_PREFIX = 'events:';
|
||||||
|
const DEFAULT_MAXLEN = 100_000;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedisStreamsEventBus implements EventBus {
|
||||||
|
private readonly logger = new Logger(RedisStreamsEventBus.name);
|
||||||
|
private readonly maxlen: number;
|
||||||
|
|
||||||
|
constructor(private readonly redis: RedisService) {
|
||||||
|
const envMax = process.env['EVENT_BUS_STREAM_MAXLEN'];
|
||||||
|
this.maxlen = envMax ? Number(envMax) : DEFAULT_MAXLEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish<T>(envelope: EventEnvelope<T>): Promise<PublishResult> {
|
||||||
|
assertValidEnvelope(envelope);
|
||||||
|
const stream = streamFor(envelope.eventType);
|
||||||
|
const client = this.redis.getClient();
|
||||||
|
// XADD <stream> MAXLEN ~ <n> * envelope <json>
|
||||||
|
const transportId = await client.xadd(
|
||||||
|
stream,
|
||||||
|
'MAXLEN',
|
||||||
|
'~',
|
||||||
|
this.maxlen,
|
||||||
|
'*',
|
||||||
|
'envelope',
|
||||||
|
JSON.stringify(envelope),
|
||||||
|
);
|
||||||
|
if (transportId === null) {
|
||||||
|
throw new Error(`XADD returned NIL for stream ${stream}`);
|
||||||
|
}
|
||||||
|
this.logger.debug(
|
||||||
|
`Published ${envelope.eventType} eventId=${envelope.eventId} -> ${stream}@${transportId}`,
|
||||||
|
);
|
||||||
|
return { eventId: envelope.eventId, transportId, stream };
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishAll(envelopes: EventEnvelope[]): Promise<PublishResult[]> {
|
||||||
|
const out: PublishResult[] = [];
|
||||||
|
for (const env of envelopes) {
|
||||||
|
out.push(await this.publish(env));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function streamFor(eventType: string): string {
|
||||||
|
return `${STREAM_PREFIX}${eventType}`;
|
||||||
|
}
|
||||||
@@ -42,3 +42,18 @@ export { FileValidationPipe } from './pipes/file-validation.pipe';
|
|||||||
export type { FileValidationOptions, UploadedFile } from './pipes/file-validation.pipe';
|
export type { FileValidationOptions, UploadedFile } from './pipes/file-validation.pipe';
|
||||||
export { validateEnv, validateJwtSecret } from './env-validation';
|
export { validateEnv, validateJwtSecret } from './env-validation';
|
||||||
export { cacheMetaStorage, type CacheMeta, type CacheMetaStore } from './cache-meta.store';
|
export { cacheMetaStorage, type CacheMeta, type CacheMetaStore } from './cache-meta.store';
|
||||||
|
// RFC-001 Phase 1 — API versioning.
|
||||||
|
export {
|
||||||
|
API_VERSION_REGISTRY,
|
||||||
|
resolveMajorSpec,
|
||||||
|
type ApiMajorSpec,
|
||||||
|
type ApiVersionDeprecation,
|
||||||
|
type ApiVersionRegistry,
|
||||||
|
} from './versioning';
|
||||||
|
export {
|
||||||
|
VersionInterceptor,
|
||||||
|
DeprecationInterceptor,
|
||||||
|
API_MINOR_HEADER,
|
||||||
|
API_MINOR_RESOLVED_HEADER,
|
||||||
|
type ResolvedApiVersion,
|
||||||
|
} from './interceptors';
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { buildEnvelope } from '../../event-bus';
|
||||||
|
import { InMemoryEventBus } from '../../event-bus/in-memory.event-bus';
|
||||||
|
import { OutboxRelay } from '../outbox.relay';
|
||||||
|
|
||||||
|
type OutboxRow = {
|
||||||
|
id: string;
|
||||||
|
eventId: string;
|
||||||
|
eventType: string;
|
||||||
|
aggregateId: string | null;
|
||||||
|
envelope: unknown;
|
||||||
|
createdAt: Date;
|
||||||
|
publishedAt: Date | null;
|
||||||
|
attempts: number;
|
||||||
|
lastError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight fake Prisma — just enough for OutboxRelay.tick():
|
||||||
|
* - pg_try_advisory_lock
|
||||||
|
* - eventOutbox.findMany / update
|
||||||
|
*/
|
||||||
|
function makeFakePrisma(rows: OutboxRow[], options: { acquireLock?: boolean } = {}) {
|
||||||
|
const acquireLock = options.acquireLock ?? true;
|
||||||
|
return {
|
||||||
|
$queryRawUnsafe: vi.fn(async (sql: string) => {
|
||||||
|
if (sql.includes('pg_try_advisory_lock')) return [{ locked: acquireLock }];
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
eventOutbox: {
|
||||||
|
findMany: vi.fn(async (args: { where?: { publishedAt: null }; take?: number }) => {
|
||||||
|
const pending = rows.filter((r) => r.publishedAt === null);
|
||||||
|
return pending.slice(0, args.take ?? 100);
|
||||||
|
}),
|
||||||
|
update: vi.fn(async (args: { where: { id: string }; data: Record<string, unknown> }) => {
|
||||||
|
const row = rows.find((r) => r.id === args.where.id);
|
||||||
|
if (!row) throw new Error('row not found');
|
||||||
|
const data = args.data;
|
||||||
|
if ('publishedAt' in data) row.publishedAt = data['publishedAt'] as Date | null;
|
||||||
|
if ('lastError' in data) row.lastError = data['lastError'] as string | null;
|
||||||
|
if ('attempts' in data) {
|
||||||
|
const v = data['attempts'];
|
||||||
|
if (v && typeof v === 'object' && 'increment' in v) {
|
||||||
|
row.attempts += (v as { increment: number }).increment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeRow(type: string): OutboxRow {
|
||||||
|
const envelope = buildEnvelope({ producer: 'api' }, { eventType: type, payload: { k: 'v' } });
|
||||||
|
return {
|
||||||
|
id: `row-${envelope.eventId}`,
|
||||||
|
eventId: envelope.eventId,
|
||||||
|
eventType: envelope.eventType,
|
||||||
|
aggregateId: null,
|
||||||
|
envelope,
|
||||||
|
createdAt: new Date(),
|
||||||
|
publishedAt: null,
|
||||||
|
attempts: 0,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OutboxRelay.tick', () => {
|
||||||
|
let bus: InMemoryEventBus;
|
||||||
|
beforeEach(() => {
|
||||||
|
bus = new InMemoryEventBus();
|
||||||
|
process.env['EVENT_OUTBOX_RELAY_ENABLED'] = 'false'; // don't auto-start timer
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drains pending rows into the EventBus and marks them published', async () => {
|
||||||
|
const rows = [fakeRow('payment.completed'), fakeRow('listing.approved')];
|
||||||
|
const prisma = makeFakePrisma(rows);
|
||||||
|
const relay = new OutboxRelay(prisma as never, bus);
|
||||||
|
|
||||||
|
const result = await relay.tick();
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true);
|
||||||
|
expect(result.processed).toBe(2);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
expect(bus.all()).toHaveLength(2);
|
||||||
|
expect(rows.every((r) => r.publishedAt instanceof Date)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the advisory lock is held elsewhere', async () => {
|
||||||
|
const rows = [fakeRow('kyc.verified')];
|
||||||
|
const prisma = makeFakePrisma(rows, { acquireLock: false });
|
||||||
|
const relay = new OutboxRelay(prisma as never, bus);
|
||||||
|
|
||||||
|
const result = await relay.tick();
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(false);
|
||||||
|
expect(result.processed).toBe(0);
|
||||||
|
expect(bus.all()).toHaveLength(0);
|
||||||
|
expect(rows[0]?.publishedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records lastError and leaves publishedAt null on publish failure', async () => {
|
||||||
|
const rows = [fakeRow('payment.completed')];
|
||||||
|
const prisma = makeFakePrisma(rows);
|
||||||
|
const failing = {
|
||||||
|
publish: vi.fn(async () => {
|
||||||
|
throw new Error('XADD refused');
|
||||||
|
}),
|
||||||
|
publishAll: vi.fn(),
|
||||||
|
};
|
||||||
|
const relay = new OutboxRelay(prisma as never, failing as never);
|
||||||
|
|
||||||
|
const result = await relay.tick();
|
||||||
|
|
||||||
|
expect(result.processed).toBe(0);
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
expect(rows[0]?.publishedAt).toBeNull();
|
||||||
|
expect(rows[0]?.lastError).toContain('XADD refused');
|
||||||
|
expect(rows[0]?.attempts).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips rows that are already published', async () => {
|
||||||
|
const row = fakeRow('listing.approved');
|
||||||
|
row.publishedAt = new Date();
|
||||||
|
const prisma = makeFakePrisma([row]);
|
||||||
|
const relay = new OutboxRelay(prisma as never, bus);
|
||||||
|
|
||||||
|
const result = await relay.tick();
|
||||||
|
|
||||||
|
expect(result.processed).toBe(0);
|
||||||
|
expect(bus.all()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { OutboxService, type OutboxAppendOptions } from './outbox.service';
|
||||||
|
export { OutboxRelay } from './outbox.relay';
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { EventEnvelope } from '@goodgo/contracts-events';
|
||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
type OnModuleDestroy,
|
||||||
|
type OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { EVENT_BUS, type EventBus } from '../event-bus/event-bus.interface';
|
||||||
|
import { type PrismaService } from '../prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-process relay that drains `event_outbox` into the EventBus.
|
||||||
|
*
|
||||||
|
* Concurrency: every node tries to acquire the same Postgres advisory
|
||||||
|
* lock (`pg_try_advisory_lock`); only the holder runs the poll loop.
|
||||||
|
* This is the single-process + advisory-lock design called out in
|
||||||
|
* RFC-004 §4 ("No leader-election library yet").
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ADVISORY_LOCK_KEY = 0xe7b04204; // bespoke 32-bit key for the outbox relay
|
||||||
|
const DEFAULT_POLL_MS = 1_000;
|
||||||
|
const DEFAULT_BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OutboxRelay implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(OutboxRelay.name);
|
||||||
|
private readonly pollIntervalMs: number;
|
||||||
|
private readonly batchSize: number;
|
||||||
|
private readonly enabled: boolean;
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
private running = false;
|
||||||
|
private stopped = false;
|
||||||
|
private holdsLock = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@Inject(EVENT_BUS) private readonly bus: EventBus,
|
||||||
|
) {
|
||||||
|
this.pollIntervalMs = Number(process.env['EVENT_OUTBOX_POLL_MS'] ?? DEFAULT_POLL_MS);
|
||||||
|
this.batchSize = Number(process.env['EVENT_OUTBOX_BATCH_SIZE'] ?? DEFAULT_BATCH_SIZE);
|
||||||
|
this.enabled = (process.env['EVENT_OUTBOX_RELAY_ENABLED'] ?? 'true').toLowerCase() !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
if (!this.enabled) {
|
||||||
|
this.logger.log('OutboxRelay disabled via EVENT_OUTBOX_RELAY_ENABLED=false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.scheduleNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
this.stopped = true;
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
if (this.holdsLock) {
|
||||||
|
try {
|
||||||
|
await this.prisma.$queryRawUnsafe(`SELECT pg_advisory_unlock(${ADVISORY_LOCK_KEY})`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to release advisory lock: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleNext(): void {
|
||||||
|
if (this.stopped) return;
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
void this.tick().finally(() => this.scheduleNext());
|
||||||
|
}, this.pollIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public for tests — drains one batch synchronously. */
|
||||||
|
async tick(): Promise<{ acquired: boolean; processed: number; failed: number }> {
|
||||||
|
if (this.running) return { acquired: false, processed: 0, failed: 0 };
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
const acquired = await this.tryAcquireLock();
|
||||||
|
if (!acquired) return { acquired: false, processed: 0, failed: 0 };
|
||||||
|
const { processed, failed } = await this.drainBatch();
|
||||||
|
return { acquired: true, processed, failed };
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryAcquireLock(): Promise<boolean> {
|
||||||
|
if (this.holdsLock) return true;
|
||||||
|
const rows = await this.prisma.$queryRawUnsafe<{ locked: boolean }[]>(
|
||||||
|
`SELECT pg_try_advisory_lock(${ADVISORY_LOCK_KEY}) AS locked`,
|
||||||
|
);
|
||||||
|
const locked = rows[0]?.locked === true;
|
||||||
|
if (locked) {
|
||||||
|
this.holdsLock = true;
|
||||||
|
this.logger.log('Acquired event_outbox advisory lock — this node is now the relay leader');
|
||||||
|
}
|
||||||
|
return locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drainBatch(): Promise<{ processed: number; failed: number }> {
|
||||||
|
const pending = await this.prisma.eventOutbox.findMany({
|
||||||
|
where: { publishedAt: null },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: this.batchSize,
|
||||||
|
});
|
||||||
|
if (pending.length === 0) return { processed: 0, failed: 0 };
|
||||||
|
let processed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const row of pending) {
|
||||||
|
try {
|
||||||
|
const envelope = row.envelope as unknown as EventEnvelope;
|
||||||
|
await this.bus.publish(envelope);
|
||||||
|
await this.prisma.eventOutbox.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: { publishedAt: new Date(), attempts: { increment: 1 }, lastError: null },
|
||||||
|
});
|
||||||
|
processed += 1;
|
||||||
|
} catch (err) {
|
||||||
|
failed += 1;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.error(
|
||||||
|
`Outbox publish failed for eventId=${row.eventId} type=${row.eventType}: ${message}`,
|
||||||
|
);
|
||||||
|
await this.prisma.eventOutbox.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: { attempts: { increment: 1 }, lastError: message.slice(0, 1000) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (processed > 0 || failed > 0) {
|
||||||
|
this.logger.debug(`Outbox drained batch: processed=${processed} failed=${failed}`);
|
||||||
|
}
|
||||||
|
return { processed, failed };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { type EventEnvelope, assertValidEnvelope } from '@goodgo/contracts-events';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
|
||||||
|
import { PrismaService } from '../prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transactional outbox writer. Call inside the same Prisma transaction
|
||||||
|
* as the domain change so the row commits atomically with the state
|
||||||
|
* mutation it describes. The Outbox **never** publishes directly; the
|
||||||
|
* relay (`OutboxRelay`) tails `event_outbox` and forwards to the EventBus.
|
||||||
|
*/
|
||||||
|
export interface OutboxAppendOptions {
|
||||||
|
aggregateId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventOutboxDelegate = PrismaService['eventOutbox'];
|
||||||
|
type PrismaTxLike = Pick<EventOutboxDelegate, 'create'> | { eventOutbox: Pick<EventOutboxDelegate, 'create'> };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OutboxService {
|
||||||
|
private readonly logger = new Logger(OutboxService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async append(
|
||||||
|
tx: PrismaTxLike | PrismaService,
|
||||||
|
envelope: EventEnvelope,
|
||||||
|
options: OutboxAppendOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
assertValidEnvelope(envelope);
|
||||||
|
const client = ('eventOutbox' in tx ? tx.eventOutbox : tx) as EventOutboxDelegate;
|
||||||
|
await client.create({
|
||||||
|
data: {
|
||||||
|
eventId: envelope.eventId,
|
||||||
|
eventType: envelope.eventType,
|
||||||
|
aggregateId: options.aggregateId ?? null,
|
||||||
|
envelope: envelope as unknown as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async appendStandalone(envelope: EventEnvelope, options: OutboxAppendOptions = {}): Promise<void> {
|
||||||
|
await this.append(this.prisma, envelope, options);
|
||||||
|
this.logger.warn(
|
||||||
|
`appendStandalone used for ${envelope.eventType} eventId=${envelope.eventId} — prefer the transactional append()`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Global, type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
|
import { Global, type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { APP_FILTER } from '@nestjs/core';
|
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { PrometheusModule, makeCounterProvider } from '@willsoto/nestjs-prometheus';
|
import { PrometheusModule, makeCounterProvider } from '@willsoto/nestjs-prometheus';
|
||||||
import {
|
import {
|
||||||
@@ -10,8 +10,11 @@ import {
|
|||||||
CACHE_DEGRADATION_TOTAL,
|
CACHE_DEGRADATION_TOTAL,
|
||||||
} from './infrastructure/cache.service';
|
} from './infrastructure/cache.service';
|
||||||
import { EventBusService } from './infrastructure/event-bus.service';
|
import { EventBusService } from './infrastructure/event-bus.service';
|
||||||
|
import { EVENT_BUS, RedisStreamsEventBus } from './infrastructure/event-bus';
|
||||||
|
import { OutboxRelay, OutboxService } from './infrastructure/outbox';
|
||||||
import { FieldEncryptionService } from './infrastructure/field-encryption.service';
|
import { FieldEncryptionService } from './infrastructure/field-encryption.service';
|
||||||
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
|
||||||
|
import { DeprecationInterceptor, VersionInterceptor } from './infrastructure/interceptors';
|
||||||
import { LoggerService } from './infrastructure/logger.service';
|
import { LoggerService } from './infrastructure/logger.service';
|
||||||
import { CorrelationIdMiddleware } from './infrastructure/middleware/correlation-id.middleware';
|
import { CorrelationIdMiddleware } from './infrastructure/middleware/correlation-id.middleware';
|
||||||
import { CsrfMiddleware } from './infrastructure/middleware/csrf.middleware';
|
import { CsrfMiddleware } from './infrastructure/middleware/csrf.middleware';
|
||||||
@@ -35,6 +38,10 @@ import { TypesenseClientService } from './infrastructure/typesense-client.servic
|
|||||||
RedisService,
|
RedisService,
|
||||||
CacheService,
|
CacheService,
|
||||||
EventBusService,
|
EventBusService,
|
||||||
|
// RFC-004 Phase 0 (GOO-172) — durable async messaging backbone.
|
||||||
|
{ provide: EVENT_BUS, useClass: RedisStreamsEventBus },
|
||||||
|
OutboxService,
|
||||||
|
OutboxRelay,
|
||||||
TypesenseClientService,
|
TypesenseClientService,
|
||||||
makeCounterProvider({
|
makeCounterProvider({
|
||||||
name: CACHE_HIT_TOTAL,
|
name: CACHE_HIT_TOTAL,
|
||||||
@@ -55,8 +62,18 @@ import { TypesenseClientService } from './infrastructure/typesense-client.servic
|
|||||||
provide: APP_FILTER,
|
provide: APP_FILTER,
|
||||||
useClass: GlobalExceptionFilter,
|
useClass: GlobalExceptionFilter,
|
||||||
},
|
},
|
||||||
|
// RFC-001 Phase 1 (GOO-170) — order matters: VersionInterceptor first
|
||||||
|
// populates req.apiVersion; DeprecationInterceptor reads from it.
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: VersionInterceptor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: DeprecationInterceptor,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, FieldEncryptionService, TypesenseClientService, PrometheusModule],
|
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, EVENT_BUS, OutboxService, FieldEncryptionService, TypesenseClientService, PrometheusModule],
|
||||||
})
|
})
|
||||||
export class SharedModule implements NestModule {
|
export class SharedModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer): void {
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
|
|||||||
86
apps/web/__tests__/i18n-key-parity.spec.ts
Normal file
86
apps/web/__tests__/i18n-key-parity.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* i18n key parity tests — ensures `en.json` and `vi.json` stay in sync.
|
||||||
|
*
|
||||||
|
* Rules enforced:
|
||||||
|
* 1. Both files must have the same set of flattened leaf keys.
|
||||||
|
* 2. No key value may be an empty string in either locale.
|
||||||
|
* 3. Locale metadata keys must be present in both files.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import enMessages from '../messages/en.json';
|
||||||
|
import viMessages from '../messages/vi.json';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
type MessageDict = Record<string, unknown>;
|
||||||
|
|
||||||
|
function flattenKeys(obj: MessageDict, prefix = ''): string[] {
|
||||||
|
return Object.entries(obj).flatMap(([key, value]) => {
|
||||||
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return flattenKeys(value as MessageDict, fullKey);
|
||||||
|
}
|
||||||
|
return [fullKey];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenEntries(obj: MessageDict, prefix = ''): Array<[string, string]> {
|
||||||
|
return Object.entries(obj).flatMap(([key, value]) => {
|
||||||
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return flattenEntries(value as MessageDict, fullKey);
|
||||||
|
}
|
||||||
|
return [[fullKey, String(value)]];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('i18n message key parity (en ↔ vi)', () => {
|
||||||
|
const enKeys = new Set(flattenKeys(enMessages));
|
||||||
|
const viKeys = new Set(flattenKeys(viMessages));
|
||||||
|
|
||||||
|
it('vi.json has no extra keys missing from en.json', () => {
|
||||||
|
const extraInVi = [...viKeys].filter((k) => !enKeys.has(k));
|
||||||
|
expect(extraInVi, `Keys present in vi.json but not en.json: ${JSON.stringify(extraInVi)}`).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('en.json has no extra keys missing from vi.json', () => {
|
||||||
|
const extraInEn = [...enKeys].filter((k) => !viKeys.has(k));
|
||||||
|
expect(extraInEn, `Keys present in en.json but not vi.json: ${JSON.stringify(extraInEn)}`).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('en.json has no empty string values', () => {
|
||||||
|
const empty = flattenEntries(enMessages).filter(([, v]) => v.trim() === '');
|
||||||
|
expect(empty, `Empty values in en.json: ${JSON.stringify(empty.map(([k]) => k))}`).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vi.json has no empty string values', () => {
|
||||||
|
const empty = flattenEntries(viMessages).filter(([, v]) => v.trim() === '');
|
||||||
|
expect(empty, `Empty values in vi.json: ${JSON.stringify(empty.map(([k]) => k))}`).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both locales define the same top-level namespace keys', () => {
|
||||||
|
const enTopLevel = Object.keys(enMessages).sort();
|
||||||
|
const viTopLevel = Object.keys(viMessages).sort();
|
||||||
|
expect(viTopLevel).toEqual(enTopLevel);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('i18n locale config', () => {
|
||||||
|
it('language namespace exists in en.json with the expected locale keys', () => {
|
||||||
|
const lang = (enMessages as MessageDict).language as MessageDict | undefined;
|
||||||
|
expect(lang).toBeDefined();
|
||||||
|
expect(Object.keys(lang ?? {})).toContain('vi');
|
||||||
|
expect(Object.keys(lang ?? {})).toContain('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('language namespace exists in vi.json with the expected locale keys', () => {
|
||||||
|
const lang = (viMessages as MessageDict).language as MessageDict | undefined;
|
||||||
|
expect(lang).toBeDefined();
|
||||||
|
expect(Object.keys(lang ?? {})).toContain('vi');
|
||||||
|
expect(Object.keys(lang ?? {})).toContain('en');
|
||||||
|
});
|
||||||
|
});
|
||||||
199
apps/web/__tests__/middleware.spec.ts
Normal file
199
apps/web/__tests__/middleware.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Minimal next/server stubs — use vi.hoisted so refs are available at mock time
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const { mockRedirectFn, mockNextFn, mockIntlMiddleware } = vi.hoisted(() => {
|
||||||
|
const mockRedirectFn = vi.fn((url: URL | string) => ({
|
||||||
|
type: 'redirect',
|
||||||
|
url: typeof url === 'string' ? url : url.toString(),
|
||||||
|
}));
|
||||||
|
const mockNextFn = vi.fn(() => ({ type: 'next' }));
|
||||||
|
const mockIntlMiddleware = vi.fn((_req: unknown) => ({ type: 'intl' }));
|
||||||
|
return { mockRedirectFn, mockNextFn, mockIntlMiddleware };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('next/server', () => ({
|
||||||
|
NextResponse: {
|
||||||
|
redirect: mockRedirectFn,
|
||||||
|
next: mockNextFn,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stub intlMiddleware — captures its input and returns a sentinel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
vi.mock('next-intl/middleware', () => ({
|
||||||
|
default: () => mockIntlMiddleware,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stub routing so the module resolves
|
||||||
|
vi.mock('@/i18n/routing', () => ({
|
||||||
|
routing: { locales: ['vi', 'en'], defaultLocale: 'vi', localePrefix: 'as-needed' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Now import the middleware (after mocks are registered)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
import { middleware } from '../middleware';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function makeRequest(pathname: string, hasCookie = false): NextRequest {
|
||||||
|
const url = new URL(`http://localhost${pathname}`);
|
||||||
|
return {
|
||||||
|
nextUrl: url,
|
||||||
|
url: url.toString(),
|
||||||
|
cookies: {
|
||||||
|
has: (name: string) => name === 'goodgo_authenticated' && hasCookie,
|
||||||
|
},
|
||||||
|
} as unknown as NextRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('middleware – authentication guard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects unauthenticated user from a protected path to /login', () => {
|
||||||
|
middleware(makeRequest('/dashboard', false));
|
||||||
|
expect(mockRedirectFn).toHaveBeenCalledOnce();
|
||||||
|
const calledUrl: URL = mockRedirectFn.mock.calls[0][0] as URL;
|
||||||
|
expect(calledUrl.pathname).toBe('/login');
|
||||||
|
expect(calledUrl.searchParams.get('redirect')).toBe('/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the redirect param for a nested protected path', () => {
|
||||||
|
middleware(makeRequest('/dashboard/profile', false));
|
||||||
|
const calledUrl: URL = mockRedirectFn.mock.calls[0][0] as URL;
|
||||||
|
expect(calledUrl.searchParams.get('redirect')).toBe('/dashboard/profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows unauthenticated user to reach /', () => {
|
||||||
|
middleware(makeRequest('/', false));
|
||||||
|
expect(mockRedirectFn).not.toHaveBeenCalled();
|
||||||
|
expect(mockIntlMiddleware).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows unauthenticated user to reach /search', () => {
|
||||||
|
middleware(makeRequest('/search', false));
|
||||||
|
expect(mockRedirectFn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows unauthenticated user to reach /listings/123', () => {
|
||||||
|
middleware(makeRequest('/listings/123', false));
|
||||||
|
expect(mockRedirectFn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows unauthenticated user to reach /login', () => {
|
||||||
|
middleware(makeRequest('/login', false));
|
||||||
|
expect(mockRedirectFn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows unauthenticated user to reach /register', () => {
|
||||||
|
middleware(makeRequest('/register', false));
|
||||||
|
expect(mockRedirectFn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows unauthenticated user to reach /auth/callback/google', () => {
|
||||||
|
middleware(makeRequest('/auth/callback/google', false));
|
||||||
|
expect(mockRedirectFn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows authenticated user to access a protected path', () => {
|
||||||
|
middleware(makeRequest('/dashboard', true));
|
||||||
|
expect(mockRedirectFn).not.toHaveBeenCalled();
|
||||||
|
expect(mockIntlMiddleware).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('middleware – auth-only redirect (already authenticated)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects authenticated user away from /login to /dashboard', () => {
|
||||||
|
middleware(makeRequest('/login', true));
|
||||||
|
expect(mockRedirectFn).toHaveBeenCalledOnce();
|
||||||
|
const calledUrl: URL = mockRedirectFn.mock.calls[0][0] as URL;
|
||||||
|
expect(calledUrl.pathname).toBe('/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects authenticated user away from /register to /dashboard', () => {
|
||||||
|
middleware(makeRequest('/register', true));
|
||||||
|
expect(mockRedirectFn).toHaveBeenCalledOnce();
|
||||||
|
const calledUrl: URL = mockRedirectFn.mock.calls[0][0] as URL;
|
||||||
|
expect(calledUrl.pathname).toBe('/dashboard');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('middleware – locale prefix stripping', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips /vi locale prefix before evaluating the guard', () => {
|
||||||
|
middleware(makeRequest('/vi/dashboard', false));
|
||||||
|
expect(mockRedirectFn).toHaveBeenCalledOnce();
|
||||||
|
const calledUrl: URL = mockRedirectFn.mock.calls[0][0] as URL;
|
||||||
|
expect(calledUrl.pathname).toBe('/login');
|
||||||
|
expect(calledUrl.searchParams.get('redirect')).toBe('/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips /en locale prefix before evaluating the guard', () => {
|
||||||
|
middleware(makeRequest('/en/dashboard', false));
|
||||||
|
expect(mockRedirectFn).toHaveBeenCalledOnce();
|
||||||
|
const calledUrl: URL = mockRedirectFn.mock.calls[0][0] as URL;
|
||||||
|
expect(calledUrl.pathname).toBe('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips /vi locale and recognises /vi/login as auth-only path', () => {
|
||||||
|
middleware(makeRequest('/vi/login', true));
|
||||||
|
expect(mockRedirectFn).toHaveBeenCalledOnce();
|
||||||
|
const calledUrl: URL = mockRedirectFn.mock.calls[0][0] as URL;
|
||||||
|
expect(calledUrl.pathname).toBe('/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips /en locale and allows unauthenticated access to /en/', () => {
|
||||||
|
middleware(makeRequest('/en/', false));
|
||||||
|
expect(mockRedirectFn).not.toHaveBeenCalled();
|
||||||
|
expect(mockIntlMiddleware).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through to intlMiddleware for locale-prefixed public paths', () => {
|
||||||
|
middleware(makeRequest('/vi/search', false));
|
||||||
|
expect(mockRedirectFn).not.toHaveBeenCalled();
|
||||||
|
expect(mockIntlMiddleware).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('middleware – intl middleware delegation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates to intlMiddleware for all pass-through cases', () => {
|
||||||
|
middleware(makeRequest('/', false));
|
||||||
|
expect(mockIntlMiddleware).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates to intlMiddleware for authenticated protected paths', () => {
|
||||||
|
middleware(makeRequest('/dashboard', true));
|
||||||
|
expect(mockIntlMiddleware).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call intlMiddleware when redirecting to login', () => {
|
||||||
|
middleware(makeRequest('/dashboard', false));
|
||||||
|
expect(mockIntlMiddleware).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call intlMiddleware when redirecting authenticated user away from login', () => {
|
||||||
|
middleware(makeRequest('/login', true));
|
||||||
|
expect(mockIntlMiddleware).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
127
apps/web/app/[locale]/(admin)/admin/__tests__/admin-kyc.spec.tsx
Normal file
127
apps/web/app/[locale]/(admin)/admin/__tests__/admin-kyc.spec.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => {
|
||||||
|
const icon = (name: string) => (props: Record<string, unknown>) => <span data-testid={`icon-${name}`} {...props} />;
|
||||||
|
return {
|
||||||
|
CheckCircle: icon('check'),
|
||||||
|
XCircle: icon('x'),
|
||||||
|
RefreshCw: icon('refresh'),
|
||||||
|
ChevronLeft: icon('chevron-left'),
|
||||||
|
ChevronRight: icon('chevron-right'),
|
||||||
|
ShieldCheck: icon('shield'),
|
||||||
|
X: icon('close'),
|
||||||
|
User: icon('user'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/design-system/status-chip', () => ({
|
||||||
|
StatusChip: ({ status }: { status: string }) => <span data-testid="status-chip">{status}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetKycQueue = vi.fn();
|
||||||
|
const mockApproveKyc = vi.fn();
|
||||||
|
const mockRejectKyc = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/admin-api', () => ({
|
||||||
|
adminApi: {
|
||||||
|
getKycQueue: (...args: unknown[]) => mockGetKycQueue(...args),
|
||||||
|
approveKyc: (...args: unknown[]) => mockApproveKyc(...args),
|
||||||
|
rejectKyc: (...args: unknown[]) => mockRejectKyc(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AdminKycPage from '../kyc/page';
|
||||||
|
|
||||||
|
const mockQueueData = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
userId: 'u1',
|
||||||
|
fullName: 'Nguyen Van A',
|
||||||
|
phone: '0912345678',
|
||||||
|
email: 'a@test.com',
|
||||||
|
role: 'AGENT',
|
||||||
|
kycStatus: 'PENDING',
|
||||||
|
kycData: { idType: 'CCCD', idNumber: '012345678901', frontImageUrl: 'https://img.test/front.jpg' },
|
||||||
|
createdAt: '2024-06-15T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: 'u2',
|
||||||
|
fullName: 'Tran Thi B',
|
||||||
|
phone: '0987654321',
|
||||||
|
email: null,
|
||||||
|
role: 'USER',
|
||||||
|
kycStatus: 'PENDING',
|
||||||
|
kycData: null,
|
||||||
|
createdAt: '2024-06-16T10:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AdminKycPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockGetKycQueue.mockResolvedValue(mockQueueData);
|
||||||
|
mockApproveKyc.mockResolvedValue({});
|
||||||
|
mockRejectKyc.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders heading and fetches queue', async () => {
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Duyệt KYC')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(mockGetKycQueue).toHaveBeenCalledWith(1, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders queue items in table', async () => {
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Nguyen Van A')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tran Thi B')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no requests', async () => {
|
||||||
|
mockGetKycQueue.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20, totalPages: 0 });
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Không có yêu cầu KYC nào đang chờ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state when fetch fails', async () => {
|
||||||
|
mockGetKycQueue.mockRejectedValue(new Error('Network error'));
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Thử lại')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes queue on refresh button click', async () => {
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Nguyen Van A')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /làm mới/i }));
|
||||||
|
|
||||||
|
expect(mockGetKycQueue).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('next/dynamic', () => ({
|
||||||
|
default: () => {
|
||||||
|
const Mock = () => <div data-testid="chart-placeholder">Chart</div>;
|
||||||
|
Mock.displayName = 'MockChart';
|
||||||
|
return Mock;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
|
||||||
|
<a href={href} {...props}>{children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseMarketReport = vi.fn();
|
||||||
|
const mockUseHeatmap = vi.fn();
|
||||||
|
const mockUseListingsSearch = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/hooks/use-analytics', () => ({
|
||||||
|
useMarketReport: (...args: unknown[]) => mockUseMarketReport(...args),
|
||||||
|
useHeatmap: (...args: unknown[]) => mockUseHeatmap(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/hooks/use-listings', () => ({
|
||||||
|
useListingsSearch: (...args: unknown[]) => mockUseListingsSearch(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/listings/listing-status-badge', () => ({
|
||||||
|
ListingStatusBadge: ({ status }: { status: string }) => <span data-testid="status-badge">{status}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import DashboardPage from '../page';
|
||||||
|
|
||||||
|
const fullData = {
|
||||||
|
marketReport: {
|
||||||
|
districts: [
|
||||||
|
{ district: 'Quan 1', totalListings: 100, avgPriceM2: 120000000, medianPrice: '15000000000', daysOnMarket: 45, yoyChange: 5.2, inventoryLevel: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
heatmap: { dataPoints: [{ district: 'Quan 1', avgPriceM2: 120000000, totalListings: 100, lat: 10.77, lng: 106.7 }] },
|
||||||
|
listings: {
|
||||||
|
data: [{
|
||||||
|
id: '1', status: 'ACTIVE', transactionType: 'SALE', priceVND: '5000000000', viewCount: 10,
|
||||||
|
saveCount: 2, inquiryCount: 3, publishedAt: '2024-01-01', createdAt: '2024-01-01',
|
||||||
|
pricePerM2: null, rentPriceMonthly: null, commissionPct: null,
|
||||||
|
property: {
|
||||||
|
id: 'p1', propertyType: 'APARTMENT', title: 'Căn hộ Quận 7', description: 'Test',
|
||||||
|
address: '123 Nguyễn Hữu Thọ', ward: 'Tân Hưng', district: 'Quận 7',
|
||||||
|
city: 'Hồ Chí Minh', areaM2: 75, bedrooms: 2, bathrooms: 2, floors: null,
|
||||||
|
direction: null, yearBuilt: null, legalStatus: null, amenities: null, projectName: null, media: [],
|
||||||
|
},
|
||||||
|
seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' },
|
||||||
|
agent: null,
|
||||||
|
}],
|
||||||
|
total: 1, page: 1, limit: 6, totalPages: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DashboardPage — deep tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading state with placeholders', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: undefined, isLoading: true });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: undefined, isLoading: true });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: undefined, isLoading: true });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
expect(screen.getByText('Bảng điều khiển')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders stat cards with computed values', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Tin đăng của tôi')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Lượt xem')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Liên hệ')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Giá TB thị trường')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders recent listings with property title', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Tin đăng gần đây')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/căn hộ quận 7/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Đăng tin mới" link', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', { name: /đăng tin mới/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty listings state', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: { data: [], total: 0, page: 1, limit: 6, totalPages: 0 }, isLoading: false });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Bảng điều khiển')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /đăng tin mới/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
Check: (props: Record<string, unknown>) => <span data-testid="check-icon" {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFetchProfile = vi.fn();
|
||||||
|
const mockUseAuthStore = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth-store', () => ({
|
||||||
|
useAuthStore: (...args: unknown[]) => mockUseAuthStore(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-client', () => ({
|
||||||
|
apiClient: { patch: vi.fn().mockResolvedValue({}) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import KycPage from '../kyc/page';
|
||||||
|
|
||||||
|
function setupStore(overrides: Record<string, unknown> = {}) {
|
||||||
|
const store = {
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
fullName: 'Nguyen Van A',
|
||||||
|
phone: '0912345678',
|
||||||
|
kycStatus: 'NONE',
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
fetchProfile: mockFetchProfile,
|
||||||
|
};
|
||||||
|
mockUseAuthStore.mockImplementation((selector?: (s: typeof store) => unknown) => {
|
||||||
|
if (typeof selector === 'function') return selector(store);
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('KycPage — deep tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setupStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders heading and NONE status', () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByText('Xác minh danh tính (KYC)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Chưa xác minh')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders step 1 with document type selector and number input', () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/số giấy tờ/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error and stays on step 1 when doc number is empty', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/vui lòng nhập số giấy tờ/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advances from step 1 → step 2 after filling doc number', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/ảnh mặt trước/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error on step 2 when front image is missing', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
// Step 1 → 2
|
||||||
|
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('kyc-front-input')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to advance without uploading
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/vui lòng tải ảnh mặt trước/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes back from step 2 → step 1', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('kyc-back-button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-back-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders VERIFIED state without form', () => {
|
||||||
|
setupStore({ kycStatus: 'VERIFIED' });
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByText('Đã xác minh')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Danh tính đã được xác minh')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('kyc-next-button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders PENDING state without form', () => {
|
||||||
|
setupStore({ kycStatus: 'PENDING' });
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByText('Đang chờ duyệt')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Đang xem xét hồ sơ')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('kyc-next-button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders REJECTED state with form available', () => {
|
||||||
|
setupStore({ kycStatus: 'REJECTED' });
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByText('Bị từ chối')).toBeInTheDocument();
|
||||||
|
// Form should still show for resubmission
|
||||||
|
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses error when close button is clicked', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('kyc-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Đóng'));
|
||||||
|
expect(screen.queryByTestId('kyc-error')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes document type via select', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
const select = screen.getByLabelText(/loại giấy tờ/i);
|
||||||
|
await userEvent.selectOptions(select, 'PASSPORT');
|
||||||
|
expect(select).toHaveValue('PASSPORT');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mockUseTransactions = vi.fn();
|
||||||
|
|
||||||
|
const mockTransactions = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'tx-1',
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
amountVND: '499000',
|
||||||
|
provider: 'VNPAY',
|
||||||
|
providerTxId: 'TXN123456789012',
|
||||||
|
createdAt: '2024-06-15T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tx-2',
|
||||||
|
type: 'LISTING_FEE',
|
||||||
|
status: 'PENDING',
|
||||||
|
amountVND: '100000',
|
||||||
|
provider: 'MOMO',
|
||||||
|
providerTxId: null,
|
||||||
|
createdAt: '2024-06-20T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tx-3',
|
||||||
|
type: 'FEATURED_LISTING',
|
||||||
|
status: 'FAILED',
|
||||||
|
amountVND: '200000',
|
||||||
|
provider: 'ZALOPAY',
|
||||||
|
providerTxId: 'ZLP999',
|
||||||
|
createdAt: '2024-06-21T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@/lib/hooks/use-payments', () => ({
|
||||||
|
useTransactions: (...args: unknown[]) => mockUseTransactions(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/currency', () => ({
|
||||||
|
formatVND: (amount: string | number) => `${Number(amount).toLocaleString()} đ`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import PaymentsPage from '../payments/page';
|
||||||
|
|
||||||
|
describe('PaymentsPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUseTransactions.mockReturnValue({ data: mockTransactions, isLoading: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders payment page heading and description', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: 'Thanh toán' })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/lịch sử giao dịch/i).length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders summary cards with correct values', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText('Tổng giao dịch')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Đã thanh toán')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Đang chờ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders transaction table with type/provider/status labels', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
// Type labels appear in desktop table + mobile cards, so use getAllByText
|
||||||
|
expect(screen.getAllByText('Gói dịch vụ').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Phí đăng tin').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Tin nổi bật').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Thành công').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Chờ xử lý').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Thất bại').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading state', () => {
|
||||||
|
mockUseTransactions.mockReturnValue({ data: undefined, isLoading: true });
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getAllByText('...').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText('Đang tải...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state', () => {
|
||||||
|
mockUseTransactions.mockReturnValue({ data: { items: [], total: 0 }, isLoading: false });
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText('Chưa có giao dịch nào')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes status filter via select', async () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
const select = screen.getByDisplayValue('Tất cả');
|
||||||
|
await userEvent.selectOptions(select, 'COMPLETED');
|
||||||
|
|
||||||
|
expect(mockUseTransactions).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'COMPLETED' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates long providerTxId', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText('TXN123456789...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows dash for missing providerTxId', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText('—')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders pagination when more than 1 page', () => {
|
||||||
|
// 25 total with limit 20 = 2 pages
|
||||||
|
const manyItems = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
id: `tx-${i}`,
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
amountVND: '100000',
|
||||||
|
provider: 'VNPAY',
|
||||||
|
providerTxId: null,
|
||||||
|
createdAt: '2024-06-15T10:00:00.000Z',
|
||||||
|
}));
|
||||||
|
mockUseTransactions.mockReturnValue({
|
||||||
|
data: { items: manyItems, total: 25 },
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText(/trang 1\/2/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /trước/i })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: /sau/i })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
useUpdateSavedSearch,
|
useUpdateSavedSearch,
|
||||||
} from '@/lib/hooks/use-saved-searches';
|
} from '@/lib/hooks/use-saved-searches';
|
||||||
import { type SavedSearch, type SavedSearchFilters } from '@/lib/saved-search-api';
|
import { type SavedSearch, type SavedSearchFilters } from '@/lib/saved-search-api';
|
||||||
|
import { formatPrice } from '@/lib/currency';
|
||||||
|
|
||||||
const PROPERTY_TYPE_LABELS: Record<string, string> = {
|
const PROPERTY_TYPE_LABELS: Record<string, string> = {
|
||||||
APARTMENT: 'Chung cư',
|
APARTMENT: 'Chung cư',
|
||||||
@@ -25,12 +26,6 @@ const TRANSACTION_TYPE_LABELS: Record<string, string> = {
|
|||||||
RENT: 'Cho thuê',
|
RENT: 'Cho thuê',
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatPrice(value: string): string {
|
|
||||||
const num = Number(value);
|
|
||||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`;
|
|
||||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
|
|
||||||
return num.toLocaleString('vi-VN');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFilters(filters: SavedSearchFilters): string[] {
|
function formatFilters(filters: SavedSearchFilters): string[] {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|||||||
60
apps/web/app/[locale]/(public)/agents/[id]/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/agents/[id]/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function AgentProfileError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Agent profile error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải hồ sơ môi giới</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải thông tin môi giới. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/agents"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về danh sách môi giới
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/agents/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/agents/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function AgentsError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Agents page error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải thông tin môi giới</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải danh sách môi giới. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về trang chủ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/du-an/[slug]/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/du-an/[slug]/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ProjectDetailError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Project detail error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải thông tin dự án</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải chi tiết dự án. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/du-an"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về danh sách dự án
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/du-an/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/du-an/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ProjectsError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Projects (du-an) error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải danh sách dự án</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải dự án bất động sản. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về trang chủ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function PublicError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Public page error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải trang</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải nội dung. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về trang chủ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function IndustrialParkDetailError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Industrial park detail error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải chi tiết khu công nghiệp</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải thông tin khu công nghiệp. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/khu-cong-nghiep"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về danh sách khu công nghiệp
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/khu-cong-nghiep/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/khu-cong-nghiep/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function IndustrialParksError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Industrial parks error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải thông tin khu công nghiệp</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải dữ liệu khu công nghiệp. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về trang chủ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/listings/[id]/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/listings/[id]/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ListingDetailError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Listing detail error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải thông tin bất động sản</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải chi tiết bất động sản. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/listings"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về danh sách
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/listings/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/listings/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ListingsError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Listings error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải danh sách bất động sản</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải danh sách. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về trang chủ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,27 +18,12 @@ import {
|
|||||||
useTrendingAreas,
|
useTrendingAreas,
|
||||||
} from '@/lib/hooks/use-analytics';
|
} from '@/lib/hooks/use-analytics';
|
||||||
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
||||||
|
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Helpers */
|
/* Helpers */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
const vndFmt = new Intl.NumberFormat('vi-VN', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'VND',
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatVnd(value: number): string {
|
|
||||||
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)} tỷ`;
|
|
||||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(0)} tr`;
|
|
||||||
return vndFmt.format(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPriceM2(value: number): string {
|
|
||||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)} tr/m²`;
|
|
||||||
return `${Math.round(value / 1000)}k/m²`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentPeriod(): string {
|
function currentPeriod(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -138,7 +123,7 @@ function KpiStrip({ city }: { city: string }) {
|
|||||||
<section className="mb-6 grid grid-cols-2 gap-3 md:grid-cols-4">
|
<section className="mb-6 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="GGI HCM"
|
label="GGI HCM"
|
||||||
value={data ? formatPriceM2(data.avgPricePerM2) : '—'}
|
value={data ? formatPricePerM2(data.avgPricePerM2) : '—'}
|
||||||
delta={data?.priceChangePct?.d7}
|
delta={data?.priceChangePct?.d7}
|
||||||
footnote="Chỉ số giá TB/m²"
|
footnote="Chỉ số giá TB/m²"
|
||||||
icon={<BarChart3 className="h-3.5 w-3.5" />}
|
icon={<BarChart3 className="h-3.5 w-3.5" />}
|
||||||
@@ -146,7 +131,7 @@ function KpiStrip({ city }: { city: string }) {
|
|||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Giá TB"
|
label="Giá TB"
|
||||||
value={data ? formatVnd(data.avgPrice) : '—'}
|
value={data ? formatPrice(data.avgPrice) : '—'}
|
||||||
delta={data?.priceChangePct?.d30}
|
delta={data?.priceChangePct?.d30}
|
||||||
footnote="Toàn thành phố"
|
footnote="Toàn thành phố"
|
||||||
icon={<Building2 className="h-3.5 w-3.5" />}
|
icon={<Building2 className="h-3.5 w-3.5" />}
|
||||||
@@ -154,7 +139,7 @@ function KpiStrip({ city }: { city: string }) {
|
|||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Giá trung vị"
|
label="Giá trung vị"
|
||||||
value={data ? formatVnd(data.medianPrice) : '—'}
|
value={data ? formatPrice(data.medianPrice) : '—'}
|
||||||
footnote="Median price"
|
footnote="Median price"
|
||||||
icon={<Layers className="h-3.5 w-3.5" />}
|
icon={<Layers className="h-3.5 w-3.5" />}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
@@ -352,7 +337,7 @@ function RecentListings() {
|
|||||||
const price = Number(r.priceVND);
|
const price = Number(r.priceVND);
|
||||||
return (
|
return (
|
||||||
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
|
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
|
||||||
{formatVnd(price)}
|
{formatPrice(price)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -367,7 +352,7 @@ function RecentListings() {
|
|||||||
cell: (r) =>
|
cell: (r) =>
|
||||||
r.pricePerM2 ? (
|
r.pricePerM2 ? (
|
||||||
<span className="text-xs tabular-nums text-foreground-muted">
|
<span className="text-xs tabular-nums text-foreground-muted">
|
||||||
{formatPriceM2(r.pricePerM2)}
|
{formatPricePerM2(r.pricePerM2)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-foreground-dim">—</span>
|
<span className="text-foreground-dim">—</span>
|
||||||
@@ -457,7 +442,7 @@ export default function MarketDashboardPage() {
|
|||||||
{
|
{
|
||||||
id: 'price',
|
id: 'price',
|
||||||
header: 'Giá TB/m²',
|
header: 'Giá TB/m²',
|
||||||
cell: (r) => formatPriceM2(r.avgPriceM2),
|
cell: (r) => formatPricePerM2(r.avgPriceM2),
|
||||||
align: 'right' as const,
|
align: 'right' as const,
|
||||||
numeric: true,
|
numeric: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
|||||||
60
apps/web/app/[locale]/(public)/payment/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/payment/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function PaymentError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Payment page error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Lỗi thanh toán</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi trong quá trình thanh toán. Vui lòng thử lại hoặc liên hệ hỗ trợ.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về bảng điều khiển
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,8 +64,49 @@ function SearchContent() {
|
|||||||
const [saveAlertEnabled, setSaveAlertEnabled] = React.useState(true);
|
const [saveAlertEnabled, setSaveAlertEnabled] = React.useState(true);
|
||||||
const [saveSuccess, setSaveSuccess] = React.useState(false);
|
const [saveSuccess, setSaveSuccess] = React.useState(false);
|
||||||
|
|
||||||
|
const saveDialogRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const saveButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
const saveNameInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const createSavedSearch = useCreateSavedSearch();
|
const createSavedSearch = useCreateSavedSearch();
|
||||||
|
|
||||||
|
// Focus management for save-search dialog
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (showSaveDialog) {
|
||||||
|
saveNameInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [showSaveDialog]);
|
||||||
|
|
||||||
|
// Focus trap + Escape key for save-search dialog
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!showSaveDialog) return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setShowSaveDialog(false);
|
||||||
|
saveButtonRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
const dialog = saveDialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
const focusable = dialog.querySelectorAll<HTMLElement>(
|
||||||
|
'button, input, [tabindex]:not([tabindex="-1"])',
|
||||||
|
);
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last?.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [showSaveDialog]);
|
||||||
|
|
||||||
const handleMarkerClick = (listing: ListingDetail) => {
|
const handleMarkerClick = (listing: ListingDetail) => {
|
||||||
setSelectedListingId(listing.id);
|
setSelectedListingId(listing.id);
|
||||||
};
|
};
|
||||||
@@ -163,11 +204,15 @@ function SearchContent() {
|
|||||||
{activeFilterCount > 0 && (
|
{activeFilterCount > 0 && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Button
|
<Button
|
||||||
|
ref={saveButtonRef}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowSaveDialog(!showSaveDialog)}
|
onClick={() => setShowSaveDialog(!showSaveDialog)}
|
||||||
|
aria-expanded={showSaveDialog}
|
||||||
|
aria-controls="save-search-dialog"
|
||||||
|
aria-haspopup="dialog"
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||||
</svg>
|
</svg>
|
||||||
Lưu tìm kiếm
|
Lưu tìm kiếm
|
||||||
@@ -175,10 +220,17 @@ function SearchContent() {
|
|||||||
|
|
||||||
{/* Save search dialog */}
|
{/* Save search dialog */}
|
||||||
{showSaveDialog && (
|
{showSaveDialog && (
|
||||||
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg">
|
<div
|
||||||
|
id="save-search-dialog"
|
||||||
|
ref={saveDialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="save-search-heading"
|
||||||
|
className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg"
|
||||||
|
>
|
||||||
{saveSuccess ? (
|
{saveSuccess ? (
|
||||||
<div className="flex items-center gap-2 text-green-600">
|
<div className="flex items-center gap-2 text-green-600">
|
||||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-medium">Đã lưu tìm kiếm!</span>
|
<span className="text-sm font-medium">Đã lưu tìm kiếm!</span>
|
||||||
@@ -188,6 +240,7 @@ function SearchContent() {
|
|||||||
<h3 className="mb-3 font-medium" id="save-search-heading">Lưu bộ lọc tìm kiếm</h3>
|
<h3 className="mb-3 font-medium" id="save-search-heading">Lưu bộ lọc tìm kiếm</h3>
|
||||||
<label htmlFor="save-search-name" className="sr-only">Tên tìm kiếm</label>
|
<label htmlFor="save-search-name" className="sr-only">Tên tìm kiếm</label>
|
||||||
<input
|
<input
|
||||||
|
ref={saveNameInputRef}
|
||||||
id="save-search-name"
|
id="save-search-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={saveName}
|
value={saveName}
|
||||||
@@ -246,8 +299,9 @@ function SearchContent() {
|
|||||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode('list')}
|
||||||
|
aria-pressed={viewMode === 'list'}
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
</svg>
|
</svg>
|
||||||
Danh sách
|
Danh sách
|
||||||
@@ -256,8 +310,9 @@ function SearchContent() {
|
|||||||
variant={viewMode === 'map' ? 'default' : 'ghost'}
|
variant={viewMode === 'map' ? 'default' : 'ghost'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode('map')}
|
onClick={() => setViewMode('map')}
|
||||||
|
aria-pressed={viewMode === 'map'}
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||||
</svg>
|
</svg>
|
||||||
Bản đồ
|
Bản đồ
|
||||||
@@ -267,8 +322,9 @@ function SearchContent() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="hidden lg:flex"
|
className="hidden lg:flex"
|
||||||
onClick={() => setViewMode('split')}
|
onClick={() => setViewMode('split')}
|
||||||
|
aria-pressed={viewMode === 'split'}
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
|
||||||
</svg>
|
</svg>
|
||||||
Chia đôi
|
Chia đôi
|
||||||
@@ -280,8 +336,10 @@ function SearchContent() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="lg:hidden"
|
className="lg:hidden"
|
||||||
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
||||||
|
aria-expanded={showMobileFilters}
|
||||||
|
aria-controls="mobile-filter-panel"
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||||
</svg>
|
</svg>
|
||||||
Bộ lọc
|
Bộ lọc
|
||||||
@@ -305,7 +363,7 @@ function SearchContent() {
|
|||||||
|
|
||||||
{/* Mobile filter panel */}
|
{/* Mobile filter panel */}
|
||||||
{showMobileFilters && (
|
{showMobileFilters && (
|
||||||
<div className="mb-4 rounded-lg border bg-card p-4 lg:hidden">
|
<div id="mobile-filter-panel" className="mb-4 rounded-lg border bg-card p-4 lg:hidden">
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={handleFilterChange}
|
onChange={handleFilterChange}
|
||||||
@@ -392,7 +450,11 @@ export default function SearchPage() {
|
|||||||
return (
|
return (
|
||||||
<React.Suspense
|
<React.Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex min-h-[400px] items-center justify-center">
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label="Đang tải..."
|
||||||
|
className="flex min-h-[400px] items-center justify-center"
|
||||||
|
>
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
95
apps/web/app/[locale]/__tests__/error.spec.tsx
Normal file
95
apps/web/app/[locale]/__tests__/error.spec.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
/**
|
||||||
|
* Tests for the locale-aware error boundary page.
|
||||||
|
* Located at app/[locale]/error.tsx — renders role="alert", retry button, go-home link.
|
||||||
|
*/
|
||||||
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock Sentry before any imports that trigger it
|
||||||
|
vi.mock('@sentry/nextjs', () => ({
|
||||||
|
captureException: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock next-intl with Vietnamese messages
|
||||||
|
const viMessages = await import('@/messages/vi.json');
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: (namespace?: string) => {
|
||||||
|
const messages = viMessages.default ?? viMessages;
|
||||||
|
const ns = namespace
|
||||||
|
? (messages[namespace as keyof typeof messages] as Record<string, unknown> | undefined)
|
||||||
|
: (messages as unknown as Record<string, unknown>);
|
||||||
|
return (key: string, params?: Record<string, unknown>) => {
|
||||||
|
if (!ns) return key;
|
||||||
|
const parts = key.split('.');
|
||||||
|
let val: unknown = ns;
|
||||||
|
for (const p of parts) {
|
||||||
|
val = (val as Record<string, unknown>)?.[p];
|
||||||
|
}
|
||||||
|
if (typeof val === 'string' && params) {
|
||||||
|
return val.replace(/\{(\w+)\}/g, (_, k: string) => String(params[k] ?? `{${k}}`));
|
||||||
|
}
|
||||||
|
return typeof val === 'string' ? val : key;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
useLocale: () => 'vi',
|
||||||
|
}));
|
||||||
|
|
||||||
|
import GlobalError from '../error';
|
||||||
|
|
||||||
|
const mockError = new Error('Test error') as Error & { digest?: string };
|
||||||
|
|
||||||
|
describe('GlobalError (locale) page', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with role="alert"', () => {
|
||||||
|
const reset = vi.fn();
|
||||||
|
render(<GlobalError error={mockError} reset={reset} />);
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a retry button', () => {
|
||||||
|
const reset = vi.fn();
|
||||||
|
render(<GlobalError error={mockError} reset={reset} />);
|
||||||
|
act(() => { vi.advanceTimersByTime(5000); });
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
expect(buttons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a go-home link', () => {
|
||||||
|
const reset = vi.fn();
|
||||||
|
render(<GlobalError error={mockError} reset={reset} />);
|
||||||
|
const links = screen.getAllByRole('link');
|
||||||
|
const homeLink = links.find((a) => a.getAttribute('href') === '/');
|
||||||
|
expect(homeLink).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls reset when auto-retry fires after 3 seconds', () => {
|
||||||
|
const reset = vi.fn();
|
||||||
|
render(<GlobalError error={mockError} reset={reset} />);
|
||||||
|
act(() => { vi.advanceTimersByTime(3500); });
|
||||||
|
expect(reset).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error digest code when provided', () => {
|
||||||
|
const reset = vi.fn();
|
||||||
|
const errorWithDigest = Object.assign(new Error('Test'), { digest: 'abc-123' }) as Error & { digest?: string };
|
||||||
|
render(<GlobalError error={errorWithDigest} reset={reset} />);
|
||||||
|
expect(screen.getByText(/abc-123/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls Sentry.captureException with the error', async () => {
|
||||||
|
const { captureException } = await import('@sentry/nextjs');
|
||||||
|
const reset = vi.fn();
|
||||||
|
render(<GlobalError error={mockError} reset={reset} />);
|
||||||
|
expect(captureException).toHaveBeenCalledWith(mockError);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
apps/web/app/[locale]/__tests__/not-found.spec.tsx
Normal file
66
apps/web/app/[locale]/__tests__/not-found.spec.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
/**
|
||||||
|
* Tests for the locale-aware 404 Not Found page.
|
||||||
|
* Located at app/[locale]/not-found.tsx — uses next-intl and @/i18n/navigation.
|
||||||
|
*/
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock next-intl with Vietnamese messages
|
||||||
|
const viMessages = await import('@/messages/vi.json');
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: (namespace?: string) => {
|
||||||
|
const messages = viMessages.default ?? viMessages;
|
||||||
|
const ns = namespace
|
||||||
|
? (messages[namespace as keyof typeof messages] as Record<string, unknown> | undefined)
|
||||||
|
: (messages as unknown as Record<string, unknown>);
|
||||||
|
return (key: string) => {
|
||||||
|
if (!ns) return key;
|
||||||
|
const parts = key.split('.');
|
||||||
|
let val: unknown = ns;
|
||||||
|
for (const p of parts) {
|
||||||
|
val = (val as Record<string, unknown>)?.[p];
|
||||||
|
}
|
||||||
|
return typeof val === 'string' ? val : key;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
useLocale: () => 'vi',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
|
||||||
|
<a href={href} className={className}>{children}</a>
|
||||||
|
),
|
||||||
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
|
usePathname: () => '/not-found',
|
||||||
|
}));
|
||||||
|
|
||||||
|
import NotFound from '../not-found';
|
||||||
|
|
||||||
|
describe('NotFound (locale) page', () => {
|
||||||
|
it('renders the 404 numeric display', () => {
|
||||||
|
render(<NotFound />);
|
||||||
|
expect(screen.getByText('404')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a home link', () => {
|
||||||
|
render(<NotFound />);
|
||||||
|
const homeLinks = screen.getAllByRole('link');
|
||||||
|
const hasHomeLink = homeLinks.some((a) => a.getAttribute('href') === '/');
|
||||||
|
expect(hasHomeLink).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a search link', () => {
|
||||||
|
render(<NotFound />);
|
||||||
|
const links = screen.getAllByRole('link');
|
||||||
|
const hasSearchLink = links.some((a) => a.getAttribute('href') === '/search');
|
||||||
|
expect(hasSearchLink).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the page title text', () => {
|
||||||
|
render(<NotFound />);
|
||||||
|
const headings = screen.getAllByRole('heading');
|
||||||
|
expect(headings.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
apps/web/app/[locale]/auth/callback/error.tsx
Normal file
58
apps/web/app/[locale]/auth/callback/error.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function AuthCallbackError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Auth callback error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Lỗi đăng nhập</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Không thể hoàn tất quá trình đăng nhập. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về trang đăng nhập
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
apps/web/app/global-error.tsx
Normal file
126
apps/web/app/global-error.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Global error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="vi">
|
||||||
|
<body>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
minHeight: '100vh',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '1rem',
|
||||||
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: '28rem', textAlign: 'center' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: '0 auto',
|
||||||
|
display: 'flex',
|
||||||
|
height: '3.5rem',
|
||||||
|
width: '3.5rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '9999px',
|
||||||
|
backgroundColor: '#fee2e2',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style={{ height: '1.75rem', width: '1.75rem', color: '#ef4444' }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#111827',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Đã xảy ra lỗi nghiêm trọng
|
||||||
|
</h1>
|
||||||
|
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#6b7280' }}>
|
||||||
|
Ứng dụng gặp sự cố không mong muốn. Vui lòng tải lại trang.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p style={{ marginTop: '0.25rem', fontSize: '0.75rem', color: '#9ca3af' }}>
|
||||||
|
Mã lỗi: {error.digest}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
height: '2.25rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
backgroundColor: '#2563eb',
|
||||||
|
padding: '0 1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
height: '2.25rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
padding: '0 1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#374151',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Về trang chủ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { PriceAreaChart } from '../price-area-chart';
|
||||||
|
|
||||||
|
vi.mock('recharts', () => ({
|
||||||
|
ResponsiveContainer: ({ children }: { children: ReactNode }) => (
|
||||||
|
<div data-testid="responsive-container">{children}</div>
|
||||||
|
),
|
||||||
|
AreaChart: ({
|
||||||
|
children,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
data: unknown[];
|
||||||
|
}) => (
|
||||||
|
<div data-testid="area-chart" data-count={data.length}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
Area: ({ stroke, dataKey }: { stroke: string; dataKey: string }) => (
|
||||||
|
<div data-testid={`area-${dataKey}`} data-stroke={stroke} />
|
||||||
|
),
|
||||||
|
XAxis: ({ dataKey }: { dataKey: string }) => (
|
||||||
|
<div data-testid={`xaxis-${dataKey}`} />
|
||||||
|
),
|
||||||
|
YAxis: () => <div data-testid="yaxis" />,
|
||||||
|
CartesianGrid: () => <div data-testid="grid" />,
|
||||||
|
Tooltip: () => <div data-testid="tooltip" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('PriceAreaChart', () => {
|
||||||
|
it('renders responsive container with chart, axes, grid and tooltip', () => {
|
||||||
|
render(
|
||||||
|
<PriceAreaChart
|
||||||
|
data={[
|
||||||
|
{ period: 'D1', avgPriceM2: 60_000_000 },
|
||||||
|
{ period: 'D2', avgPriceM2: 62_000_000 },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('area-chart')).toHaveAttribute('data-count', '2');
|
||||||
|
expect(screen.getByTestId('xaxis-period')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('yaxis')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('grid')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('tooltip')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses signal-up stroke color when last point >= first', () => {
|
||||||
|
render(
|
||||||
|
<PriceAreaChart
|
||||||
|
data={[
|
||||||
|
{ period: 'D1', avgPriceM2: 60_000_000 },
|
||||||
|
{ period: 'D2', avgPriceM2: 65_000_000 },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute(
|
||||||
|
'data-stroke',
|
||||||
|
'var(--color-signal-up)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses signal-down stroke color when last point < first', () => {
|
||||||
|
render(
|
||||||
|
<PriceAreaChart
|
||||||
|
data={[
|
||||||
|
{ period: 'D1', avgPriceM2: 70_000_000 },
|
||||||
|
{ period: 'D2', avgPriceM2: 60_000_000 },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute(
|
||||||
|
'data-stroke',
|
||||||
|
'var(--color-signal-down)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to signal-down stroke for single or empty data', () => {
|
||||||
|
render(<PriceAreaChart data={[]} />);
|
||||||
|
expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute(
|
||||||
|
'data-stroke',
|
||||||
|
'var(--color-signal-down)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through className to wrapper div', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PriceAreaChart
|
||||||
|
data={[{ period: 'D1', avgPriceM2: 1 }]}
|
||||||
|
className="custom-wrap"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.custom-wrap')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { TransferItemTable } from '../transfer-item-table';
|
||||||
|
|
||||||
|
const baseItem = {
|
||||||
|
id: 'i1',
|
||||||
|
name: 'Tủ lạnh Toshiba',
|
||||||
|
brand: 'Toshiba',
|
||||||
|
modelName: 'GR-RT624WE-PMV',
|
||||||
|
category: 'APPLIANCE' as const,
|
||||||
|
condition: 'GOOD' as const,
|
||||||
|
purchaseYear: 2022,
|
||||||
|
originalPriceVND: '15000000',
|
||||||
|
askingPriceVND: '8000000',
|
||||||
|
aiEstimatePriceVND: '7500000',
|
||||||
|
aiConfidence: 0.85,
|
||||||
|
quantity: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TransferItemTable', () => {
|
||||||
|
it('renders empty state when no items', () => {
|
||||||
|
render(<TransferItemTable items={[]} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText('Chưa có danh sách vật phẩm.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all column headers', () => {
|
||||||
|
render(<TransferItemTable items={[baseItem]} />);
|
||||||
|
expect(screen.getByText('Tên')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Loại')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tình trạng')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Thương hiệu')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('SL')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Giá yêu cầu')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Giá AI')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders item row with localized currency formatting', () => {
|
||||||
|
render(<TransferItemTable items={[baseItem]} />);
|
||||||
|
expect(screen.getByText('Tủ lạnh Toshiba')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('GR-RT624WE-PMV')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/8\.000\.000/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/7\.500\.000/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to em-dash for missing brand and AI estimate', () => {
|
||||||
|
render(
|
||||||
|
<TransferItemTable
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
...baseItem,
|
||||||
|
id: 'i2',
|
||||||
|
brand: null,
|
||||||
|
aiEstimatePriceVND: null,
|
||||||
|
aiConfidence: null,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const dashes = screen.getAllByText('—');
|
||||||
|
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { TransferListingCard } from '../transfer-listing-card';
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
...rest
|
||||||
|
}: React.PropsWithChildren<{ href: string } & Record<string, unknown>>) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseListing = {
|
||||||
|
id: 'tl1',
|
||||||
|
sellerId: 's1',
|
||||||
|
category: 'FURNITURE' as const,
|
||||||
|
status: 'ACTIVE' as const,
|
||||||
|
title: 'Bộ sofa gỗ còn mới 90%',
|
||||||
|
address: '123 Lê Lợi',
|
||||||
|
district: 'Quận 1',
|
||||||
|
city: 'TP.HCM',
|
||||||
|
latitude: 10.77,
|
||||||
|
longitude: 106.7,
|
||||||
|
askingPriceVND: '4500000',
|
||||||
|
aiEstimatePriceVND: null,
|
||||||
|
pricingSource: 'MANUAL' as const,
|
||||||
|
isNegotiable: true,
|
||||||
|
areaM2: 12,
|
||||||
|
itemCount: 5,
|
||||||
|
viewCount: 42,
|
||||||
|
publishedAt: '2026-04-10T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TransferListingCard', () => {
|
||||||
|
it('renders title, location and formatted price', () => {
|
||||||
|
render(<TransferListingCard listing={baseListing} />);
|
||||||
|
expect(screen.getByText('Bộ sofa gỗ còn mới 90%')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Quận 1, TP\.HCM/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/4\.500\.000/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ACTIVE status with green color and "Thương lượng" when negotiable', () => {
|
||||||
|
render(<TransferListingCard listing={baseListing} />);
|
||||||
|
expect(screen.getByText('Đang đăng')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Thương lượng')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders item and view counts and square-meter area', () => {
|
||||||
|
render(<TransferListingCard listing={baseListing} />);
|
||||||
|
expect(screen.getByText('5')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('42')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/12 m/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to listing detail by id', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TransferListingCard listing={baseListing} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('a')?.getAttribute('href')).toBe(
|
||||||
|
'/chuyen-nhuong/tl1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits publish date footer when publishedAt is null', () => {
|
||||||
|
render(
|
||||||
|
<TransferListingCard
|
||||||
|
listing={{ ...baseListing, publishedAt: null }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/Đăng/)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,14 +19,12 @@ import {
|
|||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
} from '@/lib/chuyen-nhuong-api';
|
} from '@/lib/chuyen-nhuong-api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { formatVNDFull } from '@/lib/currency';
|
||||||
|
|
||||||
interface ChuyenNhuongDetailClientProps {
|
interface ChuyenNhuongDetailClientProps {
|
||||||
listing: TransferListingDetail;
|
listing: TransferListingDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatVND(value: string): string {
|
|
||||||
return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientProps) {
|
export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientProps) {
|
||||||
const statusColor =
|
const statusColor =
|
||||||
@@ -69,13 +67,13 @@ export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientPr
|
|||||||
<div className="my-6 grid grid-cols-2 gap-4 rounded-lg border bg-card p-4 sm:grid-cols-4 lg:grid-cols-6">
|
<div className="my-6 grid grid-cols-2 gap-4 rounded-lg border bg-card p-4 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
<QuickStat
|
<QuickStat
|
||||||
label="Giá yêu cầu"
|
label="Giá yêu cầu"
|
||||||
value={formatVND(listing.askingPriceVND)}
|
value={formatVNDFull(listing.askingPriceVND)}
|
||||||
valueClassName="text-primary"
|
valueClassName="text-primary"
|
||||||
/>
|
/>
|
||||||
{listing.aiEstimatePriceVND && (
|
{listing.aiEstimatePriceVND && (
|
||||||
<QuickStat
|
<QuickStat
|
||||||
label="Giá AI ước tính"
|
label="Giá AI ước tính"
|
||||||
value={formatVND(listing.aiEstimatePriceVND)}
|
value={formatVNDFull(listing.aiEstimatePriceVND)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{listing.areaM2 && (
|
{listing.areaM2 && (
|
||||||
@@ -144,7 +142,7 @@ export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientPr
|
|||||||
{listing.monthlyRentVND && (
|
{listing.monthlyRentVND && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Tiền thuê hàng tháng</span>
|
<span className="text-sm text-muted-foreground">Tiền thuê hàng tháng</span>
|
||||||
<span className="font-medium">{formatVND(listing.monthlyRentVND)}</span>
|
<span className="font-medium">{formatVNDFull(listing.monthlyRentVND)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{listing.depositMonths != null && (
|
{listing.depositMonths != null && (
|
||||||
@@ -181,14 +179,14 @@ export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientPr
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Giá yêu cầu</span>
|
<span className="text-sm text-muted-foreground">Giá yêu cầu</span>
|
||||||
<span className="text-lg font-bold text-primary">
|
<span className="text-lg font-bold text-primary">
|
||||||
{formatVND(listing.askingPriceVND)}
|
{formatVNDFull(listing.askingPriceVND)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{listing.aiEstimatePriceVND && (
|
{listing.aiEstimatePriceVND && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Giá AI ước tính</span>
|
<span className="text-sm text-muted-foreground">Giá AI ước tính</span>
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{formatVND(listing.aiEstimatePriceVND)}
|
{formatVNDFull(listing.aiEstimatePriceVND)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ import {
|
|||||||
CONDITION_COLORS,
|
CONDITION_COLORS,
|
||||||
CONDITION_LABELS,
|
CONDITION_LABELS,
|
||||||
} from '@/lib/chuyen-nhuong-api';
|
} from '@/lib/chuyen-nhuong-api';
|
||||||
|
import { formatVNDFull } from '@/lib/currency';
|
||||||
|
|
||||||
interface TransferItemTableProps {
|
interface TransferItemTableProps {
|
||||||
items: TransferItemData[];
|
items: TransferItemData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatVND(value: string): string {
|
|
||||||
return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TransferItemTable({ items }: TransferItemTableProps) {
|
export function TransferItemTable({ items }: TransferItemTableProps) {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
@@ -59,12 +57,12 @@ export function TransferItemTable({ items }: TransferItemTableProps) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">{item.quantity}</td>
|
<td className="px-3 py-2 text-right">{item.quantity}</td>
|
||||||
<td className="px-3 py-2 text-right font-medium">
|
<td className="px-3 py-2 text-right font-medium">
|
||||||
{formatVND(item.askingPriceVND)}
|
{formatVNDFull(item.askingPriceVND)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right text-muted-foreground">
|
<td className="px-3 py-2 text-right text-muted-foreground">
|
||||||
{item.aiEstimatePriceVND ? (
|
{item.aiEstimatePriceVND ? (
|
||||||
<span title={item.aiConfidence ? `Độ tin cậy: ${Math.round(item.aiConfidence * 100)}%` : undefined}>
|
<span title={item.aiConfidence ? `Độ tin cậy: ${Math.round(item.aiConfidence * 100)}%` : undefined}>
|
||||||
{formatVND(item.aiEstimatePriceVND)}
|
{formatVNDFull(item.aiEstimatePriceVND)}
|
||||||
</span>
|
</span>
|
||||||
) : '—'}
|
) : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -10,14 +10,12 @@ import {
|
|||||||
CATEGORY_LABELS,
|
CATEGORY_LABELS,
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
} from '@/lib/chuyen-nhuong-api';
|
} from '@/lib/chuyen-nhuong-api';
|
||||||
|
import { formatVNDFull } from '@/lib/currency';
|
||||||
|
|
||||||
interface TransferListingCardProps {
|
interface TransferListingCardProps {
|
||||||
listing: TransferListingListItem;
|
listing: TransferListingListItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatVND(value: string): string {
|
|
||||||
return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TransferListingCard({ listing }: TransferListingCardProps) {
|
export function TransferListingCard({ listing }: TransferListingCardProps) {
|
||||||
const statusColor =
|
const statusColor =
|
||||||
@@ -62,7 +60,7 @@ export function TransferListingCard({ listing }: TransferListingCardProps) {
|
|||||||
{/* Price */}
|
{/* Price */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<p className="text-lg font-bold text-primary">
|
<p className="text-lg font-bold text-primary">
|
||||||
{formatVND(listing.askingPriceVND)}
|
{formatVNDFull(listing.askingPriceVND)}
|
||||||
</p>
|
</p>
|
||||||
{listing.isNegotiable && (
|
{listing.isNegotiable && (
|
||||||
<span className="text-xs text-muted-foreground">Thương lượng</span>
|
<span className="text-xs text-muted-foreground">Thương lượng</span>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { act, render, renderHook, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
DENSITY_CELL_PADDING,
|
||||||
|
DENSITY_DATA_FONT,
|
||||||
|
DENSITY_ROW_HEIGHT,
|
||||||
|
DensityProvider,
|
||||||
|
useDensity,
|
||||||
|
} from '../density-provider';
|
||||||
|
|
||||||
|
// jsdom (opaque origin) does not provide a usable localStorage; install a tiny in-memory shim.
|
||||||
|
function installLocalStorage(): Storage {
|
||||||
|
const store: Record<string, string> = {};
|
||||||
|
const fake: Storage = {
|
||||||
|
get length() {
|
||||||
|
return Object.keys(store).length;
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
for (const k of Object.keys(store)) delete store[k];
|
||||||
|
},
|
||||||
|
getItem: (k) => (k in store ? store[k]! : null),
|
||||||
|
key: (i) => Object.keys(store)[i] ?? null,
|
||||||
|
removeItem: (k) => {
|
||||||
|
delete store[k];
|
||||||
|
},
|
||||||
|
setItem: (k, v) => {
|
||||||
|
store[k] = String(v);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: fake,
|
||||||
|
});
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DensityProvider', () => {
|
||||||
|
let storage: Storage;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = installLocalStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (typeof storage.clear === 'function') {
|
||||||
|
storage.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes default density "regular" via useDensity', () => {
|
||||||
|
const { result } = renderHook(() => useDensity(), {
|
||||||
|
wrapper: ({ children }) => <DensityProvider>{children}</DensityProvider>,
|
||||||
|
});
|
||||||
|
expect(result.current.density).toBe('regular');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors the defaultDensity prop', () => {
|
||||||
|
const { result } = renderHook(() => useDensity(), {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<DensityProvider defaultDensity="compact">{children}</DensityProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(result.current.density).toBe('compact');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists density changes to localStorage', () => {
|
||||||
|
const { result } = renderHook(() => useDensity(), {
|
||||||
|
wrapper: ({ children }) => <DensityProvider>{children}</DensityProvider>,
|
||||||
|
});
|
||||||
|
act(() => result.current.setDensity('roomy'));
|
||||||
|
expect(result.current.density).toBe('roomy');
|
||||||
|
expect(localStorage.getItem('goodgo.density')).toBe('roomy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads stored density on mount when valid', () => {
|
||||||
|
localStorage.setItem('goodgo.density', 'compact');
|
||||||
|
function Probe() {
|
||||||
|
const { density } = useDensity();
|
||||||
|
return <span data-testid="d">{density}</span>;
|
||||||
|
}
|
||||||
|
render(
|
||||||
|
<DensityProvider>
|
||||||
|
<Probe />
|
||||||
|
</DensityProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('d').textContent).toBe('compact');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes row-height, padding and font tables for all densities', () => {
|
||||||
|
for (const mode of ['compact', 'regular', 'roomy'] as const) {
|
||||||
|
expect(DENSITY_ROW_HEIGHT[mode]).toBeTruthy();
|
||||||
|
expect(DENSITY_CELL_PADDING[mode]).toBeTruthy();
|
||||||
|
expect(DENSITY_DATA_FONT[mode]).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
190
apps/web/components/design-system/__tests__/navbar.spec.tsx
Normal file
190
apps/web/components/design-system/__tests__/navbar.spec.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock lucide-react icons to avoid SVG rendering issues
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
ChevronDown: () => <span data-testid="icon-chevron-down" />,
|
||||||
|
LayoutDashboard: () => <span data-testid="icon-layout-dashboard" />,
|
||||||
|
LogOut: () => <span data-testid="icon-logout" />,
|
||||||
|
Menu: () => <span data-testid="icon-menu" />,
|
||||||
|
Moon: () => <span data-testid="icon-moon" />,
|
||||||
|
Shield: () => <span data-testid="icon-shield" />,
|
||||||
|
Sun: () => <span data-testid="icon-sun" />,
|
||||||
|
User: () => <span data-testid="icon-user" />,
|
||||||
|
X: () => <span data-testid="icon-x" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Navbar, type NavbarProps } from '../navbar';
|
||||||
|
|
||||||
|
const renderLink: NavbarProps['renderLink'] = ({ href, children, className, onClick }) => (
|
||||||
|
<a href={href} className={className} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseLabels: NavbarProps['labels'] = {
|
||||||
|
login: 'Đăng nhập',
|
||||||
|
register: 'Đăng ký',
|
||||||
|
dashboard: 'Quản lý',
|
||||||
|
admin: 'Quản trị',
|
||||||
|
profile: 'Hồ sơ',
|
||||||
|
logout: 'Đăng xuất',
|
||||||
|
openMenu: 'Mở menu',
|
||||||
|
closeMenu: 'Đóng menu',
|
||||||
|
darkMode: 'Chế độ tối',
|
||||||
|
lightMode: 'Chế độ sáng',
|
||||||
|
mainNav: 'Điều hướng chính',
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseLinks: NavbarProps['links'] = [
|
||||||
|
{ href: '/', label: 'Trang chủ', isActive: true },
|
||||||
|
{ href: '/search', label: 'Tìm kiếm', isActive: false },
|
||||||
|
{ href: '/pricing', label: 'Bảng giá', isActive: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultProps: NavbarProps = {
|
||||||
|
brand: 'GoodGo',
|
||||||
|
links: baseLinks,
|
||||||
|
user: null,
|
||||||
|
dashboardHref: '/dashboard',
|
||||||
|
theme: 'light',
|
||||||
|
onToggleTheme: vi.fn(),
|
||||||
|
onLogout: vi.fn(),
|
||||||
|
labels: baseLabels,
|
||||||
|
renderLink,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Navbar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the brand name', () => {
|
||||||
|
render(<Navbar {...defaultProps} />);
|
||||||
|
expect(screen.getByText('GoodGo')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders as a banner landmark', () => {
|
||||||
|
render(<Navbar {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('banner')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders desktop nav links', () => {
|
||||||
|
render(<Navbar {...defaultProps} />);
|
||||||
|
expect(screen.getAllByText('Trang chủ').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText('Tìm kiếm').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText('Bảng giá').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders login and register buttons when unauthenticated', () => {
|
||||||
|
render(<Navbar {...defaultProps} />);
|
||||||
|
expect(screen.getAllByText('Đăng nhập').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText('Đăng ký').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render dashboard button when unauthenticated', () => {
|
||||||
|
render(<Navbar {...defaultProps} />);
|
||||||
|
expect(screen.queryByText('Quản lý')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders user full name when authenticated', () => {
|
||||||
|
render(
|
||||||
|
<Navbar
|
||||||
|
{...defaultProps}
|
||||||
|
user={{ fullName: 'Nguyễn Văn A', role: 'BUYER', email: 'a@test.com' }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText('Nguyễn Văn A').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders dashboard button for authenticated user', () => {
|
||||||
|
render(
|
||||||
|
<Navbar
|
||||||
|
{...defaultProps}
|
||||||
|
user={{ fullName: 'Nguyễn Văn A', role: 'BUYER' }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Quản lý')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders admin label for ADMIN role', () => {
|
||||||
|
render(
|
||||||
|
<Navbar
|
||||||
|
{...defaultProps}
|
||||||
|
user={{ fullName: 'Admin User', role: 'ADMIN' }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Quản trị')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows moon icon in light theme', () => {
|
||||||
|
render(<Navbar {...defaultProps} theme="light" />);
|
||||||
|
expect(screen.getByTestId('icon-moon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows sun icon in dark theme', () => {
|
||||||
|
render(<Navbar {...defaultProps} theme="dark" />);
|
||||||
|
expect(screen.getByTestId('icon-sun')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onToggleTheme when theme button is clicked', () => {
|
||||||
|
const onToggleTheme = vi.fn();
|
||||||
|
render(<Navbar {...defaultProps} onToggleTheme={onToggleTheme} />);
|
||||||
|
const themeBtn = screen.getByRole('button', { name: 'Chế độ tối' });
|
||||||
|
fireEvent.click(themeBtn);
|
||||||
|
expect(onToggleTheme).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles mobile menu on hamburger click', () => {
|
||||||
|
render(<Navbar {...defaultProps} />);
|
||||||
|
const hamburger = screen.getByRole('button', { name: 'Mở menu' });
|
||||||
|
fireEvent.click(hamburger);
|
||||||
|
expect(screen.getByRole('button', { name: 'Đóng menu' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders main nav accessible label', () => {
|
||||||
|
render(<Navbar {...defaultProps} />);
|
||||||
|
const navEls = screen.getAllByRole('navigation');
|
||||||
|
const mainNavs = navEls.filter((el) => el.getAttribute('aria-label') === 'Điều hướng chính');
|
||||||
|
expect(mainNavs.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders notification slot when provided', () => {
|
||||||
|
render(
|
||||||
|
<Navbar
|
||||||
|
{...defaultProps}
|
||||||
|
user={{ fullName: 'User', role: 'BUYER' }}
|
||||||
|
notifications={<button aria-label="Thông báo">🔔</button>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', { name: 'Thông báo' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders language switcher slot when provided', () => {
|
||||||
|
render(
|
||||||
|
<Navbar
|
||||||
|
{...defaultProps}
|
||||||
|
languageSwitcher={<div data-testid="lang-sw">VI</div>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('lang-sw')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onLogout and closes mobile menu when logout clicked', async () => {
|
||||||
|
const onLogout = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(
|
||||||
|
<Navbar
|
||||||
|
{...defaultProps}
|
||||||
|
user={{ fullName: 'User', role: 'BUYER' }}
|
||||||
|
onLogout={onLogout}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Open mobile menu
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Mở menu' }));
|
||||||
|
const logoutBtn = screen.getByRole('button', { name: 'Đăng xuất' });
|
||||||
|
fireEvent.click(logoutBtn);
|
||||||
|
expect(onLogout).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { TickerStrip, type TickerItem } from '../ticker-strip';
|
||||||
|
|
||||||
|
const items: TickerItem[] = [
|
||||||
|
{ id: 'q1', label: 'Quận 1', changePercent: 2.5 },
|
||||||
|
{ id: 'q2', label: 'Quận 7', changePercent: -1.2 },
|
||||||
|
{ id: 'q3', label: 'Thủ Đức', changePercent: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('TickerStrip', () => {
|
||||||
|
it('renders each item label twice (duplicated for seamless loop)', () => {
|
||||||
|
render(<TickerStrip items={items} paused />);
|
||||||
|
expect(screen.getAllByText('Quận 1')).toHaveLength(2);
|
||||||
|
expect(screen.getAllByText('Quận 7')).toHaveLength(2);
|
||||||
|
expect(screen.getAllByText('Thủ Đức')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies animate-ticker class when not paused', () => {
|
||||||
|
const { container } = render(<TickerStrip items={items} />);
|
||||||
|
const inner = container.querySelector('.animate-ticker');
|
||||||
|
expect(inner).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits animate-ticker class when paused', () => {
|
||||||
|
const { container } = render(<TickerStrip items={items} paused />);
|
||||||
|
expect(container.querySelector('.animate-ticker')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through className to root', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TickerStrip items={items} paused className="custom-strip" />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.custom-strip')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with empty items without crashing', () => {
|
||||||
|
const { container } = render(<TickerStrip items={[]} paused />);
|
||||||
|
expect(container.querySelector('.overflow-hidden')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
78
apps/web/components/du-an/__tests__/project-card.spec.tsx
Normal file
78
apps/web/components/du-an/__tests__/project-card.spec.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ProjectCard } from '../project-card';
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
...rest
|
||||||
|
}: React.PropsWithChildren<{ href: string } & Record<string, unknown>>) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
default: ({ alt, src }: { alt: string; src: string }) => (
|
||||||
|
<img alt={alt} src={src} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseProject = {
|
||||||
|
id: 'p1',
|
||||||
|
slug: 'vinhomes-central-park',
|
||||||
|
name: 'Vinhomes Central Park',
|
||||||
|
status: 'UNDER_CONSTRUCTION' as const,
|
||||||
|
developer: { id: 'd1', name: 'Vingroup' },
|
||||||
|
city: 'TP.HCM',
|
||||||
|
district: 'Bình Thạnh',
|
||||||
|
address: '208 Nguyễn Hữu Cảnh',
|
||||||
|
latitude: 10.79,
|
||||||
|
longitude: 106.72,
|
||||||
|
thumbnailUrl: 'https://example.com/t.jpg',
|
||||||
|
totalArea: 43,
|
||||||
|
totalUnits: 10000,
|
||||||
|
propertyTypes: ['APARTMENT', 'VILLA'] as ('APARTMENT' | 'VILLA')[],
|
||||||
|
minPrice: '3500000000',
|
||||||
|
maxPrice: '20000000000',
|
||||||
|
completionDate: null,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ProjectCard', () => {
|
||||||
|
it('renders name, location, developer and status label', () => {
|
||||||
|
render(<ProjectCard project={baseProject} />);
|
||||||
|
expect(screen.getByText('Vinhomes Central Park')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Bình Thạnh, TP\.HCM/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Vingroup')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Đang xây dựng')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to project detail by slug', () => {
|
||||||
|
const { container } = render(<ProjectCard project={baseProject} />);
|
||||||
|
expect(container.querySelector('a')?.getAttribute('href')).toBe(
|
||||||
|
'/du-an/vinhomes-central-park',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders thumbnail image when thumbnailUrl present', () => {
|
||||||
|
render(<ProjectCard project={baseProject} />);
|
||||||
|
const img = screen.getByAltText('Vinhomes Central Park') as HTMLImageElement;
|
||||||
|
expect(img.src).toContain('t.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Liên hệ" when minPrice is null', () => {
|
||||||
|
render(
|
||||||
|
<ProjectCard project={{ ...baseProject, minPrice: null, maxPrice: null }} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Liên hệ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders unit count with "căn" suffix', () => {
|
||||||
|
render(<ProjectCard project={baseProject} />);
|
||||||
|
expect(screen.getByText('10000 căn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ProjectFilterBar } from '../project-filter-bar';
|
||||||
|
|
||||||
|
describe('ProjectFilterBar', () => {
|
||||||
|
it('submits search with trimmed q and resets page to 1', () => {
|
||||||
|
const onFilterChange = vi.fn();
|
||||||
|
render(<ProjectFilterBar filters={{}} onFilterChange={onFilterChange} />);
|
||||||
|
const input = screen.getByPlaceholderText('Tìm dự án theo tên, khu vực...');
|
||||||
|
fireEvent.change(input, { target: { value: ' Vinhomes ' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Tìm' }));
|
||||||
|
expect(onFilterChange).toHaveBeenCalledWith({
|
||||||
|
q: 'Vinhomes',
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts billion-VND price input to raw VND', () => {
|
||||||
|
const onFilterChange = vi.fn();
|
||||||
|
render(<ProjectFilterBar filters={{}} onFilterChange={onFilterChange} />);
|
||||||
|
fireEvent.change(screen.getByLabelText('Giá tối thiểu'), {
|
||||||
|
target: { value: '2.5' },
|
||||||
|
});
|
||||||
|
expect(onFilterChange).toHaveBeenCalledWith({
|
||||||
|
minPrice: '2500000000',
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates sort select', () => {
|
||||||
|
const onFilterChange = vi.fn();
|
||||||
|
render(<ProjectFilterBar filters={{}} onFilterChange={onFilterChange} />);
|
||||||
|
fireEvent.change(screen.getByLabelText('Sắp xếp'), {
|
||||||
|
target: { value: 'price_asc' },
|
||||||
|
});
|
||||||
|
expect(onFilterChange).toHaveBeenCalledWith({
|
||||||
|
sort: 'price_asc',
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates city/district text inputs', () => {
|
||||||
|
const onFilterChange = vi.fn();
|
||||||
|
render(<ProjectFilterBar filters={{}} onFilterChange={onFilterChange} />);
|
||||||
|
fireEvent.change(screen.getByLabelText('Thành phố'), {
|
||||||
|
target: { value: 'Hà Nội' },
|
||||||
|
});
|
||||||
|
expect(onFilterChange).toHaveBeenCalledWith({
|
||||||
|
city: 'Hà Nội',
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows clear button only when a filter is active and clears preserving limit', () => {
|
||||||
|
const onFilterChange = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<ProjectFilterBar
|
||||||
|
filters={{ limit: 12 }}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Xóa bộ lọc')).toBeNull();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ProjectFilterBar
|
||||||
|
filters={{ limit: 12, status: 'SELLING' as never }}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('Xóa bộ lọc'));
|
||||||
|
expect(onFilterChange).toHaveBeenLastCalledWith({ page: 1, limit: 12 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ComponentErrorBoundary } from '../component-error-boundary';
|
||||||
|
|
||||||
|
vi.mock('@sentry/nextjs', () => ({
|
||||||
|
captureException: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function Boom() {
|
||||||
|
throw new Error('component-fail');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ComponentErrorBoundary', () => {
|
||||||
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children when no error', () => {
|
||||||
|
render(
|
||||||
|
<ComponentErrorBoundary label="bản đồ">
|
||||||
|
<div>map ok</div>
|
||||||
|
</ComponentErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('map ok')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders default-size fallback with label', () => {
|
||||||
|
render(
|
||||||
|
<ComponentErrorBoundary label="thanh toán">
|
||||||
|
<Boom />
|
||||||
|
</ComponentErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Không thể tải thanh toán')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Thử lại' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders compact fallback with label inline', () => {
|
||||||
|
render(
|
||||||
|
<ComponentErrorBoundary label="tìm kiếm" compact>
|
||||||
|
<Boom />
|
||||||
|
</ComponentErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Lỗi tìm kiếm/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Thử lại' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to generic copy when label is missing', () => {
|
||||||
|
render(
|
||||||
|
<ComponentErrorBoundary>
|
||||||
|
<Boom />
|
||||||
|
</ComponentErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Đã xảy ra lỗi')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ErrorBoundary } from '../error-boundary';
|
||||||
|
|
||||||
|
vi.mock('@sentry/nextjs', () => ({
|
||||||
|
captureException: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function Boom({ shouldThrow = true }: { shouldThrow?: boolean }) {
|
||||||
|
if (shouldThrow) {
|
||||||
|
throw new Error('boom');
|
||||||
|
}
|
||||||
|
return <div>safe</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ErrorBoundary', () => {
|
||||||
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// React logs caught errors to console.error in dev — silence for clean output
|
||||||
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children when no error', () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div>healthy</div>
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('healthy')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders default Vietnamese fallback on error', () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Boom />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Đã xảy ra lỗi')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Thử lại' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onError callback when child throws', () => {
|
||||||
|
const onError = vi.fn();
|
||||||
|
render(
|
||||||
|
<ErrorBoundary onError={onError}>
|
||||||
|
<Boom />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(onError).toHaveBeenCalledOnce();
|
||||||
|
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom fallback when provided', () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={({ error }) => <div>custom: {error.message}</div>}
|
||||||
|
>
|
||||||
|
<Boom />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('custom: boom')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset clears error and re-renders children', () => {
|
||||||
|
function Toggle() {
|
||||||
|
const [throwIt, setThrowIt] = React.useState(true);
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={({ reset }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setThrowIt(false);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Boom shouldThrow={throwIt} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Toggle />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'retry' }));
|
||||||
|
expect(screen.getByText('safe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { PageErrorBoundary } from '../page-error-boundary';
|
||||||
|
|
||||||
|
vi.mock('@sentry/nextjs', () => ({
|
||||||
|
captureException: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function Boom() {
|
||||||
|
throw new Error('page-fail');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PageErrorBoundary', () => {
|
||||||
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children when no error', () => {
|
||||||
|
render(
|
||||||
|
<PageErrorBoundary pageName="Trang chủ">
|
||||||
|
<div>ok</div>
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('ok')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders page-level fallback including pageName', () => {
|
||||||
|
render(
|
||||||
|
<PageErrorBoundary pageName="Danh sách">
|
||||||
|
<Boom />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Lỗi tải trang: Danh sách')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Trang này gặp sự cố/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders retry and home-link actions', () => {
|
||||||
|
render(
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<Boom />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', { name: 'Thử lại' })).toBeInTheDocument();
|
||||||
|
const homeLink = screen.getByRole('link', { name: 'Trang chủ' });
|
||||||
|
expect(homeLink).toHaveAttribute('href', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to generic title when pageName missing', () => {
|
||||||
|
render(
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<Boom />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Đã xảy ra lỗi')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,7 +81,8 @@ describe('InquiryDetailDialog', () => {
|
|||||||
render(
|
render(
|
||||||
<InquiryDetailDialog inquiry={mockInquiry} open={true} onOpenChange={vi.fn()} />,
|
<InquiryDetailDialog inquiry={mockInquiry} open={true} onOpenChange={vi.fn()} />,
|
||||||
);
|
);
|
||||||
expect(screen.getByText(/0912345678/)).toBeInTheDocument();
|
// formatPhone formats VN numbers as "0xxx yyy zzz" — match with optional spaces
|
||||||
|
expect(screen.getByText(/0912[\s]?345[\s]?678/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders inquiry message', () => {
|
it('renders inquiry message', () => {
|
||||||
@@ -156,6 +157,7 @@ describe('InquiryDetailDialog', () => {
|
|||||||
render(
|
render(
|
||||||
<InquiryDetailDialog inquiry={inquiryWithPhone} open={true} onOpenChange={vi.fn()} />,
|
<InquiryDetailDialog inquiry={inquiryWithPhone} open={true} onOpenChange={vi.fn()} />,
|
||||||
);
|
);
|
||||||
expect(screen.getByText(/0987654321/)).toBeInTheDocument();
|
// formatPhone formats VN numbers as "0xxx yyy zzz" — match with optional spaces
|
||||||
|
expect(screen.getByText(/0987[\s]?654[\s]?321/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { useMarkInquiryRead } from '@/lib/hooks/use-inquiries';
|
import { useMarkInquiryRead } from '@/lib/hooks/use-inquiries';
|
||||||
import type { InquiryReadDto } from '@/lib/inquiries-api';
|
import type { InquiryReadDto } from '@/lib/inquiries-api';
|
||||||
|
import { formatPhone, zaloHref } from '@/lib/phone';
|
||||||
|
|
||||||
interface InquiryDetailDialogProps {
|
interface InquiryDetailDialogProps {
|
||||||
inquiry: InquiryReadDto | null;
|
inquiry: InquiryReadDto | null;
|
||||||
@@ -42,6 +43,8 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const phone = inquiry.phone ?? inquiry.userPhone;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-md sm:max-w-lg">
|
<DialogContent className="max-w-md sm:max-w-lg">
|
||||||
@@ -60,7 +63,7 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
|||||||
<InquiryStatusBadge isRead={inquiry.isRead} />
|
<InquiryStatusBadge isRead={inquiry.isRead} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm text-muted-foreground">
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
<p>SĐT: {inquiry.phone ?? inquiry.userPhone}</p>
|
<p>SĐT: {formatPhone(phone)}</p>
|
||||||
<p>Ngày gửi: {formattedDate}</p>
|
<p>Ngày gửi: {formattedDate}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,13 +81,13 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
|||||||
<h4 className="text-sm font-medium">Liên hệ nhanh</h4>
|
<h4 className="text-sm font-medium">Liên hệ nhanh</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<a
|
<a
|
||||||
href={`tel:${inquiry.phone ?? inquiry.userPhone}`}
|
href={`tel:${phone}`}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
<Phone className="h-4 w-4" aria-hidden="true" /> Gọi điện
|
<Phone className="h-4 w-4" aria-hidden="true" /> Gọi điện
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={`https://zalo.me/${(inquiry.phone ?? inquiry.userPhone).replace(/^0/, '84')}`}
|
href={zaloHref(phone)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { IndustrialListingCard } from '../listing-card';
|
||||||
|
import type { IndustrialListingItem } from '@/lib/khu-cong-nghiep-api';
|
||||||
|
|
||||||
|
const baseListing: IndustrialListingItem = {
|
||||||
|
id: 'l1',
|
||||||
|
parkId: 'p1',
|
||||||
|
parkName: 'KCN Tân Thuận',
|
||||||
|
parkSlug: 'kcn-tan-thuan',
|
||||||
|
propertyType: 'READY_BUILT_FACTORY',
|
||||||
|
leaseType: 'FACTORY_LEASE',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
title: 'Nhà xưởng 5000m² gần cảng',
|
||||||
|
description: null,
|
||||||
|
areaM2: 5000,
|
||||||
|
ceilingHeightM: 9,
|
||||||
|
priceUsdM2: '4.5',
|
||||||
|
pricingUnit: 'm²/tháng',
|
||||||
|
totalLeasePrice: null,
|
||||||
|
minLeaseYears: 5,
|
||||||
|
maxLeaseYears: 20,
|
||||||
|
availableFrom: null,
|
||||||
|
media: null,
|
||||||
|
viewCount: 42,
|
||||||
|
publishedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('IndustrialListingCard', () => {
|
||||||
|
it('renders title, park link, area and property/lease badges', () => {
|
||||||
|
render(<IndustrialListingCard listing={baseListing} />);
|
||||||
|
expect(screen.getByText('Nhà xưởng 5000m² gần cảng')).toBeInTheDocument();
|
||||||
|
const parkLink = screen.getByRole('link', { name: 'KCN Tân Thuận' });
|
||||||
|
expect(parkLink.getAttribute('href')).toBe('/khu-cong-nghiep/kcn-tan-thuan');
|
||||||
|
expect(screen.getByText('5,000 m²')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Nhà xưởng xây sẵn')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Thuê nhà xưởng')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats priceUsdM2 with pricingUnit', () => {
|
||||||
|
render(<IndustrialListingCard listing={baseListing} />);
|
||||||
|
expect(screen.getByText('$4.5/m²/tháng')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to totalLeasePrice when priceUsdM2 missing', () => {
|
||||||
|
render(
|
||||||
|
<IndustrialListingCard
|
||||||
|
listing={{
|
||||||
|
...baseListing,
|
||||||
|
priceUsdM2: null,
|
||||||
|
pricingUnit: null,
|
||||||
|
totalLeasePrice: '125000',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('$125,000')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Liên hệ" when no price fields are present', () => {
|
||||||
|
render(
|
||||||
|
<IndustrialListingCard
|
||||||
|
listing={{
|
||||||
|
...baseListing,
|
||||||
|
priceUsdM2: null,
|
||||||
|
totalLeasePrice: null,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Liên hệ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders lease term range when both min and max provided', () => {
|
||||||
|
render(<IndustrialListingCard listing={baseListing} />);
|
||||||
|
expect(screen.getByText('5–20 năm')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Từ N năm" when only minLeaseYears provided', () => {
|
||||||
|
render(
|
||||||
|
<IndustrialListingCard
|
||||||
|
listing={{ ...baseListing, maxLeaseYears: null }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Từ 5 năm')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows view count only when > 0', () => {
|
||||||
|
const { rerender } = render(<IndustrialListingCard listing={baseListing} />);
|
||||||
|
expect(screen.getByText('42')).toBeInTheDocument();
|
||||||
|
rerender(
|
||||||
|
<IndustrialListingCard listing={{ ...baseListing, viewCount: 0 }} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('42')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ParkCard } from '../park-card';
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
...rest
|
||||||
|
}: React.PropsWithChildren<{ href: string } & Record<string, unknown>>) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const basePark = {
|
||||||
|
id: 'p1',
|
||||||
|
name: 'KCN Tân Thuận',
|
||||||
|
nameEn: 'Tan Thuan IP',
|
||||||
|
slug: 'kcn-tan-thuan',
|
||||||
|
developer: 'IPC Corp',
|
||||||
|
status: 'OPERATIONAL' as const,
|
||||||
|
province: 'TP.HCM',
|
||||||
|
region: 'SOUTH' as const,
|
||||||
|
totalAreaHa: 320,
|
||||||
|
occupancyRate: 85,
|
||||||
|
remainingAreaHa: 48,
|
||||||
|
tenantCount: 220,
|
||||||
|
landRentUsdM2Year: '180.5',
|
||||||
|
rbfRentUsdM2Month: '5.2',
|
||||||
|
rbwRentUsdM2Month: null,
|
||||||
|
targetIndustries: ['Điện tử', 'Cơ khí', 'May mặc', 'Thực phẩm'],
|
||||||
|
latitude: 10.7,
|
||||||
|
longitude: 106.7,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ParkCard', () => {
|
||||||
|
it('renders park name, English name, developer and location', () => {
|
||||||
|
render(<ParkCard park={basePark} />);
|
||||||
|
expect(screen.getByText('KCN Tân Thuận')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tan Thuan IP')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('IPC Corp')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/TP\.HCM/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to park detail page by slug', () => {
|
||||||
|
const { container } = render(<ParkCard park={basePark} />);
|
||||||
|
const link = container.querySelector('a');
|
||||||
|
expect(link?.getAttribute('href')).toBe('/khu-cong-nghiep/kcn-tan-thuan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders status label from PARK_STATUS_LABELS', () => {
|
||||||
|
render(<ParkCard park={basePark} />);
|
||||||
|
expect(screen.getByText('Đang hoạt động')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies amber occupancy color for 70–89%', () => {
|
||||||
|
render(<ParkCard park={basePark} />);
|
||||||
|
const pct = screen.getByText('85%');
|
||||||
|
expect(pct.className).toContain('text-amber-600');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies red occupancy color for >= 90%', () => {
|
||||||
|
render(<ParkCard park={{ ...basePark, occupancyRate: 95 }} />);
|
||||||
|
const pct = screen.getByText('95%');
|
||||||
|
expect(pct.className).toContain('text-red-600');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders rent info when landRentUsdM2Year present', () => {
|
||||||
|
render(<ParkCard park={basePark} />);
|
||||||
|
expect(screen.getByText(/\$180\.5\/m²\/năm/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/\$5\.2\/m²\/th/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits visible industry badges to 3 and shows +N overflow', () => {
|
||||||
|
render(<ParkCard park={basePark} />);
|
||||||
|
expect(screen.getByText('Điện tử')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Cơ khí')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('May mặc')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Thực phẩm')).toBeNull();
|
||||||
|
expect(screen.getByText('+1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ParkFilterBar } from '../park-filter-bar';
|
||||||
|
|
||||||
|
describe('ParkFilterBar', () => {
|
||||||
|
it('submits search with trimmed query and resets page to 1', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<ParkFilterBar params={{ limit: 20 }} onChange={onChange} />);
|
||||||
|
const input = screen.getByPlaceholderText(/Tìm kiếm KCN/);
|
||||||
|
fireEvent.change(input, { target: { value: ' Tân Thuận ' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Tìm' }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith({
|
||||||
|
limit: 20,
|
||||||
|
q: 'Tân Thuận',
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits with q=undefined when trimmed query is empty', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<ParkFilterBar params={{}} onChange={onChange} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Tìm' }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ q: undefined, page: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onChange when region select changes', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<ParkFilterBar params={{}} onChange={onChange} />);
|
||||||
|
fireEvent.change(screen.getByLabelText('Vùng miền'), {
|
||||||
|
target: { value: 'SOUTH' },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ region: 'SOUTH', page: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onChange when status select changes', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<ParkFilterBar params={{}} onChange={onChange} />);
|
||||||
|
fireEvent.change(screen.getByLabelText('Trạng thái'), {
|
||||||
|
target: { value: 'OPERATIONAL' },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledWith({
|
||||||
|
status: 'OPERATIONAL',
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows clear button only when a filter is active and resets while preserving limit', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<ParkFilterBar params={{ limit: 10 }} onChange={onChange} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Xóa bộ lọc')).toBeNull();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ParkFilterBar
|
||||||
|
params={{ limit: 10, region: 'NORTH' }}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const clearBtn = screen.getByText('Xóa bộ lọc');
|
||||||
|
fireEvent.click(clearBtn);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({ page: 1, limit: 10 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -69,7 +69,8 @@ describe('LeadDetailDialog', () => {
|
|||||||
|
|
||||||
it('renders phone number', () => {
|
it('renders phone number', () => {
|
||||||
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
|
||||||
expect(screen.getByText(/0987654321/)).toBeInTheDocument();
|
// formatPhone formats VN numbers as "0xxx yyy zzz" — match with optional spaces
|
||||||
|
expect(screen.getByText(/0987[\s]?654[\s]?321/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders email when present', () => {
|
it('renders email when present', () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { useDeleteLead, useUpdateLeadStatus } from '@/lib/hooks/use-leads';
|
import { useDeleteLead, useUpdateLeadStatus } from '@/lib/hooks/use-leads';
|
||||||
import { LEAD_STATUSES, LEAD_SOURCES, type LeadReadDto, type LeadStatus } from '@/lib/leads-api';
|
import { LEAD_STATUSES, LEAD_SOURCES, type LeadReadDto, type LeadStatus } from '@/lib/leads-api';
|
||||||
|
import { formatPhone, zaloHref } from '@/lib/phone';
|
||||||
|
|
||||||
interface LeadDetailDialogProps {
|
interface LeadDetailDialogProps {
|
||||||
lead: LeadReadDto | null;
|
lead: LeadReadDto | null;
|
||||||
@@ -96,7 +97,7 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
|
|||||||
<LeadStatusBadge status={lead.status} />
|
<LeadStatusBadge status={lead.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm text-muted-foreground">
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
<p>SĐT: {lead.phone}</p>
|
<p>SĐT: {formatPhone(lead.phone)}</p>
|
||||||
{lead.email && <p>Email: {lead.email}</p>}
|
{lead.email && <p>Email: {lead.email}</p>}
|
||||||
<p>Nguồn: {getSourceLabel(lead.source)}</p>
|
<p>Nguồn: {getSourceLabel(lead.source)}</p>
|
||||||
{lead.score !== null && <p>Điểm: {lead.score}/100</p>}
|
{lead.score !== null && <p>Điểm: {lead.score}/100</p>}
|
||||||
@@ -163,7 +164,7 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
href={`https://zalo.me/${lead.phone.replace(/^0/, '84')}`}
|
href={zaloHref(lead.phone)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||||
|
|||||||
74
apps/web/components/listings/__tests__/sparkline.spec.tsx
Normal file
74
apps/web/components/listings/__tests__/sparkline.spec.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { render, waitFor } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Sparkline } from '../sparkline';
|
||||||
|
|
||||||
|
const getPriceHistoryMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/listings-api', () => ({
|
||||||
|
listingsApi: {
|
||||||
|
getPriceHistory: (id: string) => getPriceHistoryMock(id),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function wrap(children: React.ReactNode) {
|
||||||
|
const client = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Sparkline', () => {
|
||||||
|
it('renders loading skeleton initially', () => {
|
||||||
|
getPriceHistoryMock.mockReturnValue(new Promise(() => {}));
|
||||||
|
const { container } = render(wrap(<Sparkline listingId="1" />));
|
||||||
|
expect(container.querySelector('.animate-pulse')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders em-dash when fewer than 2 data points', async () => {
|
||||||
|
getPriceHistoryMock.mockResolvedValue([{ newPrice: 100 }]);
|
||||||
|
const { findByText } = render(wrap(<Sparkline listingId="2" />));
|
||||||
|
expect(await findByText('—')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders polyline svg when given history data', async () => {
|
||||||
|
getPriceHistoryMock.mockResolvedValue([
|
||||||
|
{ newPrice: 100 },
|
||||||
|
{ newPrice: 110 },
|
||||||
|
{ newPrice: 120 },
|
||||||
|
]);
|
||||||
|
const { container } = render(wrap(<Sparkline listingId="3" />));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector('svg polyline')).not.toBeNull();
|
||||||
|
});
|
||||||
|
const polyline = container.querySelector('polyline');
|
||||||
|
expect(polyline?.getAttribute('points')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses up-trend color when last >= first price', async () => {
|
||||||
|
getPriceHistoryMock.mockResolvedValue([
|
||||||
|
{ newPrice: 100 },
|
||||||
|
{ newPrice: 200 },
|
||||||
|
]);
|
||||||
|
const { container } = render(wrap(<Sparkline listingId="4" />));
|
||||||
|
await waitFor(() => {
|
||||||
|
const stroke = container.querySelector('polyline')?.getAttribute('stroke');
|
||||||
|
expect(stroke).toContain('signal-up');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses down-trend color when last < first price', async () => {
|
||||||
|
getPriceHistoryMock.mockResolvedValue([
|
||||||
|
{ newPrice: 200 },
|
||||||
|
{ newPrice: 100 },
|
||||||
|
]);
|
||||||
|
const { container } = render(wrap(<Sparkline listingId="5" />));
|
||||||
|
await waitFor(() => {
|
||||||
|
const stroke = container.querySelector('polyline')?.getAttribute('stroke');
|
||||||
|
expect(stroke).toContain('signal-down');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { UseFormRegister, UseFormSetValue, UseFormWatch, FieldErrors } from 'react-hook-form';
|
import type { UseFormRegister, UseFormSetValue, UseFormWatch, FieldErrors } from 'react-hook-form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -43,9 +44,13 @@ interface StepLocationProps extends StepProps {
|
|||||||
watch?: UseFormWatch<CreateListingFormData>;
|
watch?: UseFormWatch<CreateListingFormData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldError({ message }: { message?: string }) {
|
function FieldError({ id, message }: { id: string; message?: string }) {
|
||||||
if (!message) return null;
|
if (!message) return null;
|
||||||
return <p className="mt-1 text-xs text-destructive">{message}</p>;
|
return (
|
||||||
|
<p id={id} role="alert" className="mt-1 text-xs text-destructive">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step 1: Basic Info ──────────────────────────────────
|
// ─── Step 1: Basic Info ──────────────────────────────────
|
||||||
@@ -58,7 +63,12 @@ export function StepBasicInfo({ register, errors }: StepProps) {
|
|||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="transactionType">Loại giao dịch *</Label>
|
<Label htmlFor="transactionType">Loại giao dịch *</Label>
|
||||||
<Select id="transactionType" {...register('transactionType')}>
|
<Select
|
||||||
|
id="transactionType"
|
||||||
|
aria-invalid={!!errors.transactionType}
|
||||||
|
aria-describedby={errors.transactionType ? 'transactionType-error' : undefined}
|
||||||
|
{...register('transactionType')}
|
||||||
|
>
|
||||||
<option value="">-- Chọn --</option>
|
<option value="">-- Chọn --</option>
|
||||||
{TRANSACTION_TYPES.map((t) => (
|
{TRANSACTION_TYPES.map((t) => (
|
||||||
<option key={t.value} value={t.value}>
|
<option key={t.value} value={t.value}>
|
||||||
@@ -66,12 +76,17 @@ export function StepBasicInfo({ register, errors }: StepProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<FieldError message={errors.transactionType?.message} />
|
<FieldError id="transactionType-error" message={errors.transactionType?.message} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="propertyType">Loại bất động sản *</Label>
|
<Label htmlFor="propertyType">Loại bất động sản *</Label>
|
||||||
<Select id="propertyType" {...register('propertyType')}>
|
<Select
|
||||||
|
id="propertyType"
|
||||||
|
aria-invalid={!!errors.propertyType}
|
||||||
|
aria-describedby={errors.propertyType ? 'propertyType-error' : undefined}
|
||||||
|
{...register('propertyType')}
|
||||||
|
>
|
||||||
<option value="">-- Chọn --</option>
|
<option value="">-- Chọn --</option>
|
||||||
{PROPERTY_TYPES.map((t) => (
|
{PROPERTY_TYPES.map((t) => (
|
||||||
<option key={t.value} value={t.value}>
|
<option key={t.value} value={t.value}>
|
||||||
@@ -79,14 +94,20 @@ export function StepBasicInfo({ register, errors }: StepProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<FieldError message={errors.propertyType?.message} />
|
<FieldError id="propertyType-error" message={errors.propertyType?.message} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="title">Tiêu đề tin đăng *</Label>
|
<Label htmlFor="title">Tiêu đề tin đăng *</Label>
|
||||||
<Input id="title" placeholder="VD: Bán căn hộ 2PN tại Vinhomes Central Park" {...register('title')} />
|
<Input
|
||||||
<FieldError message={errors.title?.message} />
|
id="title"
|
||||||
|
placeholder="VD: Bán căn hộ 2PN tại Vinhomes Central Park"
|
||||||
|
aria-invalid={!!errors.title}
|
||||||
|
aria-describedby={errors.title ? 'title-error' : undefined}
|
||||||
|
{...register('title')}
|
||||||
|
/>
|
||||||
|
<FieldError id="title-error" message={errors.title?.message} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -95,9 +116,11 @@ export function StepBasicInfo({ register, errors }: StepProps) {
|
|||||||
id="description"
|
id="description"
|
||||||
rows={5}
|
rows={5}
|
||||||
placeholder="Mô tả chi tiết về bất động sản..."
|
placeholder="Mô tả chi tiết về bất động sản..."
|
||||||
|
aria-invalid={!!errors.description}
|
||||||
|
aria-describedby={errors.description ? 'description-error' : undefined}
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
/>
|
/>
|
||||||
<FieldError message={errors.description?.message} />
|
<FieldError id="description-error" message={errors.description?.message} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -115,10 +138,35 @@ export function StepLocation({ register, errors, setValue, watch }: StepLocation
|
|||||||
const latValid = latNum != null && Number.isFinite(latNum) && latNum >= -90 && latNum <= 90;
|
const latValid = latNum != null && Number.isFinite(latNum) && latNum >= -90 && latNum <= 90;
|
||||||
const lngValid = lngNum != null && Number.isFinite(lngNum) && lngNum >= -180 && lngNum <= 180;
|
const lngValid = lngNum != null && Number.isFinite(lngNum) && lngNum >= -180 && lngNum <= 180;
|
||||||
|
|
||||||
|
// Live region message announced when the map geocoder resolves a location.
|
||||||
|
const [locationAnnouncement, setLocationAnnouncement] = useState('');
|
||||||
|
const announcementTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Clear announcement after it has been read to avoid stale text being
|
||||||
|
// re-announced on re-render.
|
||||||
|
useEffect(() => {
|
||||||
|
if (locationAnnouncement) {
|
||||||
|
announcementTimerRef.current = setTimeout(() => setLocationAnnouncement(''), 3000);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (announcementTimerRef.current) clearTimeout(announcementTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [locationAnnouncement]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">Vị trí</h3>
|
<h3 className="text-lg font-semibold">Vị trí</h3>
|
||||||
|
|
||||||
|
{/* Visually-hidden live region for map-picker location announcements */}
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
className="sr-only"
|
||||||
|
>
|
||||||
|
{locationAnnouncement}
|
||||||
|
</div>
|
||||||
|
|
||||||
{setValue && (
|
{setValue && (
|
||||||
<LocationPicker
|
<LocationPicker
|
||||||
lat={latValid ? latNum : null}
|
lat={latValid ? latNum : null}
|
||||||
@@ -131,6 +179,11 @@ export function StepLocation({ register, errors, setValue, watch }: StepLocation
|
|||||||
if (resolved.ward) setValue('ward', resolved.ward, { shouldDirty: true });
|
if (resolved.ward) setValue('ward', resolved.ward, { shouldDirty: true });
|
||||||
if (resolved.district) setValue('district', resolved.district, { shouldDirty: true });
|
if (resolved.district) setValue('district', resolved.district, { shouldDirty: true });
|
||||||
if (resolved.city) setValue('city', resolved.city, { shouldDirty: true });
|
if (resolved.city) setValue('city', resolved.city, { shouldDirty: true });
|
||||||
|
// Announce resolved location to screen reader users
|
||||||
|
const parts = [resolved.ward, resolved.district, resolved.city].filter(Boolean);
|
||||||
|
if (parts.length > 0) {
|
||||||
|
setLocationAnnouncement(`Đã cập nhật vị trí: ${parts.join(', ')}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
height="360px"
|
height="360px"
|
||||||
@@ -139,25 +192,49 @@ export function StepLocation({ register, errors, setValue, watch }: StepLocation
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="address">Địa chỉ *</Label>
|
<Label htmlFor="address">Địa chỉ *</Label>
|
||||||
<Input id="address" placeholder="Số nhà, tên đường" {...register('address')} />
|
<Input
|
||||||
<FieldError message={errors.address?.message} />
|
id="address"
|
||||||
|
placeholder="Số nhà, tên đường"
|
||||||
|
aria-invalid={!!errors.address}
|
||||||
|
aria-describedby={errors.address ? 'address-error' : undefined}
|
||||||
|
{...register('address')}
|
||||||
|
/>
|
||||||
|
<FieldError id="address-error" message={errors.address?.message} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="ward">Phường/Xã *</Label>
|
<Label htmlFor="ward">Phường/Xã *</Label>
|
||||||
<Input id="ward" placeholder="Phường/Xã" {...register('ward')} />
|
<Input
|
||||||
<FieldError message={errors.ward?.message} />
|
id="ward"
|
||||||
|
placeholder="Phường/Xã"
|
||||||
|
aria-invalid={!!errors.ward}
|
||||||
|
aria-describedby={errors.ward ? 'ward-error' : undefined}
|
||||||
|
{...register('ward')}
|
||||||
|
/>
|
||||||
|
<FieldError id="ward-error" message={errors.ward?.message} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="district">Quận/Huyện *</Label>
|
<Label htmlFor="district">Quận/Huyện *</Label>
|
||||||
<Input id="district" placeholder="Quận/Huyện" {...register('district')} />
|
<Input
|
||||||
<FieldError message={errors.district?.message} />
|
id="district"
|
||||||
|
placeholder="Quận/Huyện"
|
||||||
|
aria-invalid={!!errors.district}
|
||||||
|
aria-describedby={errors.district ? 'district-error' : undefined}
|
||||||
|
{...register('district')}
|
||||||
|
/>
|
||||||
|
<FieldError id="district-error" message={errors.district?.message} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="city">Tỉnh/Thành phố *</Label>
|
<Label htmlFor="city">Tỉnh/Thành phố *</Label>
|
||||||
<Input id="city" placeholder="Tỉnh/Thành phố" {...register('city')} />
|
<Input
|
||||||
<FieldError message={errors.city?.message} />
|
id="city"
|
||||||
|
placeholder="Tỉnh/Thành phố"
|
||||||
|
aria-invalid={!!errors.city}
|
||||||
|
aria-describedby={errors.city ? 'city-error' : undefined}
|
||||||
|
{...register('city')}
|
||||||
|
/>
|
||||||
|
<FieldError id="city-error" message={errors.city?.message} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -169,9 +246,11 @@ export function StepLocation({ register, errors, setValue, watch }: StepLocation
|
|||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="VD: 10.7769"
|
placeholder="VD: 10.7769"
|
||||||
|
aria-invalid={!!errors.latitude}
|
||||||
|
aria-describedby={errors.latitude ? 'latitude-error' : undefined}
|
||||||
{...register('latitude')}
|
{...register('latitude')}
|
||||||
/>
|
/>
|
||||||
<FieldError message={errors.latitude?.message} />
|
<FieldError id="latitude-error" message={errors.latitude?.message} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="longitude">Kinh độ</Label>
|
<Label htmlFor="longitude">Kinh độ</Label>
|
||||||
@@ -180,9 +259,11 @@ export function StepLocation({ register, errors, setValue, watch }: StepLocation
|
|||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="VD: 106.7009"
|
placeholder="VD: 106.7009"
|
||||||
|
aria-invalid={!!errors.longitude}
|
||||||
|
aria-describedby={errors.longitude ? 'longitude-error' : undefined}
|
||||||
{...register('longitude')}
|
{...register('longitude')}
|
||||||
/>
|
/>
|
||||||
<FieldError message={errors.longitude?.message} />
|
<FieldError id="longitude-error" message={errors.longitude?.message} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,8 +280,16 @@ export function StepDetails({ register, errors }: StepProps) {
|
|||||||
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="areaM2">Diện tích (m²) *</Label>
|
<Label htmlFor="areaM2">Diện tích (m²) *</Label>
|
||||||
<Input id="areaM2" type="number" step="0.1" placeholder="VD: 75" {...register('areaM2')} />
|
<Input
|
||||||
<FieldError message={errors.areaM2?.message} />
|
id="areaM2"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="VD: 75"
|
||||||
|
aria-invalid={!!errors.areaM2}
|
||||||
|
aria-describedby={errors.areaM2 ? 'areaM2-error' : undefined}
|
||||||
|
{...register('areaM2')}
|
||||||
|
/>
|
||||||
|
<FieldError id="areaM2-error" message={errors.areaM2?.message} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="usableAreaM2">Diện tích sử dụng (m²)</Label>
|
<Label htmlFor="usableAreaM2">Diện tích sử dụng (m²)</Label>
|
||||||
@@ -360,8 +449,14 @@ export function StepPricing({ register, errors }: StepProps) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="priceVND">Giá bán (VNĐ) *</Label>
|
<Label htmlFor="priceVND">Giá bán (VNĐ) *</Label>
|
||||||
<Input id="priceVND" placeholder="VD: 5000000000" {...register('priceVND')} />
|
<Input
|
||||||
<FieldError message={errors.priceVND?.message} />
|
id="priceVND"
|
||||||
|
placeholder="VD: 5000000000"
|
||||||
|
aria-invalid={!!errors.priceVND}
|
||||||
|
aria-describedby={errors.priceVND ? 'priceVND-error' : undefined}
|
||||||
|
{...register('priceVND')}
|
||||||
|
/>
|
||||||
|
<FieldError id="priceVND-error" message={errors.priceVND?.message} />
|
||||||
<p className="mt-1 text-xs text-muted-foreground">Nhập số không có dấu chấm hoặc dấu phẩy</p>
|
<p className="mt-1 text-xs text-muted-foreground">Nhập số không có dấu chấm hoặc dấu phẩy</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user