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,197 @@
/**
* Prisma query-extension that transparently encrypts PII fields on write and
* decrypts them on read. Works via Prisma `$extends({ query: … })`.
*
* Design principles:
* - Zero changes required in business logic / repositories
* - Searchable fields also get a `{field}Hash` written on create/update
* - Decryption is applied to all query results automatically
* - Non-encrypted (plaintext) values pass through unchanged — safe for
* incremental migration
*/
import { Prisma } from '@prisma/client';
import {
type FieldEncryptionService,
type ModelEncryptionConfig,
type ModelEncryptionFieldConfig,
} from './field-encryption.service';
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function encryptDataObject(
data: Record<string, unknown>,
fields: ModelEncryptionFieldConfig[],
service: FieldEncryptionService,
): void {
const encryptedFields: string[] = [];
for (const fieldConfig of fields) {
const value = data[fieldConfig.field];
if (value === undefined || value === null) continue;
// Skip if already encrypted (idempotent)
if (service.isAlreadyEncrypted(value)) continue;
// Compute deterministic hash for searchable fields BEFORE encryption
if (fieldConfig.searchable && typeof value === 'string') {
data[`${fieldConfig.field}Hash`] = service.computeHash(value);
}
data[fieldConfig.field] = service.encrypt(value);
encryptedFields.push(fieldConfig.field);
}
if (encryptedFields.length > 0) {
service.logAccess('encrypt', 'write', encryptedFields);
}
}
function decryptRow(
row: Record<string, unknown>,
fields: ModelEncryptionFieldConfig[],
service: FieldEncryptionService,
): void {
const decryptedFields: string[] = [];
for (const fieldConfig of fields) {
const value = row[fieldConfig.field];
if (value === undefined || value === null) continue;
if (service.isAlreadyEncrypted(value)) {
row[fieldConfig.field] = service.decrypt(value);
decryptedFields.push(fieldConfig.field);
}
}
if (decryptedFields.length > 0) {
service.logAccess('decrypt', 'read', decryptedFields);
}
}
function decryptResult(
result: unknown,
config: ModelEncryptionConfig,
service: FieldEncryptionService,
): void {
if (Array.isArray(result)) {
for (const item of result) {
if (typeof item === 'object' && item !== null) {
decryptRow(item as Record<string, unknown>, config.fields, service);
}
}
} else if (typeof result === 'object' && result !== null) {
decryptRow(result as Record<string, unknown>, config.fields, service);
}
}
// ---------------------------------------------------------------------------
// Write-args encryption
// ---------------------------------------------------------------------------
function encryptWriteArgs(
args: Record<string, unknown>,
action: string,
config: ModelEncryptionConfig,
service: FieldEncryptionService,
): void {
if (action === 'createMany' || action === 'createManyAndReturn') {
const data = args['data'];
if (Array.isArray(data)) {
for (const row of data) {
encryptDataObject(row as Record<string, unknown>, config.fields, service);
}
}
return;
}
if (action === 'upsert') {
const create = args['create'] as Record<string, unknown> | undefined;
const update = args['update'] as Record<string, unknown> | undefined;
if (create) encryptDataObject(create, config.fields, service);
if (update) encryptDataObject(update, config.fields, service);
return;
}
// create, update, updateMany — args.data
const data = args['data'] as Record<string, unknown> | undefined;
if (data) {
encryptDataObject(data, config.fields, service);
}
}
// Prisma actions that write data
const WRITE_ACTIONS = new Set([
'create',
'createMany',
'createManyAndReturn',
'update',
'updateMany',
'upsert',
]);
// Prisma actions whose results we should decrypt
const READ_ACTIONS = new Set([
'findUnique',
'findUniqueOrThrow',
'findFirst',
'findFirstOrThrow',
'findMany',
'create',
'createManyAndReturn',
'update',
'upsert',
'delete',
]);
// ---------------------------------------------------------------------------
// Public: create the Prisma extension
// ---------------------------------------------------------------------------
/**
* Creates a Prisma query extension for field-level encryption.
*
* Usage inside PrismaService:
* ```ts
* const extended = prisma.$extends(createEncryptionExtension(service));
* ```
*/
export function createEncryptionExtension(service: FieldEncryptionService) {
// Build a fast lookup: lowercase model name → config
const modelLookup = new Map<string, ModelEncryptionConfig>();
for (const config of service.getFieldMap()) {
modelLookup.set(config.model.toLowerCase(), config);
}
return Prisma.defineExtension({
name: 'field-encryption',
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
// Look up encryption config for this model
const config = model ? modelLookup.get(model.toLowerCase()) : undefined;
if (!config || !service.isEnabled()) {
return query(args);
}
// Encrypt on write
if (WRITE_ACTIONS.has(operation) && args) {
encryptWriteArgs(args as Record<string, unknown>, operation, config, service);
}
const result = await query(args);
// Decrypt on read
if (READ_ACTIONS.has(operation) && result !== null && result !== undefined) {
decryptResult(result, config, service);
}
return result;
},
},
},
});
}