feat: add MFA/TOTP auth, PII encryption, agents/leads/inquiries modules, and comprehensive tests

- Add TOTP-based MFA with setup, verify, disable, backup codes, and challenge flow
- Add PII field encryption middleware with AES-256-GCM and deterministic search hashes
- Add agents, inquiries, and leads domain modules with entities, events, value objects
- Add web dashboard pages for inquiries and leads with detail dialogs
- Add 30+ component tests (valuation, charts, listings, search, providers, UI)
- Add Prisma migrations for encryption hash columns and MFA TOTP support
- Fix all ESLint errors (unused imports, duplicate imports, lint auto-fixes)
- Update dependencies and lock file
- Clean up obsolete exploration/QA docs, add audit documentation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-11 23:43:20 +07:00
parent 9e2bf9a4b5
commit 1fbe2f4e73
131 changed files with 11436 additions and 2595 deletions

View File

@@ -0,0 +1,34 @@
-- =============================================================================
-- Migration: Add hash columns for field-level PII encryption
--
-- This migration adds deterministic HMAC-SHA256 hash columns alongside
-- encrypted PII fields to support indexed lookups (WHERE / UNIQUE).
--
-- The original email/phone columns will hold encrypted ciphertext after
-- the backfill script runs. Uniqueness shifts to the hash columns.
-- =============================================================================
-- ---------- User ----------
-- Drop existing unique constraints on plaintext columns
-- These will be replaced by unique constraints on hash columns
ALTER TABLE "User" DROP CONSTRAINT IF EXISTS "User_email_key";
ALTER TABLE "User" DROP CONSTRAINT IF EXISTS "User_phone_key";
-- Add hash columns
ALTER TABLE "User" ADD COLUMN "emailHash" TEXT;
ALTER TABLE "User" ADD COLUMN "phoneHash" TEXT;
-- Unique indexes on hash columns (replace the old plaintext unique constraints)
CREATE UNIQUE INDEX "User_emailHash_key" ON "User"("emailHash");
CREATE UNIQUE INDEX "User_phoneHash_key" ON "User"("phoneHash");
-- ---------- Lead ----------
-- Add hash columns
ALTER TABLE "Lead" ADD COLUMN "phoneHash" TEXT;
ALTER TABLE "Lead" ADD COLUMN "emailHash" TEXT;
-- Non-unique indexes for Lead hash columns (leads can share phone/email)
CREATE INDEX "Lead_phoneHash_idx" ON "Lead"("phoneHash");
CREATE INDEX "Lead_emailHash_idx" ON "Lead"("emailHash");

View File

@@ -0,0 +1,29 @@
-- AddMfaTotpSupport
-- Add TOTP-based MFA fields to User model and MfaChallenge table
-- Add MFA columns to User
ALTER TABLE "User" ADD COLUMN "totpSecret" TEXT;
ALTER TABLE "User" ADD COLUMN "totpEnabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "User" ADD COLUMN "totpBackupCodes" TEXT[] DEFAULT ARRAY[]::TEXT[];
ALTER TABLE "User" ADD COLUMN "totpEnabledAt" TIMESTAMP(3);
-- Create MfaChallenge table for login verification flow
CREATE TABLE "MfaChallenge" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"attemptCount" INTEGER NOT NULL DEFAULT 0,
"maxAttempts" INTEGER NOT NULL DEFAULT 5,
"isVerified" BOOLEAN NOT NULL DEFAULT false,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MfaChallenge_pkey" PRIMARY KEY ("id")
);
-- Indexes
CREATE INDEX "MfaChallenge_userId_expiresAt_idx" ON "MfaChallenge"("userId", "expiresAt");
CREATE INDEX "MfaChallenge_expiresAt_idx" ON "MfaChallenge"("expiresAt");
-- Foreign key
ALTER TABLE "MfaChallenge" ADD CONSTRAINT "MfaChallenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,16 +1,16 @@
import path from 'node:path';
import { defineConfig } from 'prisma/config';
// Use DATABASE_URL_DIRECT (bypasses PgBouncer) for migrations/introspection
// when available; fall back to DATABASE_URL for local dev without PgBouncer.
const databaseUrl =
process.env.DATABASE_URL_DIRECT || process.env.DATABASE_URL!;
export default defineConfig({
earlyAccess: true,
schema: path.join(__dirname, 'schema.prisma'),
migrate: {
async development() {
// Use DATABASE_URL_DIRECT (bypasses PgBouncer) for migrations/introspection
// when available; fall back to DATABASE_URL for local dev without PgBouncer.
return {
url: process.env.DATABASE_URL_DIRECT || process.env.DATABASE_URL!,
};
},
// Prisma 7 requires datasource.url for db push, migrate deploy, etc.
datasource: {
url: databaseUrl,
},
});

View File

@@ -33,8 +33,10 @@ enum KYCStatus {
model User {
id String @id @default(cuid())
email String? @unique
phone String @unique
email String?
emailHash String? @unique
phone String
phoneHash String? @unique
passwordHash String?
fullName String
avatarUrl String?
@@ -47,6 +49,12 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// MFA fields
totpSecret String? // Encrypted TOTP secret
totpEnabled Boolean @default(false)
totpBackupCodes String[] // Bcrypt-hashed backup codes
totpEnabledAt DateTime?
agent Agent?
listings Listing[]
savedSearches SavedSearch[]
@@ -57,6 +65,7 @@ model User {
refreshTokens RefreshToken[]
oauthAccounts OAuthAccount[]
buyerTransactions Transaction[] @relation("BuyerTransactions")
mfaChallenges MfaChallenge[]
@@index([role])
@@index([kycStatus])
@@ -69,6 +78,21 @@ model User {
@@index([kycStatus, createdAt])
}
model MfaChallenge {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // "totp" | "backup_code"
attemptCount Int @default(0)
maxAttempts Int @default(5)
isVerified Boolean @default(false)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId, expiresAt])
@@index([expiresAt])
}
enum OAuthProvider {
GOOGLE
ZALO
@@ -355,7 +379,9 @@ model Lead {
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
name String
phone String
phoneHash String?
email String?
emailHash String?
source String
score Float?
notes Json?
@@ -365,6 +391,8 @@ model Lead {
@@index([agentId])
@@index([status])
@@index([phoneHash])
@@index([emailHash])
}
// =============================================================================