# GoodGo Platform — Prisma Schema Audit

> Companion to [docs/db/ERD.md](./ERD.md). Generated for [TEC-3010](/TEC/issues/TEC-3010) (Epic [TEC-3006](/TEC/issues/TEC-3006)).

Source: `prisma/schema.prisma` (1408 lines, 41 models, 37 enums) as of 2026-04-21.

## Severity legend

| Level | Meaning |
|---|---|
| **High** | Data-integrity, PII, or auth/billing correctness risk. Fix before next prod migration. |
| **Medium** | Query-perf, referential-integrity, or cleanup risk. Schedule within current epic. |
| **Low** | Cosmetic, consistency, or doc-only; track as follow-up. |

## Summary by model

| # | Model | # Findings | Max severity |
|---|---|---|---|
| 1 | User | 4 | High |
| 2 | MfaChallenge | 1 | Low |
| 3 | RefreshToken | 1 | Medium |
| 4 | OAuthAccount | 2 | Medium |
| 5 | Agent | 2 | Medium |
| 6 | ProjectDevelopment | 2 | Medium |
| 7 | Property | 3 | Medium |
| 8 | PropertyMedia | 1 | Low |
| 9 | Listing | 2 | Medium |
| 10 | PriceHistory | 1 | Low |
| 11 | SavedSearch | 1 | Low |
| 12 | SavedListing | 0 | — |
| 13 | Transaction | 2 | Medium |
| 14 | Inquiry | 2 | Medium |
| 15 | Lead | 2 | Medium |
| 16 | Payment | 2 | High |
| 17 | Order | 2 | Medium |
| 18 | Escrow | 1 | Medium |
| 19 | Plan | 1 | Low |
| 20 | Subscription | 1 | Medium |
| 21 | UsageRecord | 2 | Medium |
| 22 | Valuation | 1 | Low |
| 23 | MarketIndex | 1 | Low |
| 24 | NotificationLog | 2 | Medium |
| 25 | NotificationPreference | 1 | Low |
| 26 | AdminAuditLog | 1 | Medium |
| 27 | ModerationAuditLog | 2 | Medium |
| 28 | POI | 1 | Low |
| 29 | NeighborhoodScore | 1 | Low |
| 30 | Review | 3 | High |
| 31 | IndustrialPark | 1 | Low |
| 32 | IndustrialListing | 3 | High |
| 33 | Conversation | 2 | Medium |
| 34 | ConversationParticipant | 1 | Medium |
| 35 | Message | 2 | Medium |
| 36 | TransferListing | 2 | Medium |
| 37 | TransferItem | 1 | Low |
| 38 | Report | 1 | Low |
| 39 | MacroeconomicData | 1 | Low |
| 40 | InfrastructureProject | 2 | Medium |
| 41 | SystemSetting | 2 | High |

## Findings

### 1. User — High

- **[High]** `email` is nullable and not unique, while `emailHash` carries the uniqueness. Application code must hash every lookup; add a DB-level CHECK that `(email IS NULL) = (emailHash IS NULL)` to prevent divergence.
- **[High]** `phone` is stored plaintext AND `phoneHash` is unique — duplicate-safe, but plaintext phone should be flagged for PII encryption at rest (roadmap: TDE or app-layer envelope).
- **[Medium]** `totpSecret` comment says "Encrypted TOTP secret" but schema has no marker to enforce that; add a Prisma-level `/// ENCRYPTED` doc comment convention and a lint.
- **[Medium]** `totpBackupCodes String[]` — array mutation races: when a backup code is consumed, an atomic `array_remove` is needed at the SQL layer; document.

### 2. MfaChallenge — Low

- **[Low]** `type` is a free-form string (`"totp" | "backup_code"`). Convert to enum.

### 3. RefreshToken — Medium

- **[Medium]** `token` is `@unique String` — should be stored as SHA-256 hash rather than raw token to mitigate DB leak. Add note or rename field to `tokenHash`.

