Generated from prisma/schema.prisma (41 models, 37 enums): - docs/db/ERD.md: Mermaid ERD + domain map - docs/db/schema-audit.md: per-model findings with severity + 10 cross-cutting findings Co-Authored-By: Paperclip <noreply@paperclip.ing>
14 KiB
14 KiB
GoodGo Platform — Prisma Schema Audit
Companion to docs/db/ERD.md. Generated for TEC-3010 (Epic 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]
emailis nullable and not unique, whileemailHashcarries the uniqueness. Application code must hash every lookup; add a DB-level CHECK that(email IS NULL) = (emailHash IS NULL)to prevent divergence. - [High]
phoneis stored plaintext ANDphoneHashis unique — duplicate-safe, but plaintext phone should be flagged for PII encryption at rest (roadmap: TDE or app-layer envelope). - [Medium]
totpSecretcomment says "Encrypted TOTP secret" but schema has no marker to enforce that; add a Prisma-level/// ENCRYPTEDdoc comment convention and a lint. - [Medium]
totpBackupCodes String[]— array mutation races: when a backup code is consumed, an atomicarray_removeis needed at the SQL layer; document.
2. MfaChallenge — Low
- [Low]
typeis a free-form string ("totp" | "backup_code"). Convert to enum.
3. RefreshToken — Medium
- [Medium]
tokenis@unique String— should be stored as SHA-256 hash rather than raw token to mitigate DB leak. Add note or rename field totokenHash.
4. OAuthAccount — Medium
- [Medium]
accessToken/refreshTokenstored plaintext — must be encrypted at rest; same encryption story asUser.totpSecret. - [Low]
expiresAtisDateTime?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@@indexviaginon the JSONB would help, or normalize into a join tableAgentServiceArea. - [Low]
qualityScore Float @default(0)— no CHECK0 ≤ qualityScore ≤ 100.
6. ProjectDevelopment — Medium
- [Medium] Denormalized location columns (
ward,district,city) lack a FK to a canonicalDistrict/Wardtable. When canonical admin-region data ships, schedule a normalization pass. - [Low]
minPrice/maxPricehave no CHECK constraint; enforcemaxPrice >= minPriceandminPrice >= 0.
7. Property — Medium
- [Medium]
addressNormalizedis 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— CHECKareaM2 > 0missing.
8. PropertyMedia — Low
- [Low]
type Stringwith literal"image" | "video"— convert to enum.
9. Listing — Medium
- [Medium]
sellerId onDelete: RestrictbutagentId onDelete: SetNull— inconsistent. User deletion flow will fail on any listing. - [Low] Many CHECKs added in recent migration (see inline comments) — good; keep
pricePerM2derivation consistent withpriceVND / property.areaM2.
10. PriceHistory — Low
- [Low] No
agentId/actorId— cannot attribute a price change.
11. SavedSearch — Low
- [Low]
filters Jsonis 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
sellerIddirect FK — must resolve throughlisting.sellerId. Denormalize for reporting.
14. Inquiry — Medium
- [Medium]
phoneplaintext PII — hash or encrypt at rest. - [Low] No
statusfield (read/responded/closed) — relied onisReadonly.
15. Lead — Medium
- [Medium]
phoneplaintext +phoneHashoptional — make hash NOT NULL to matchUserpattern. - [Low]
source Stringfree-form; convert to enum (referral | organic | paid | imported).
16. Payment — High
- [High]
idempotencyKeyis optional on a unique composite(userId, provider, idempotencyKey)—NULLis 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 BigInthas no CHECKamountVND > 0.
17. Order — Medium
- [Medium]
amountVND = platformFeeVND + sellerPayoutVNDinvariant is not enforced at DB; add trigger or CHECK. - [Medium]
Restricton buyer/seller/listing blocks cascading user deletes.
18. Escrow — Medium
- [Medium]
statustransitions (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
BigIntwith no CHECK ≥ 0.
20. Subscription — Medium
- [Medium]
@@uniquemissing on(userId)viauserId String @unique— already ✓, but no index oncurrentPeriodEndfor renewal sweeps.
21. UsageRecord — Medium
- [Medium]
metric String— convert to enum aligned withPlan.max*fields; otherwise drift between code/DB is invisible. - [Low] Missing uniqueness on
(subscriptionId, metric, periodStart)— duplicate counters possible.
22. Valuation — Low
- [Low]
comparables Jsonunbounded — cap size, or move to S3.
23. MarketIndex — Low
- [Low]
period Stringfree-form (e.g., "2025-Q4") — enum or regex CHECK.
24. NotificationLog — Medium
- [Medium]
userIdhas no FK (no@relation) — silent referential drift. - [Medium] Missing partitioning strategy; this table will grow fast — add TimescaleDB or partition by
createdAtquarter.
25. NotificationPreference — Low
- [Low]
eventType Stringfree-form; enum or CHECK.
26. AdminAuditLog — Medium
- [Medium] Polymorphic
targetId+targetTypelacks 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 usescuid()— pick one convention.
28. POI — Low
- [Low]
osmId @uniquenullable — two rows with nullosmIdwill co-exist but that is desired for non-OSM POIs.
29. NeighborhoodScore — Low
- [Low] Each score field should have CHECK
0 ≤ value ≤ 10,totalScore0..100.
30. Review — High
- [High] Polymorphic
targetType+targetIdwithout 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— CHECKrating BETWEEN 1 AND 5. - [Medium] No
moderationStatus/hiddenAt— operational gap for abuse response.
31. IndustrialPark — Low
- [Low]
occupancyRate Floatneeds CHECK0..100;remainingAreaHashould be derivable fromleasableAreaHa - occupied— stored-derived risk.
32. IndustrialListing — High
- [High] Missing
@relationforagentId→Agent.idandsellerId→User.id. No FK = no cascade, no referential integrity. - [Medium] No
priceVND— onlypriceUsdM2; cross-currency reporting requires a rate snapshot column (fxRateVndPerUsd). - [Medium]
pricingUnit String— convert to enum.
33. Conversation — Medium
- [Medium]
listingIdhas no@relation— can reference deleted listing. - [Low]
subjectshould probably be required whenlistingIdis null (CHECK).
34. ConversationParticipant — Medium
- [Medium]
userIdlacks@relationtoUser— orphan participants possible.
35. Message — Medium
- [Medium]
senderIdno@relationtoUser. - [Low] Soft-delete
deletedAtshould be paired with an index for "exclude deleted" queries.
36. TransferListing — Medium
- [Medium]
contactPhone,contactNameplaintext — PII policy alignment withUser.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 haspdfUrl).
39. MacroeconomicData — Low
- [Low]
indicator String+unit Stringfree-form — enum forindicatorat minimum.
40. InfrastructureProject — Medium
- [Medium]
category String/status Stringshould both be enums to align withIndustrialPark. - [Low]
impactRadius Floatneeds CHECK ≥ 0.
41. SystemSetting — High
- [High] Inline schema comment admits values are plaintext and
isSecretnot 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 toUser.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`. |
| 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)
- ERD generated from
prisma/schema.prisma docs/db/ERD.mdcommitted (Mermaid + rendering notes)docs/db/schema-audit.mdcommitted with findings + severity per model (41 models)- Cross-cutting findings documented
Recommended follow-ups (subtasks under TEC-3006)
- Enforce
(userId, provider, idempotencyKey)uniqueness onPayment(X-4). - Add missing
@relationFKs (X-2) — single migration, data-checked. - Enum migration sweep (X-7).
- PII/secret encryption story (X-3) — RFC + migration.
- Retention/partitioning plan for append-only tables (X-8).
- Polymorphic target strategy decision (X-1).