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,3 @@
export { RecalculateQualityScoreCommand } from './commands/recalculate-quality-score/recalculate-quality-score.command';
export { GetAgentDashboardQuery } from './queries/get-agent-dashboard/get-agent-dashboard.query';
export { GetAgentPublicProfileQuery } from './queries/get-agent-public-profile/get-agent-public-profile.query';

View File

@@ -0,0 +1,61 @@
import { AggregateRoot } from '@modules/shared';
import { QualityScoreUpdatedEvent } from '../events/quality-score-updated.event';
import { type QualityScore } from '../value-objects/quality-score.vo';
export interface AgentProps {
userId: string;
licenseNumber: string | null;
agency: string | null;
qualityScore: QualityScore;
totalDeals: number;
responseTimeAvg: number | null;
bio: string | null;
serviceAreas: string[];
isVerified: boolean;
}
export class AgentEntity extends AggregateRoot<string> {
private _userId: string;
private _licenseNumber: string | null;
private _agency: string | null;
private _qualityScore: QualityScore;
private _totalDeals: number;
private _responseTimeAvg: number | null;
private _bio: string | null;
private _serviceAreas: string[];
private _isVerified: boolean;
constructor(id: string, props: AgentProps, createdAt?: Date, updatedAt?: Date) {
super(id, createdAt);
if (updatedAt) this.updatedAt = updatedAt;
this._userId = props.userId;
this._licenseNumber = props.licenseNumber;
this._agency = props.agency;
this._qualityScore = props.qualityScore;
this._totalDeals = props.totalDeals;
this._responseTimeAvg = props.responseTimeAvg;
this._bio = props.bio;
this._serviceAreas = props.serviceAreas;
this._isVerified = props.isVerified;
}
get userId(): string { return this._userId; }
get licenseNumber(): string | null { return this._licenseNumber; }
get agency(): string | null { return this._agency; }
get qualityScore(): QualityScore { return this._qualityScore; }
get totalDeals(): number { return this._totalDeals; }
get responseTimeAvg(): number | null { return this._responseTimeAvg; }
get bio(): string | null { return this._bio; }
get serviceAreas(): string[] { return this._serviceAreas; }
get isVerified(): boolean { return this._isVerified; }
updateQualityScore(newScore: QualityScore): void {
const oldScore = this._qualityScore.value;
this._qualityScore = newScore;
this.updatedAt = new Date();
this.addDomainEvent(
new QualityScoreUpdatedEvent(this.id, oldScore, newScore.value),
);
}
}

View File

@@ -0,0 +1 @@
export { AgentEntity, type AgentProps } from './agent.entity';

View File

@@ -0,0 +1 @@
export { QualityScoreUpdatedEvent } from './quality-score-updated.event';

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class QualityScoreUpdatedEvent implements DomainEvent {
readonly eventName = 'agent.quality_score_updated';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly oldScore: number,
public readonly newScore: number,
) {}
}

View File

@@ -0,0 +1,5 @@
export * from './entities';
export * from './value-objects';
export * from './events';
export * from './repositories';
export { QualityScoreCalculator } from './services/quality-score.service';

View File

@@ -0,0 +1,8 @@
export {
AGENT_REPOSITORY,
type IAgentRepository,
type AgentDashboardData,
type AgentPublicProfileData,
type AgentPublicListingItem,
type QualityScoreInputData,
} from './agent.repository';

View File

@@ -0,0 +1 @@
export { QualityScore } from './quality-score.vo';

View File

@@ -0,0 +1,22 @@
import { Result, ValueObject } from '@modules/shared';
interface QualityScoreProps {
value: number;
}
export class QualityScore extends ValueObject<QualityScoreProps> {
get value(): number { return this.props.value; }
static create(value: number): Result<QualityScore, string> {
if (value < 0 || value > 100) {
return Result.err('Điểm chất lượng phải từ 0 đến 100');
}
const rounded = Math.round(value * 10) / 10; // 1 decimal place
return Result.ok(new QualityScore({ value: rounded }));
}
/** Create from a raw database value (trusted, no validation). */
static fromPersistence(value: number): QualityScore {
return new QualityScore({ value });
}
}

View File