### 4. OAuthAccount — Medium

- **[Medium]** `accessToken` / `refreshToken` stored plaintext — must be encrypted at rest; same encryption story as `User.totpSecret`.
- **[Low]** `expiresAt` is `DateTime?` but no index; a sweeper job that deletes expired provider tokens will do a seq scan.

### 5. Agent — Medium

- **[Medium]** `serviceAreas Json` — lose searchability. A `@@index` via `gin` on the JSONB would help, or normalize into a join table `AgentServiceArea`.
- **[Low]** `qualityScore Float @default(0)` — no CHECK `0 ≤ qualityScore ≤ 100`.

### 6. ProjectDevelopment — Medium

- **[Medium]** Denormalized location columns (`ward`, `district`, `city`) lack a FK to a canonical `District` / `Ward` table. When canonical admin-region data ships, schedule a normalization pass.
- **[Low]** `minPrice`/`maxPrice` have no CHECK constraint; enforce `maxPrice >= minPrice` and `minPrice >= 0`.

### 7. Property — Medium

- **[Medium]** `addressNormalized` is nullable while a comment notes a backfill pending; track in follow-up issue and add `@@index([addressNormalized])` exists ✓ but partial index excluding NULL would save space.
- **[Medium]** `viewType String[]`, `suitableFor String[]` are unbounded — consider enums + join table to allow facet search.
- **[Low]** `areaM2 Float` — CHECK `areaM2 > 0` missing.

### 8. PropertyMedia — Low

- **[Low]** `type String` with literal `"image" | "video"` — convert to enum.

### 9. Listing — Medium

- **[Medium]** `sellerId onDelete: Restrict` but `agentId onDelete: SetNull` — inconsistent. User deletion flow will fail on any listing.
- **[Low]** Many CHECKs added in recent migration (see inline comments) — good; keep `pricePerM2` derivation consistent with `priceVND / property.areaM2`.

### 10. PriceHistory — Low

- **[Low]** No `agentId` / `actorId` — cannot attribute a price change.

### 11. SavedSearch — Low

- **[Low]** `filters Json` is unbounded; no schema-level validation.

### 12. SavedListing

- No findings.

### 13. Transaction — Medium

- **[Medium]** `buyerId onDelete: Restrict` — user-deletion workflow must move or anonymize.
- **[Medium]** Missing `sellerId` direct FK — must resolve through `listing.sellerId`. Denormalize for reporting.

### 14. Inquiry — Medium

- **[Medium]** `phone` plaintext PII — hash or encrypt at rest.
- **[Low]** No `status` field (read/responded/closed) — relied on `isRead` only.

### 15. Lead — Medium

- **[Medium]** `phone` plaintext + `phoneHash` optional — make hash NOT NULL to match `User` pattern.
- **[Low]** `source String` free-form; convert to enum (`referral | organic | paid | imported`).

### 16. Payment — High

- **[High]** `idempotencyKey` is optional on a unique composite `(userId, provider, idempotencyKey)` — `NULL` is distinct in PG, so duplicate payments with NULL key are still possible. Enforce NOT NULL or fall back to a server-generated UUID.
- **[Medium]** `amountVND BigInt` has no CHECK `amountVND > 0`.

### 17. Order — Medium

- **[Medium]** `amountVND = platformFeeVND + sellerPayoutVND` invariant is not enforced at DB; add trigger or CHECK.
- **[Medium]** `Restrict` on buyer/seller/listing blocks cascading user deletes.

### 18. Escrow — Medium

- **[Medium]** `status` transitions (PENDING → HELD → RELEASED/REFUNDED/DISPUTED) are not enforced; add a state-machine trigger or rely on an app-layer guard.

### 19. Plan — Low

- **[Low]** Price fields `BigInt` with no CHECK ≥ 0.

### 20. Subscription — Medium

