wip: listings/admin in-flight — bulk update, duplicates, audit log, price constraints
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 7s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 10s
Deploy / Build API Image (push) Failing after 23s
E2E Tests / Playwright E2E (push) Failing after 7s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 43s
Security Scanning / Trivy Scan — Web Image (push) Failing after 28s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 28s
Deploy / Build Web Image (push) Failing after 10s
Deploy / Build AI Services Image (push) Failing after 9s
Security Scanning / Trivy Filesystem Scan (push) Failing after 38s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped

Batch-committing concurrent work-in-progress so it isn't lost:

Listings — bulk update + duplicate detection
---------------------------------------------
- New command BulkUpdateListings + handler + tests under
  application/commands/bulk-update-listings/.
- New DTO presentation/dto/bulk-update-listings.dto.ts.
- Controller wires the bulk endpoint; update DTO extended.
- Property duplicate detector hardened: normalized-address pipeline
  (new migration 20260420020000_add_property_address_normalized),
  repository + service updates, tests refreshed.
- Listing entity gains ownership-transferred event (new event file).
- Integration specs for price constraints
  (20260420000000_add_price_check_constraints) and duplicates.
- E2E: e2e/api/listings-duplicates.spec.ts.

Admin — moderation audit log
----------------------------
- New Prisma table (migration 20260420010000_add_moderation_audit_log)
  + Prisma repo + interface + DI wiring.
- Listener `moderation-audit.listener.ts` + unit spec.
- Query GetModerationAuditLogs + handler + controller
  `admin-moderation-audit.controller.ts` + DTO.

Supporting
----------
- shared/infrastructure/cache.service.ts tweak.
- AUDIT_LISTINGS_PROPERTY_MANAGEMENT.md — in-repo audit notes.
- Various test + module wiring updates to keep the tree green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-20 13:53:28 +07:00
parent 3287298592
commit d9cea3828e
50 changed files with 3105 additions and 220 deletions

View File

@@ -0,0 +1,65 @@
-- Add CHECK constraints to enforce positive prices at the DB layer.
-- Scope (per TEC-2925): Listing price columns. Property has no direct
-- "price" column, only nullable maintenanceFeeVND (covered defensively).
-- Related price tables (PriceHistory) are also covered to keep the
-- invariant consistent across the price domain.
-- -----------------------------------------------------------------------------
-- 1) Backfill / clean offending data BEFORE applying constraints.
-- Strategy: NULL-out optional bad values; for the required Listing.priceVND
-- column we cannot null it, so any non-positive value is set to 1 (token
-- minimum) so the migration succeeds. In dev/seed these should not exist;
-- in prod the migration runner should review the audit log below before
-- applying.
-- -----------------------------------------------------------------------------
-- Audit (informational): how many rows would violate?
-- SELECT COUNT(*) FROM "Listing" WHERE "priceVND" <= 0;
-- SELECT COUNT(*) FROM "Listing" WHERE "rentPriceMonthly" IS NOT NULL AND "rentPriceMonthly" <= 0;
-- SELECT COUNT(*) FROM "Listing" WHERE "pricePerM2" IS NOT NULL AND "pricePerM2" <= 0;
-- SELECT COUNT(*) FROM "Listing" WHERE "aiPriceEstimate" IS NOT NULL AND "aiPriceEstimate" <= 0;
-- SELECT COUNT(*) FROM "PriceHistory" WHERE "oldPrice" <= 0 OR "newPrice" <= 0;
-- Backfill: required column → coerce to 1 VND (audit trail before applying).
UPDATE "Listing" SET "priceVND" = 1 WHERE "priceVND" <= 0;
-- Backfill: optional columns → NULL them out (was clearly invalid data).
UPDATE "Listing" SET "rentPriceMonthly" = NULL WHERE "rentPriceMonthly" IS NOT NULL AND "rentPriceMonthly" <= 0;
UPDATE "Listing" SET "pricePerM2" = NULL WHERE "pricePerM2" IS NOT NULL AND "pricePerM2" <= 0;
UPDATE "Listing" SET "aiPriceEstimate" = NULL WHERE "aiPriceEstimate" IS NOT NULL AND "aiPriceEstimate" <= 0;
-- PriceHistory rows with non-positive prices are corrupt; remove them.
DELETE FROM "PriceHistory" WHERE "oldPrice" <= 0 OR "newPrice" <= 0;
-- -----------------------------------------------------------------------------
-- 2) Apply CHECK constraints
-- -----------------------------------------------------------------------------
ALTER TABLE "Listing"
ADD CONSTRAINT "listing_price_vnd_positive_chk"
CHECK ("priceVND" > 0);
ALTER TABLE "Listing"
ADD CONSTRAINT "listing_rent_price_monthly_positive_chk"
CHECK ("rentPriceMonthly" IS NULL OR "rentPriceMonthly" > 0);
ALTER TABLE "Listing"
ADD CONSTRAINT "listing_price_per_m2_positive_chk"
CHECK ("pricePerM2" IS NULL OR "pricePerM2" > 0);
ALTER TABLE "Listing"
ADD CONSTRAINT "listing_ai_price_estimate_positive_chk"
CHECK ("aiPriceEstimate" IS NULL OR "aiPriceEstimate" > 0);
ALTER TABLE "PriceHistory"
ADD CONSTRAINT "price_history_old_price_positive_chk"
CHECK ("oldPrice" > 0);
ALTER TABLE "PriceHistory"
ADD CONSTRAINT "price_history_new_price_positive_chk"
CHECK ("newPrice" > 0);
-- Property defensive guard (only column with a monetary value):
ALTER TABLE "Property"
ADD CONSTRAINT "property_maintenance_fee_vnd_nonnegative_chk"
CHECK ("maintenanceFeeVND" IS NULL OR "maintenanceFeeVND" >= 0);

