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:
3
apps/api/src/modules/agents/application/index.ts
Normal file
3
apps/api/src/modules/agents/application/index.ts
Normal 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';
|
||||
61
apps/api/src/modules/agents/domain/entities/agent.entity.ts
Normal file
61
apps/api/src/modules/agents/domain/entities/agent.entity.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
apps/api/src/modules/agents/domain/entities/index.ts
Normal file
1
apps/api/src/modules/agents/domain/entities/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AgentEntity, type AgentProps } from './agent.entity';
|
||||
1
apps/api/src/modules/agents/domain/events/index.ts
Normal file
1
apps/api/src/modules/agents/domain/events/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { QualityScoreUpdatedEvent } from './quality-score-updated.event';
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
5
apps/api/src/modules/agents/domain/index.ts
Normal file
5
apps/api/src/modules/agents/domain/index.ts
Normal 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';
|
||||
8
apps/api/src/modules/agents/domain/repositories/index.ts
Normal file
8
apps/api/src/modules/agents/domain/repositories/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
AGENT_REPOSITORY,
|
||||
type IAgentRepository,
|
||||
type AgentDashboardData,
|
||||
type AgentPublicProfileData,
|
||||
type AgentPublicListingItem,
|
||||
type QualityScoreInputData,
|
||||
} from './agent.repository';
|
||||
@@ -0,0 +1 @@
|
||||
export { QualityScore } from './quality-score.vo';
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user