- **[Medium]** `@@unique` missing on `(userId)` via `userId String @unique` — already ✓, but no index on `currentPeriodEnd` for renewal sweeps.

### 21. UsageRecord — Medium

- **[Medium]** `metric String` — convert to enum aligned with `Plan.max*` fields; otherwise drift between code/DB is invisible.
- **[Low]** Missing uniqueness on `(subscriptionId, metric, periodStart)` — duplicate counters possible.

### 22. Valuation — Low

- **[Low]** `comparables Json` unbounded — cap size, or move to S3.

### 23. MarketIndex — Low

- **[Low]** `period String` free-form (e.g., "2025-Q4") — enum or regex CHECK.

### 24. NotificationLog — Medium

- **[Medium]** `userId` has no FK (no `@relation`) — silent referential drift.
- **[Medium]** Missing partitioning strategy; this table will grow fast — add TimescaleDB or partition by `createdAt` quarter.

### 25. NotificationPreference — Low

- **[Low]** `eventType String` free-form; enum or CHECK.

### 26. AdminAuditLog — Medium

- **[Medium]** Polymorphic `targetId` + `targetType` lacks FK. Acceptable in audit tables but document the tradeoff.

### 27. ModerationAuditLog — Medium

- **[Medium]** `targetType String` / `action String` — as-designed (to avoid enum migrations), but add a lint to reject unknown values on write.
- **[Medium]** `id String @default(uuid())` while sibling table uses `cuid()` — pick one convention.

### 28. POI — Low

- **[Low]** `osmId @unique` nullable — two rows with null `osmId` will co-exist but that is desired for non-OSM POIs.

### 29. NeighborhoodScore — Low

- **[Low]** Each score field should have CHECK `0 ≤ value ≤ 10`, `totalScore` `0..100`.

### 30. Review — High

- **[High]** Polymorphic `targetType` + `targetId` without FK — highest-volume polymorphic surface, will accumulate dangling references on entity delete. Plan: create per-target-type child tables or enforce with trigger.
- **[Medium]** `rating Int` — CHECK `rating BETWEEN 1 AND 5`.
- **[Medium]** No `moderationStatus` / `hiddenAt` — operational gap for abuse response.

### 31. IndustrialPark — Low

- **[Low]** `occupancyRate Float` needs CHECK `0..100`; `remainingAreaHa` should be derivable from `leasableAreaHa - occupied` — stored-derived risk.

### 32. IndustrialListing — High

- **[High]** Missing `@relation` for `agentId` → `Agent.id` and `sellerId` → `User.id`. No FK = no cascade, no referential integrity.
- **[Medium]** No `priceVND` — only `priceUsdM2`; cross-currency reporting requires a rate snapshot column (`fxRateVndPerUsd`).
- **[Medium]** `pricingUnit String` — convert to enum.

### 33. Conversation — Medium

- **[Medium]** `listingId` has no `@relation` — can reference deleted listing.
- **[Low]** `subject` should probably be required when `listingId` is null (CHECK).

### 34. ConversationParticipant — Medium

- **[Medium]** `userId` lacks `@relation` to `User` — orphan participants possible.

### 35. Message — Medium

- **[Medium]** `senderId` no `@relation` to `User`.
- **[Low]** Soft-delete `deletedAt` should be paired with an index for "exclude deleted" queries.

### 36. TransferListing — Medium

- **[Medium]** `contactPhone`, `contactName` plaintext — PII policy alignment with `User.phone`.
- **[Low]** `businessType String?` / `footTraffic String?` — enum candidates.

### 37. TransferItem — Low

- **[Low]** `dimensions Json` — consider splitting into typed columns (`widthCm Float?`, etc.) for range queries.

### 38. Report — Low

- **[Low]** `params Json` + `content Json` — large reports should live in object storage with a pointer column to keep row size small (already has `pdfUrl`).

### 39. MacroeconomicData — Low