@@ -0,0 +1,373 @@
import { AgentEntity } from '../../domain/entities/agent.entity';
import { QualityScore } from '../../domain/value-objects/quality-score.vo';
import { PrismaAgentRepository } from '../repositories/prisma-agent.repository';
describe('PrismaAgentRepository', () => {
let repository: PrismaAgentRepository;
let mockPrisma: {
agent: {
findUnique: ReturnType<typeof vi.fn>;
findUniqueOrThrow: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
lead: {
groupBy: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
inquiry: {
count: ReturnType<typeof vi.fn>;
};
listing: {
count: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
};
review: {
aggregate: ReturnType<typeof vi.fn>;
};
};
const agentRow = {
id: 'agent-1',
userId: 'user-1',
licenseNumber: 'BDS-001',
agency: 'Công ty BĐS',
qualityScore: 85,
totalDeals: 12,
responseTimeAvg: 600,
bio: 'Chuyên viên BĐS',
serviceAreas: ['Quận 7'],
isVerified: true,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-06-01'),
};
beforeEach(() => {
mockPrisma = {
agent: {
findUnique: vi.fn(),
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
},
lead: {
groupBy: vi.fn(),
count: vi.fn(),
},
inquiry: {
count: vi.fn(),
},
listing: {
count: vi.fn(),
findMany: vi.fn(),
},
review: {
aggregate: vi.fn(),
},
};
repository = new PrismaAgentRepository(mockPrisma as any);
});
describe('findByUserId', () => {
it('returns AgentEntity when found', async () => {
mockPrisma.agent.findUnique.mockResolvedValue(agentRow);
const result = await repository.findByUserId('user-1');
expect(result).toBeInstanceOf(AgentEntity);
expect(result!.id).toBe('agent-1');
expect(result!.userId).toBe('user-1');
expect(result!.qualityScore.value).toBe(85);
});
it('returns null when not found', async () => {
mockPrisma.agent.findUnique.mockResolvedValue(null);
const result = await repository.findByUserId('nonexistent');
expect(result).toBeNull();
});
});
describe('findById', () => {
it('returns AgentEntity when found', async () => {
mockPrisma.agent.findUnique.mockResolvedValue(agentRow);
const result = await repository.findById('agent-1');
expect(result).toBeInstanceOf(AgentEntity);
expect(result!.id).toBe('agent-1');
expect(result!.qualityScore.value).toBe(85);
});
it('returns null when not found', async () => {
mockPrisma.agent.findUnique.mockResolvedValue(null);
const result = await repository.findById('nonexistent');
expect(result).toBeNull();
});
});
describe('save', () => {
it('updates the quality score for the agent', async () => {
mockPrisma.agent.update.mockResolvedValue(undefined);
const agent = new AgentEntity('agent-1', {
userId: 'user-1',
licenseNumber: null,
agency: null,
qualityScore: QualityScore.fromPersistence(92),
totalDeals: 0,
responseTimeAvg: null,
bio: null,
serviceAreas: [],
isVerified: false,
});
await repository.save(agent);
expect(mockPrisma.agent.update).toHaveBeenCalledWith({
where: { id: 'agent-1' },
data: { qualityScore: 92 },
});
});
it('handles score of 0', async () => {
mockPrisma.agent.update.mockResolvedValue(undefined);
const agent = new AgentEntity('agent-1', {
userId: 'user-1',
licenseNumber: null,
agency: null,
qualityScore: QualityScore.fromPersistence(0),
totalDeals: 0,
responseTimeAvg: null,
bio: null,
serviceAreas: [],
isVerified: false,
});
await repository.save(agent);
expect(mockPrisma.agent.update).toHaveBeenCalledWith({
where: { id: 'agent-1' },
data: { qualityScore: 0 },
});
});
});
describe('getQualityScoreInputs', () => {
it('returns quality score input data', async () => {
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: 4.5 },
_count: { rating: 10 },
});
mockPrisma.lead.count
.mockResolvedValueOnce(20) // totalLeads
.mockResolvedValueOnce(5); // convertedLeads
mockPrisma.listing.count
.mockResolvedValueOnce(10) // totalListings
.mockResolvedValueOnce(7); // activeListings
mockPrisma.agent.findUnique.mockResolvedValue({ responseTimeAvg: 900 });
const result = await repository.getQualityScoreInputs('agent-1');
expect(result.avgRating).toBe(4.5);
expect(result.totalReviews).toBe(10);
expect(result.responseTimeAvg).toBe(900);
expect(result.conversionRate).toBe(0.25);
expect(result.activeListingRatio).toBe(0.7);
});
it('handles zero counts gracefully', async () => {
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: null },
_count: { rating: 0 },
});
mockPrisma.lead.count.mockResolvedValue(0);
mockPrisma.listing.count.mockResolvedValue(0);
mockPrisma.agent.findUnique.mockResolvedValue({ responseTimeAvg: null });
const result = await repository.getQualityScoreInputs('agent-1');
expect(result.avgRating).toBe(0);
expect(result.totalReviews).toBe(0);
expect(result.responseTimeAvg).toBeNull();
expect(result.conversionRate).toBe(0);
expect(result.activeListingRatio).toBe(0);
});
});
describe('getDashboard', () => {
it('returns full dashboard data', async () => {
mockPrisma.agent.findUniqueOrThrow.mockResolvedValue({
id: 'agent-1',
qualityScore: 85,
totalDeals: 12,
responseTimeAvg: 600,
isVerified: true,
});
mockPrisma.lead.groupBy.mockResolvedValue([
{ status: 'NEW', _count: { id: 5 } },
{ status: 'CONTACTED', _count: { id: 10 } },
{ status: 'CONVERTED', _count: { id: 3 } },
]);
mockPrisma.inquiry.count
.mockResolvedValueOnce(45) // total
.mockResolvedValueOnce(3); // unread
mockPrisma.listing.count
.mockResolvedValueOnce(15) // total
.mockResolvedValueOnce(10); // active
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: 4.5 },
_count: { rating: 20 },
});
const result = await repository.getDashboard('agent-1');
expect(result.agentId).toBe('agent-1');
expect(result.qualityScore).toBe(85);
expect(result.totalDeals).toBe(12);
expect(result.responseTimeAvg).toBe(600);
expect(result.isVerified).toBe(true);
expect(result.totalLeads).toBe(18);
expect(result.leadsByStatus).toEqual({ NEW: 5, CONTACTED: 10, CONVERTED: 3 });
expect(result.conversionRate).toBe(0.167);
expect(result.totalInquiries).toBe(45);
expect(result.unreadInquiries).toBe(3);
expect(result.totalListings).toBe(15);
expect(result.activeListings).toBe(10);
expect(result.avgReviewRating).toBe(4.5);
expect(result.totalReviews).toBe(20);
});
it('handles agent with zero leads', async () => {
mockPrisma.agent.findUniqueOrThrow.mockResolvedValue({
id: 'agent-1',
qualityScore: 0,
totalDeals: 0,
responseTimeAvg: null,
isVerified: false,
});
mockPrisma.lead.groupBy.mockResolvedValue([]);
mockPrisma.inquiry.count.mockResolvedValue(0);
mockPrisma.listing.count.mockResolvedValue(0);
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: null },
_count: { rating: 0 },
});
const result = await repository.getDashboard('agent-1');
expect(result.totalLeads).toBe(0);
expect(result.conversionRate).toBe(0);
expect(result.leadsByStatus).toEqual({});
expect(result.avgReviewRating).toBe(0);
expect(result.totalReviews).toBe(0);
});
});
describe('getPublicProfile', () => {
it('returns null when agent not found', async () => {
mockPrisma.agent.findUnique.mockResolvedValue(null);
const result = await repository.getPublicProfile('nonexistent');
expect(result).toBeNull();
});
it('returns full public profile', async () => {
const now = new Date();
mockPrisma.agent.findUnique.mockResolvedValue({
id: 'agent-1',
agency: 'Công ty BĐS ABC',
licenseNumber: 'BDS-001',
bio: 'Chuyên viên BĐS',
qualityScore: 85,
totalDeals: 50,
isVerified: true,
serviceAreas: ['Quận 7', 'Quận 2'],
createdAt: now,
user: {
fullName: 'Nguyễn Văn A',
avatarUrl: 'https://example.com/avatar.jpg',
phone: '0901234567',
email: 'agent@example.com',
createdAt: now,
},
});
mockPrisma.listing.findMany.mockResolvedValue([]);
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: 4.5 },
_count: { rating: 20 },
});
const result = await repository.getPublicProfile('agent-1');
expect(result).not.toBeNull();
expect(result!.id).toBe('agent-1');
expect(result!.fullName).toBe('Nguyễn Văn A');
expect(result!.agency).toBe('Công ty BĐS ABC');
expect(result!.qualityScore).toBe(85);
expect(result!.serviceAreas).toEqual(['Quận 7', 'Quận 2']);
expect(result!.avgReviewRating).toBe(4.5);
expect(result!.totalReviews).toBe(20);
expect(result!.activeListings).toEqual([]);
});
it('returns profile with active listings including property data', async () => {
const now = new Date();
mockPrisma.agent.findUnique.mockResolvedValue({
id: 'agent-1',
agency: null,
licenseNumber: null,
bio: null,
qualityScore: 70,
totalDeals: 5,
isVerified: true,
serviceAreas: [],
createdAt: now,
user: {
fullName: 'Lê Văn C',
avatarUrl: null,
phone: '0903456789',
email: null,
createdAt: now,
},
});
mockPrisma.listing.findMany.mockResolvedValue([
{
id: 'listing-1',
transactionType: 'SALE',
priceVND: BigInt('5000000000'),
status: 'ACTIVE',
property: {
id: 'prop-1',
title: 'Căn hộ cao cấp',
propertyType: 'APARTMENT',
address: '123 Nguyễn Hữu Thọ',
district: 'Quận 7',
city: 'TP.HCM',
areaM2: 75,
bedrooms: 2,
bathrooms: 2,
media: [{ url: 'https://example.com/image.jpg' }],
},
},
]);
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: 3.8 },
_count: { rating: 5 },
});
const result = await repository.getPublicProfile('agent-1');
expect(result!.activeListings).toHaveLength(1);
const listing = result!.activeListings[0]!;
expect(listing.id).toBe('listing-1');
expect(listing.transactionType).toBe('SALE');
expect(listing.priceVND).toBe('5000000000');
expect(listing.property.title).toBe('Căn hộ cao cấp');
expect(listing.property.imageUrl).toBe('https://example.com/image.jpg');
});
});
});