The OSM bbox sync was picking up `landuse=industrial` polygons that sit
just across the borders in Laos, Thailand, Cambodia and southern China.
After the bulk promote we ended up with 220 of those in the public
catalog — Vientiane SEZ, Phnom Penh SEZ, Sihanoukville SEZ, several
Thai industrial estates etc.
Two-part fix:
1. `scripts/data/vn-country-polygon.ts` — a hand-traced ~30-vertex
GeoJSON polygon that follows VN's land + sea border. The eastern
edge is generous (110°E) so every coastal industrial zone (Vũng Áng
/ Formosa, Dung Quất, Nhơn Hội, Vũng Tàu / Long Sơn) sits comfortably
inside; the western/northern edges trace the actual neighbour
borders. Includes a pure-JS `isPointInVietnam(lng, lat)` ray-cast
helper for the sync script (no extra dep).
2. `scripts/prune-non-vietnam-osm.ts` — one-shot cleaner. Uses PostGIS
`ST_Within(location, polygon)` to delete every OSM row whose centroid
falls outside. Verified the polygon doesn't reject genuine VN parks
(Formosa Hà Tĩnh, Dung Quất, Nhơn Hội, KCN Đất Đỏ etc. all pass).
3. `sync-osm-industrial-parks.ts` `parseFeature()` now calls
`isPointInVietnam` after computing the centroid and bails early on a
miss, so the next monthly cron run won't re-import them.
Run on dev: removed 220 rows. Final catalog 1,483 KCN, all inside the
Vietnam mainland polygon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The KCN catalog was running in two parallel modes — 20 hand-curated demo
rows (MANUAL) plus 2,193 OSM imports stuck in the review queue. The user
asked to drop the demo data and publish all OSM rows in one shot, so the
public catalog reflects the full Vietnamese landscape from the start.
Steps run against the dev DB:
• DELETE 20 MANUAL parks (12 IndustrialListing rows cascaded out)
• UPDATE 2,193 OSM rows → dataSource = 'OSM_PROMOTED', isPublic = true
• DELETE 490 polygons that bled across the northern border bbox and
have only CJK names (no Latin / Vietnamese letter at all). These
were Chinese industrial sites — Fangcheng Port, Guangxi Steel,
BYD test site etc. — picked up because the Quảng Ninh / Lạng Sơn
chunks of the Overpass query include the cross-border buffer.
Artefacts:
• `scripts/promote-all-osm.ts` — re-runnable bulk action with --dry-run
and --keep-manual flags. Idempotent (already-promoted rows skipped).
• `scripts/sync-osm-industrial-parks.ts` now drops non-Latin names at
`parseFeature()` so the next monthly sync won't re-import them.
Catalog ergonomics improvements that followed:
• PrismaIndustrialParkRepository.list now `ORDER BY totalAreaHa DESC
NULLS LAST` so the largest KCN appear first instead of being buried
under 0-ha NODE imports. Bàu Bàng (2,597 ha), Nhơn Trạch (2,535 ha),
Phước Đông, Hòa Lạc, etc. now lead the list.
• IndustrialParksBboxDto default `limit` raised 1000 → 3000 so a
country-zoom request returns the entire promoted set without
truncation. The bbox handler already orders by area DESC so the
truncated case keeps the meaningful entries.
Final catalog: 1,703 promoted KCN, 0 raw OSM, 0 manual.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four UX issues surfaced when reviewing the new OSM-sync pipeline against
the actual 2,193 imports — fixed in this commit:
1. Admin queue surfaced noise first.
`ListOsmPendingHandler` now sorts by `totalAreaHa DESC` (real KCN
first, single-factory `landuse=industrial` polygons last) and accepts
`minAreaHa` (default 50 ha) plus a `region` filter. The admin page
exposes both as dropdowns — "Tất cả / ≥ 5 / ≥ 50 / ≥ 200 / ≥ 500 ha".
Top-of-queue is now Bàu Bàng (2,597 ha) and Nhơn Trạch (2,535 ha).
2. Promote dialog said "KCN KCN Đại An" — duplicate prefix.
Reworded to "Sắp promote: <name>" so the row name stands on its own.
3. Province was "Chưa xác định" on 2,107 of 2,193 OSM rows.
The OSM tags lacked any addr:* hint, so the importer never had
anything to write. Added `scripts/data/vn-province-centroids.ts` (63
provinces with capital-city coords) and a `nearestProvince(lat, lng)`
fallback in `parseFeature()`. Shipped a one-shot backfill script
`scripts/backfill-osm-provinces.ts` and ran it — every existing OSM
row now has a province (Hồ Chí Minh: 408, Lạng Sơn: 232,
Quảng Ninh: 220, Hà Nội: 172, Hải Phòng: 105, …). Admin can correct
on promote if the nearest-centroid heuristic picked the wrong
neighbour for a long-thin province.
4. Public map looked empty — only 20 curated parks visible.
Added an opt-in toggle "Hiển thị KCN OSM" with a small legend above
the map. When on, the bbox endpoint returns OSM raw rows too; markers
render in amber (vs. green for curated) at slightly smaller radius
and lower opacity, so the visual hierarchy stays clear. Refetch is
wired through a ref so the toggle takes effect without remounting
the map.
Verified in browser preview: zoom-out shows clusters of 320 / 71 / etc.
across the country with the toggle on, and just three small clusters
(20 curated parks) when off.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulls every `landuse=industrial` feature from OpenStreetMap into the
IndustrialPark catalog and surfaces it on the public KCN map. Admins can
promote raw OSM rows into the public catalog or lock individual fields
to protect them from the monthly reconciliation sync.
PR 2 — Bulk import script (scripts/sync-osm-industrial-parks.ts):
• Splits Vietnam into 4 chunks (north / northCentral / southCentral /
south) to stay under Overpass 504 timeouts.
• Posts to overpass-api.de with form-encoded body, converts via
osmtogeojson, derives centroid + area via @turf/centroid + @turf/area.
• Upsert keyed on osmId. Honours `osmLocked` (skip row entirely) and
`lockedFields[]` (skip individual columns) so admin edits survive.
• Inserts use $executeRawUnsafe with ST_SetSRID(ST_MakePoint, 4326)
because Prisma can't manage the Unsupported geometry NOT NULL column.
• CLI flags: --dry-run, --chunk=NAME.
PR 3 — Bbox spatial API + Mapbox layer:
• GET /industrial/parks/by-bbox returns a GeoJSON FeatureCollection
filtered by ST_MakeEnvelope. Sends Point-only at zoom < 12,
MultiPolygon outline at zoom >= 12 to keep payloads light.
• Public consumers see MANUAL + OSM_PROMOTED only; admins can pass
includeOsmRaw=true to also see raw OSM imports.
• OsmParkBboxMap component drives Mapbox from viewport moveend with
AbortController-debounced fetches, clusters at zoom < 12, expands
via getClusterExpansionZoom (callback-style API).
• /khu-cong-nghiep page now uses the bbox map in map + split views.
PR 4 — Admin review queue + monthly cron:
• Commands: PromoteOsmPark (OSM → OSM_PROMOTED + isPublic=true,
optional lockFields), LockOsmPark (toggle row-level skip flag).
• Query: ListOsmPending lists rows with dataSource='OSM' for review.
• OsmSyncCronService runs `0 2 1 * *` Asia/Ho_Chi_Minh and spawns
sync-osm-industrial-parks.ts per chunk. Skipped unless
OSM_SYNC_ENABLED=true so dev never accidentally hits Overpass.
• New admin page /admin/industrial/osm-review: searchable table,
promote dialog with quick-pick lock fields (name, developer,
description, etc.) plus a free-text fallback, lock/unlock toggle,
deep-link to openstreetmap.org for verification.
Repository changes:
• PrismaIndustrialParkRepository now filters public queries to
`isPublic = true AND dataSource IN (MANUAL, OSM_PROMOTED)` so raw
OSM rows stay hidden from end users.
• Added *.rdb to .gitignore (Redis dump local artefact).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- price-area-chart + sparkline: replace non-existent `var(--color-signal-up)`
with proper `hsl(var(--signal-up))` (and same for -down + border +
muted-foreground). The previous tokens resolved to undefined, leaving
the chart line + sparkline invisible against the dark background.
- public/page: switch `currentPeriod()` from monthly (YYYY-MM) to
quarterly (YYYY-Qn) to match the MarketIndex aggregation period —
heatmap and district stats now find rows.
- import-market-data: add `2026-Q2` to seeded periods so the current
quarter has data on a freshly seeded dev DB.
- new scripts/seed-bulk-listings-per-district.ts: top up the dev DB
with 12 synthetic listings per district per 7-day window so the
movers query (which requires >= 10 listings/district/window) has
signal to compute against.
- update price-area-chart.spec to match new color tokens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
API bootstrap fixes (DI wiring):
- analytics.module: add forwardRef(() => AdminModule) to import
AI_CONFIG_PROVIDER for GetListingAiAdviceHandler + GetProjectAiAdviceHandler
- listings.module: add PaymentsModule to imports so PAYMENT_INITIATOR is
resolvable by FeatureListingHandler
- metrics.module: register 3 missing Prometheus providers that MetricsService
injects (READ_MODEL_PROJECTOR_LAG_SECONDS / REFRESH_DURATION /
RECONCILIATION_DRIFT_TOTAL) — caused boot failure previously
- get-listing-ai-advice.handler: switch LISTING_REPOSITORY import from barrel
@modules/listings to direct internal path to break circular reference that
made the symbol evaluate as undefined at decorator time
- shared.module: comment out broken EVENT_BUS / OutboxService / OutboxRelay
providers (depend on @goodgo/contracts-events workspace pkg not yet wired)
CSRF middleware:
- Rewrite exclude logic as inline path-check inside the middleware itself.
Nest 11 + path-to-regexp v8 changed how MiddlewareConsumer.exclude() matches
against forRoutes('*') — the previous string patterns silently stopped
matching, causing every POST to /auth/login to return 403 CSRF Forbidden.
Inlined exempt list strips the /api/v1 prefix and checks against a Set.
Admin revenue stats:
- admin-stats.queries: use Prisma.sql template fragments for DATE_TRUNC unit
('day'|'month'). Passing the unit as a bind parameter caused Postgres error
42803 (column must appear in GROUP BY) because the planner treats $1 as an
opaque scalar and cannot prove SELECT and GROUP BY expressions are equal.
Admin audit-log page:
- SeverityPill: add ?? 'info' fallback — backend AuditLogEntry does not
include a `severity` field, so SEVERITY_CONFIG[undefined] was undefined
and .dir threw TypeError, crashing the whole audit-log page.
DB seed fixes:
- seed.ts: replace Vietnamese enum literals ('Sổ hồng', 'Sổ đỏ') with
correct enum keys ('SO_HONG', 'SO_DO') for the LegalStatus column
- seed-industrial-parks.ts: gate the standalone main() behind
require.main === module so importing the file from seed.ts doesn't
immediately close the pg.Pool used by the orchestrator
- scripts/seed-industrial-listings.ts: restore from tmp/ stash; was missing
from scripts/ causing seed.ts import to fail at startup
- migration 20260429010000_add_property_certificate_verified: Property table
was missing the certificateVerified column required by seed + Prisma schema
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add seed-macro-infra.ts with 144 macroeconomic data points (HCMC + Hanoi,
6 indicators, quarterly 2023-2025) and 15 infrastructure projects with
PostGIS coordinates (Metro Line 1, Thu Duc Innovation District, Ring Road 3,
Long Thanh Airport, Can Gio Bridge, etc.). Integrated into main seed pipeline.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Add three new NestJS modules following DDD/CQRS architecture:
- Industrial: KCN (industrial park) management with PostGIS geo queries, Typesense search, and market statistics
- Transfer: Furniture/premises transfer listings with AI-powered price estimation and depreciation modeling
- Reports: Async AI report generation via BullMQ with Claude narrative service, PDF generation, and macro data integration
Includes Prisma schema models, migrations, seed scripts, and app.module wiring with BullMQ Redis config.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Previously, `docker image prune` ran immediately after deploying new
containers, potentially deleting the old images needed for rollback
if smoke tests subsequently failed. Now the deploy pipeline:
1. Tags current images as :rollback before pulling new versions
2. Only runs `docker image prune` after smoke tests pass
3. Uses explicit :rollback tags for rollback instead of relying on
Docker layer cache (which is fragile)
Applied to:
- scripts/deploy-production.sh (manual deploy script)
- .github/workflows/deploy.yml (staging + production CI jobs)
- docs/deployment.md (updated rollback documentation)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Add Nginx reverse-proxy configs for api.goodgo.vn and platform.goodgo.vn
with SSL, gzip, rate limiting, security headers, and WebSocket support
- Add Cloudflare DNS setup script for A/AAAA/CNAME records
- Add server-setup.sh for Ubuntu provisioning (Docker, fail2ban, UFW,
swap, unattended-upgrades)
- Add deploy-production.sh for manual production deployments
- Add env.production.example with all required environment variables
- Bind container ports to 127.0.0.1 in docker-compose.prod.yml
(security: prevent direct access bypassing Nginx)
- Fix deploy workflow: add -T flag to exec, sync Nginx configs,
copy pgbouncer and backup configs to server
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
- Remove `type` keyword from NestJS injectable class imports across all
modules to fix runtime DI resolution (330+ handler/listener files)
- Offset CI docker-compose ports (5433/6380/8109/9002) to avoid
conflicts with running dev containers
- Update .env.test, playwright.config.ts, and e2e workflow to use
isolated CI ports with configurable overrides
- Fix prisma/seed.ts to use deterministic IDs for Prisma 7 upsert
compatibility (phoneHash replaced phone as unique index)
- Add dedicated Docker bridge network for CI service containers
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Streamline developer onboarding with a single bootstrap.sh that checks
prerequisites, configures .env with generated secrets, installs deps,
starts Docker services, and runs migrations + seeding.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Change circuit-breaker import in resilient-search.repository.ts to use
@modules/shared barrel export instead of deep path, fixing no-restricted-imports
error. Replace console.log with console.warn in encrypt-existing-kyc.ts script
to satisfy no-console rule.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Add field-level encryption service for PII data with AES-256-GCM
- Add health check specs for Prisma and Redis indicators
- Add MCP controller specs
- Add encrypt-existing-kyc migration script for existing KYC data
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Add scripts/smoke-test.sh — hits health, readiness, and critical API
endpoints (listings, search, subscriptions) post-deploy
- Add smoke-test-staging job that runs after staging deploy with Slack
notification on failure
- Add smoke-test-production job that runs after production deploy with
success notification
- Add rollback-production job triggered on smoke test failure — reverts
to previous container images and notifies via Slack
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Apply QuotaGuard + @RequireQuota to listing creation and analytics endpoints
- Add QuotaExceeded domain event emitted when quota is exceeded
- Create ListingCreatedUsageHandler to auto-meter usage on listing creation
- Create QuotaExceededListener to send email notifications on quota exceeded
- Add maxAnalyticsQueries and maxMediaUploads fields to Plan model
- Add quota.exceeded email notification template
- Define quota limits per plan tier in seed data
- Add 15 unit tests covering guard, event handler, listener, and event
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Add non-null assertions for array indexing in prisma/seed.ts and
scripts/seed-districts.ts to satisfy strict TypeScript checks.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- scripts/seed-districts.ts: Vietnam district/ward data for HCM, Hanoi, Da Nang with sample properties
- scripts/seed-plans.ts: Subscription plans (FREE, AGENT_PRO, INVESTOR, ENTERPRISE)
- scripts/import-market-data.ts: Market index data across all 3 cities with realistic pricing
- All scripts are idempotent (upsert/ON CONFLICT DO NOTHING)
- Refactored prisma/seed.ts to import shared data from scripts, removing duplication
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Add pg-backup container with daily automated pg_dump (02:00 UTC) and 7-day retention
- Add backup/restore scripts with documented recovery procedure
- Add Loki + Promtail for centralized log aggregation from all Docker containers
- Add Loki as Grafana datasource with correlation ID derived fields
- Add Grafana logs dashboard with volume, error rate, HTTP request, and log viewer panels
- Configure Promtail to parse Pino structured JSON logs with level/context labels
- Enhance LoggerService with string-level formatter and service base field
- Configure 15-day log retention in Loki
Co-Authored-By: Paperclip <noreply@paperclip.ing>