- **[Low]** `indicator String` + `unit String` free-form — enum for `indicator` at minimum.

### 40. InfrastructureProject — Medium

- **[Medium]** `category String` / `status String` should both be enums to align with `IndustrialPark`.
- **[Low]** `impactRadius Float` needs CHECK ≥ 0.

### 41. SystemSetting — High

- **[High]** Inline schema comment admits values are plaintext and `isSecret` not actually encrypted — all AI provider credentials live here in plaintext. Escalate to a dedicated secret-store or add envelope encryption before shipping more secrets into this table.
- **[Medium]** `updatedBy String?` has no FK to `User.id`.

## Cross-cutting findings

| # | Finding | Severity |
|---|---|---|
| X-1 | **Polymorphic target patterns** (`Review`, `AdminAuditLog`, `ModerationAuditLog`) lack DB-level integrity. Standardize on either per-target tables or nightly reconciliation. | High |
| X-2 | **Missing `@relation` on foreign-key-shaped fields** in `NotificationLog`, `ConversationParticipant`, `Message`, `Conversation.listingId`, `IndustrialListing.sellerId|agentId`, `SystemSetting.updatedBy`. | High |
| X-3 | **Plaintext PII / secrets** — `User.phone`, `Inquiry.phone`, `Lead.phone`, `OAuthAccount.accessToken/refreshToken`, `User.totpSecret` (claimed encrypted), `TransferListing.contactPhone`, `SystemSetting.value`. | High |
| X-4 | **Money fields** (`BigInt`) missing CHECK `> 0` across: `Payment.amountVND`, `Order.amountVND/platformFeeVND/sellerPayoutVND`, `Escrow.amountVND/feeVND`, `Plan.price*`, `Transaction.agreedPrice/depositAmount`. Listing and PriceHistory already have CHECKs (recent migration). | Medium |
| X-5 | **Stored derived values** — `IndustrialPark.occupancyRate/remainingAreaHa`, `Property.pricePerM2` — risk drift. Add triggers or move to views. | Medium |
| X-6 | **ID conventions** mixed — 40 tables use `cuid()`, `ModerationAuditLog` uses `uuid()`. Pick one. | Low |
| X-7 | **Enum vs free-form string** — `MfaChallenge.type`, `PropertyMedia.type`, `Lead.source`, `UsageRecord.metric`, `NotificationPreference.eventType`, `MarketIndex.period`, `InfrastructureProject.category/status`, `IndustrialListing.pricingUnit`. | Medium |
| X-8 | **High-volume append tables** (`NotificationLog`, `AdminAuditLog`, `ModerationAuditLog`, `PriceHistory`) have no partitioning / retention plan. | Medium |
| X-9 | **`onDelete: Restrict` on commerce edges** — `Listing.sellerId`, `Transaction.buyerId`, `Order.buyerId/sellerId/listingId`, `Payment.userId`. User-deletion workflow (`User.deletedAt`) cannot hard-delete; document as soft-delete-only. | Medium |
| X-10 | **Geospatial consistency** — all six geo models use `geometry(Point, 4326)` + GiST ✓. Consider SRID check constraint. | Low |

## Acceptance checklist (TEC-3010)

- [x] ERD generated from `prisma/schema.prisma`
- [x] `docs/db/ERD.md` committed (Mermaid + rendering notes)
- [x] `docs/db/schema-audit.md` committed with findings + severity per model (41 models)
- [x] Cross-cutting findings documented

## Recommended follow-ups (subtasks under [TEC-3006](/TEC/issues/TEC-3006))

1. Enforce `(userId, provider, idempotencyKey)` uniqueness on `Payment` (X-4).
2. Add missing `@relation` FKs (X-2) — single migration, data-checked.
3. Enum migration sweep (X-7).
4. PII/secret encryption story (X-3) — RFC + migration.
5. Retention/partitioning plan for append-only tables (X-8).
6. Polymorphic target strategy decision (X-1).
