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:
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user