View File

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "ModerationAuditLog" (
"id" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"moderatorId" TEXT NOT NULL,
"reason" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ModerationAuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ModerationAuditLog_targetType_targetId_idx" ON "ModerationAuditLog"("targetType", "targetId");
-- CreateIndex
CREATE INDEX "ModerationAuditLog_moderatorId_createdAt_idx" ON "ModerationAuditLog"("moderatorId", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "ModerationAuditLog_action_createdAt_idx" ON "ModerationAuditLog"("action", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "ModerationAuditLog_createdAt_idx" ON "ModerationAuditLog"("createdAt");

View File

@@ -0,0 +1,46 @@
-- TEC-2932: add a normalized-address column for duplicate detection
--
-- Strategy:
-- * Vietnamese-aware normalization done in application code (Address VO),
-- so the column is plain TEXT and is populated by the API layer.
-- * Backfill existing rows with an in-DB approximation
-- (LOWER + UNACCENT + collapse whitespace + trim). This keeps the
-- migration self-contained without requiring a TS backfill script.
-- * BTree index on (agentId-aware lookups via Listing) so equality
-- filtering is cheap. Address normalization is also valuable for
-- analytics/admin so we index the column itself.
--
-- The column is added nullable so the migration can be applied to a
-- production database without blocking writes; new inserts coming through
-- the API layer always populate the value.
CREATE EXTENSION IF NOT EXISTS "unaccent";
ALTER TABLE "Property"
ADD COLUMN IF NOT EXISTS "addressNormalized" TEXT;
-- Backfill existing rows. The expression mirrors the application-level
-- Address.normalize() rule:
-- 1. concatenate `address, ward, district, city`
-- 2. unaccent (strip Vietnamese diacritics)
-- 3. lowercase
-- 4. replace any non-alphanumeric run with a single space
-- 5. collapse whitespace and trim
UPDATE "Property"
SET "addressNormalized" = TRIM(
REGEXP_REPLACE(
LOWER(
UNACCENT(
CONCAT_WS(', ', "address", "ward", "district", "city")
)
),
'[^a-z0-9]+',
' ',
'g'
)
)
WHERE "addressNormalized" IS NULL;
-- Equality lookup index (used by duplicate detector + admin endpoint).
CREATE INDEX IF NOT EXISTS "Property_addressNormalized_idx"
ON "Property" ("addressNormalized");

View File

@@ -276,6 +276,10 @@ model Property {
ward String
district String
city String
/// Lower-cased, unaccented, whitespace-collapsed concatenation of
/// address/ward/district/city. Used for duplicate detection (TEC-2932).
/// Nullable until the backfill migration covers historic rows.
addressNormalized String?
location Unsupported("geometry(Point, 4326)")
areaM2 Float
usableAreaM2 Float?
@@ -296,6 +300,7 @@ model Property {
furnishing Furnishing?
propertyCondition PropertyCondition?
balconyDirection Direction?
// CHECK ("maintenanceFeeVND" IS NULL OR "maintenanceFeeVND" >= 0)
maintenanceFeeVND BigInt?
parkingSlots Int?
viewType String[] @default([])
@@ -317,6 +322,7 @@ model Property {
// --- Compound indexes (query optimization) ---
@@index([district, propertyType])
@@index([district, city, propertyType])
@@index([addressNormalized])
}
model PropertyMedia {
@@ -343,10 +349,14 @@ model Listing {
seller User @relation(fields: [sellerId], references: [id], onDelete: Restrict)
transactionType TransactionType
status ListingStatus @default(DRAFT)
// CHECK ("priceVND" > 0) — see migration 20260420000000_add_price_check_constraints
priceVND BigInt
// CHECK ("pricePerM2" IS NULL OR "pricePerM2" > 0)
pricePerM2 Float?
// CHECK ("rentPriceMonthly" IS NULL OR "rentPriceMonthly" > 0)
rentPriceMonthly BigInt?
commissionPct Float? @default(2.0)
// CHECK ("aiPriceEstimate" IS NULL OR "aiPriceEstimate" > 0)
aiPriceEstimate BigInt?
aiConfidence Float?
moderationScore Float?
@@ -390,7 +400,9 @@ model PriceHistory {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
// CHECK ("oldPrice" > 0) — see migration 20260420000000_add_price_check_constraints
oldPrice BigInt
// CHECK ("newPrice" > 0)
newPrice BigInt
source String @default("manual_update")
changedAt DateTime @default(now())
@@ -841,6 +853,28 @@ model AdminAuditLog {
@@index([action, createdAt(sort: Desc)])
}
// Free-form moderation audit log capturing every approve/reject/edit/flag action
// performed by moderators on listings, properties, inquiries and other targets.
// Strings (not enums) are used for `targetType` and `action` so that adding new
// moderation surfaces does not require an enum migration. Existing AdminAuditLog
// stays as-is for the admin-action timeline; this table is the moderator-centric
// view used by TEC-2926.
model ModerationAuditLog {
id String @id @default(uuid())
targetType String
targetId String
action String
moderatorId String
reason String?
metadata Json?
createdAt DateTime @default(now())
@@index([targetType, targetId])
@@index([moderatorId, createdAt(sort: Desc)])
@@index([action, createdAt(sort: Desc)])
@@index([createdAt])
}
// =============================================================================
// NEIGHBORHOOD & POI
// =============================================================================