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,103 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { CreateLeadDialog } from '../create-lead-dialog';
// Mock the hook
const mockMutate = vi.fn();
vi.mock('@/lib/hooks/use-leads', () => ({
useCreateLead: () => ({
mutate: mockMutate,
isPending: false,
}),
}));
// Mock Dialog components with simplified versions
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe('CreateLeadDialog', () => {
beforeEach(() => {
mockMutate.mockClear();
});
it('renders dialog when open', () => {
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
expect(screen.getByText('Thêm lead mới')).toBeInTheDocument();
});
it('does not render when closed', () => {
render(<CreateLeadDialog open={false} onOpenChange={vi.fn()} />);
expect(screen.queryByText('Thêm lead mới')).not.toBeInTheDocument();
});
it('renders customer name input', () => {
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
expect(screen.getByLabelText('Tên khách hàng *')).toBeInTheDocument();
});
it('renders phone input', () => {
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
expect(screen.getByLabelText('Số điện thoại *')).toBeInTheDocument();
});
it('renders email input', () => {
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
expect(screen.getByLabelText('Email')).toBeInTheDocument();
});
it('renders source select', () => {
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
expect(screen.getByLabelText('Nguồn')).toBeInTheDocument();
});
it('renders score input', () => {
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
expect(screen.getByLabelText('Điểm (0-100)')).toBeInTheDocument();
});
it('renders notes textarea', () => {
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
expect(screen.getByLabelText('Ghi chú')).toBeInTheDocument();
});
it('renders cancel and submit buttons', () => {
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
expect(screen.getByText('Hủy')).toBeInTheDocument();
expect(screen.getByText('Tạo lead')).toBeInTheDocument();
});
it('calls onOpenChange when cancel clicked', async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(<CreateLeadDialog open={true} onOpenChange={onOpenChange} />);
await user.click(screen.getByText('Hủy'));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('calls mutate when form is submitted', async () => {
const user = userEvent.setup();
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
await user.type(screen.getByLabelText('Tên khách hàng *'), 'Nguyễn Văn Test');
await user.type(screen.getByLabelText('Số điện thoại *'), '0901234567');
await user.click(screen.getByText('Tạo lead'));
expect(mockMutate).toHaveBeenCalled();
});
it('renders description text', () => {
render(<CreateLeadDialog open={true} onOpenChange={vi.fn()} />);
expect(screen.getByText('Nhập thông tin khách hàng tiềm năng')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,139 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import type { LeadReadDto } from '@/lib/leads-api';
import { LeadDetailDialog } from '../lead-detail-dialog';
// Mock hooks
const mockUpdateMutate = vi.fn();
const mockDeleteMutate = vi.fn();
vi.mock('@/lib/hooks/use-leads', () => ({
useUpdateLeadStatus: () => ({
mutate: mockUpdateMutate,
isPending: false,
}),
useDeleteLead: () => ({
mutate: mockDeleteMutate,
isPending: false,
}),
}));
// Mock Dialog components
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
const mockLead: LeadReadDto = {
id: 'lead-1',
agentId: 'agent-1',
name: 'Trần Thị B',
phone: '0987654321',
email: 'tran@example.com',
source: 'website',
score: 75,
notes: { text: 'Quan tâm căn hộ Quận 7' },
status: 'NEW',
createdAt: '2026-01-15T10:00:00Z',
updatedAt: '2026-01-16T14:00:00Z',
};
describe('LeadDetailDialog', () => {
beforeEach(() => {
mockUpdateMutate.mockClear();
mockDeleteMutate.mockClear();
});
it('returns null when lead is null', () => {
const { container } = render(
<LeadDetailDialog lead={null} open={true} onOpenChange={vi.fn()} />,
);
expect(container.firstChild).toBeNull();
});
it('renders dialog title', () => {
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
expect(screen.getByText('Chi tiết lead')).toBeInTheDocument();
});
it('renders lead name', () => {
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
// Name appears in both the description and the contact card
expect(screen.getAllByText('Trần Thị B').length).toBeGreaterThanOrEqual(1);
});
it('renders phone number', () => {
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
expect(screen.getByText(/0987654321/)).toBeInTheDocument();
});
it('renders email when present', () => {
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
expect(screen.getByText(/tran@example.com/)).toBeInTheDocument();
});
it('renders score', () => {
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
expect(screen.getByText('75/100')).toBeInTheDocument();
});
it('renders notes', () => {
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
expect(screen.getByText('Quan tâm căn hộ Quận 7')).toBeInTheDocument();
});
it('renders quick contact links', () => {
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
// Emoji prefixed text
const content = document.body.textContent;
expect(content).toContain('Gọi điện');
expect(content).toContain('Zalo');
});
it('renders Zalo link with correct phone format', () => {
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
const links = document.querySelectorAll('a[href*="zalo.me"]');
expect(links.length).toBeGreaterThan(0);
expect(links[0]).toHaveAttribute('href', 'https://zalo.me/84987654321');
});
it('renders delete button', () => {
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
expect(screen.getByText('Xóa lead')).toBeInTheDocument();
});
it('shows confirmation on first delete click', async () => {
const user = userEvent.setup();
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
await user.click(screen.getByText('Xóa lead'));
expect(screen.getByText('Xác nhận xóa?')).toBeInTheDocument();
});
it('renders close button', () => {
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
expect(screen.getByText('Đóng')).toBeInTheDocument();
});
it('renders status change select', () => {
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
expect(screen.getByText('Chuyển trạng thái')).toBeInTheDocument();
});
it('renders timeline section', () => {
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
expect(screen.getByText('Lịch sử')).toBeInTheDocument();
});
it('hides email contact when email is null', () => {
const leadNoEmail = { ...mockLead, email: null };
render(<LeadDetailDialog lead={leadNoEmail} open={true} onOpenChange={vi.fn()} />);
const content = document.body.textContent;
expect(content).not.toContain('tran@example.com');
});
});

View File

@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { LeadStatusBadge } from '../lead-status-badge';
describe('LeadStatusBadge', () => {
it('renders NEW status with correct label', () => {
render(<LeadStatusBadge status="NEW" />);
expect(screen.getByText('Mới')).toBeInTheDocument();
});
it('renders CONTACTED status with correct label', () => {
render(<LeadStatusBadge status="CONTACTED" />);
expect(screen.getByText('Đã liên hệ')).toBeInTheDocument();
});
it('renders QUALIFIED status with correct label', () => {
render(<LeadStatusBadge status="QUALIFIED" />);
expect(screen.getByText('Đủ điều kiện')).toBeInTheDocument();
});
it('renders NEGOTIATING status with correct label', () => {
render(<LeadStatusBadge status="NEGOTIATING" />);
expect(screen.getByText('Đang thương lượng')).toBeInTheDocument();
});
it('renders CONVERTED status with correct label', () => {
render(<LeadStatusBadge status="CONVERTED" />);
expect(screen.getByText('Chuyển đổi')).toBeInTheDocument();
});
it('renders LOST status with correct label', () => {
render(<LeadStatusBadge status="LOST" />);
expect(screen.getByText('Mất')).toBeInTheDocument();
});
it('falls back to raw status value for unknown status', () => {
// @ts-expect-error testing unknown status
render(<LeadStatusBadge status="UNKNOWN" />);
expect(screen.getByText('UNKNOWN')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,153 @@
'use client';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useCreateLead } from '@/lib/hooks/use-leads';
import { LEAD_SOURCES } from '@/lib/leads-api';
interface CreateLeadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateLeadDialog({ open, onOpenChange }: CreateLeadDialogProps) {
const createLead = useCreateLead();
const [form, setForm] = React.useState({
name: '',
phone: '',
email: '',
source: 'website',
score: '',
notes: '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createLead.mutate(
{
name: form.name,
phone: form.phone,
email: form.email || undefined,
source: form.source,
score: form.score ? Number(form.score) : undefined,
notes: form.notes ? { text: form.notes } : undefined,
},
{
onSuccess: () => {
setForm({ name: '', phone: '', email: '', source: 'website', score: '', notes: '' });
onOpenChange(false);
},
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md sm:max-w-lg">
<DialogHeader>
<DialogTitle>Thêm lead mới</DialogTitle>
<DialogDescription>
Nhập thông tin khách hàng tiềm năng
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="lead-name">Tên khách hàng *</Label>
<Input
id="lead-name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="Nguyễn Văn A"
required
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="lead-phone">Số điện thoại *</Label>
<Input
id="lead-phone"
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
placeholder="0901234567"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="lead-email">Email</Label>
<Input
id="lead-email"
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
placeholder="email@example.com"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="lead-source">Nguồn</Label>
<Select
id="lead-source"
value={form.source}
onChange={(e) => setForm((f) => ({ ...f, source: e.target.value }))}
>
{LEAD_SOURCES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="lead-score">Điểm (0-100)</Label>
<Input
id="lead-score"
type="number"
min={0}
max={100}
value={form.score}
onChange={(e) => setForm((f) => ({ ...f, score: e.target.value }))}
placeholder="75"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lead-notes">Ghi chú</Label>
<Textarea
id="lead-notes"
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
placeholder="Thông tin bổ sung về khách hàng..."
rows={3}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Hủy
</Button>
<Button type="submit" disabled={createLead.isPending}>
{createLead.isPending ? 'Đang tạo...' : 'Tạo lead'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,212 @@
'use client';
import * as React from 'react';
import { LeadStatusBadge } from '@/components/leads/lead-status-badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Select } from '@/components/ui/select';
import { useDeleteLead, useUpdateLeadStatus } from '@/lib/hooks/use-leads';
import { LEAD_STATUSES, LEAD_SOURCES, type LeadReadDto, type LeadStatus } from '@/lib/leads-api';
interface LeadDetailDialogProps {
lead: LeadReadDto | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const STATUS_OPTIONS = Object.entries(LEAD_STATUSES) as [LeadStatus, { label: string }][];
function getSourceLabel(source: string): string {
const found = LEAD_SOURCES.find((s) => s.value === source);
return found?.label ?? source;
}
export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogProps) {
const updateStatus = useUpdateLeadStatus();
const deleteLead = useDeleteLead();
const [confirmDelete, setConfirmDelete] = React.useState(false);
if (!lead) return null;
const handleStatusChange = (newStatus: LeadStatus) => {
updateStatus.mutate(
{ id: lead.id, status: newStatus },
{
onSuccess: () => {
onOpenChange(false);
},
},
);
};
const handleDelete = () => {
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
deleteLead.mutate(lead.id, {
onSuccess: () => {
setConfirmDelete(false);
onOpenChange(false);
},
});
};
const createdDate = new Date(lead.createdAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const updatedDate = new Date(lead.updatedAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const notes = lead.notes && typeof lead.notes === 'object' && 'text' in lead.notes
? String(lead.notes['text'])
: null;
return (
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); setConfirmDelete(false); }}>
<DialogContent className="max-w-md sm:max-w-lg">
<DialogHeader>
<DialogTitle>Chi tiết lead</DialogTitle>
<DialogDescription>{lead.name}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Contact info */}
<div className="rounded-lg border p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{lead.name}</span>
<LeadStatusBadge status={lead.status} />
</div>
<div className="space-y-1 text-sm text-muted-foreground">
<p>SĐT: {lead.phone}</p>
{lead.email && <p>Email: {lead.email}</p>}
<p>Nguồn: {getSourceLabel(lead.source)}</p>
{lead.score !== null && <p>Điểm: {lead.score}/100</p>}
</div>
</div>
{/* Timeline */}
<div className="space-y-1.5">
<h4 className="text-sm font-medium">Lịch sử</h4>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<div className="h-2 w-2 rounded-full bg-blue-500" />
<span className="text-muted-foreground">Tạo lúc: {createdDate}</span>
</div>
{lead.createdAt !== lead.updatedAt && (
<div className="flex items-center gap-2 text-sm">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-muted-foreground">Cập nhật lúc: {updatedDate}</span>
</div>
)}
</div>
</div>
{/* Notes */}
{notes && (
<div className="space-y-1.5">
<h4 className="text-sm font-medium">Ghi chú</h4>
<div className="rounded-lg bg-muted p-3 text-sm leading-relaxed">
{notes}
</div>
</div>
)}
{/* Score bar */}
{lead.score !== null && (
<div className="space-y-1.5">
<h4 className="text-sm font-medium">Điểm lead</h4>
<div className="h-2 w-full rounded-full bg-muted">
<div
className="h-2 rounded-full bg-primary transition-all"
style={{ width: `${lead.score}%` }}
/>
</div>
<p className="text-xs text-muted-foreground text-right">{lead.score}/100</p>
</div>
)}
{/* Quick actions */}
<div className="space-y-1.5">
<h4 className="text-sm font-medium">Liên hệ nhanh</h4>
<div className="flex flex-wrap gap-2">
<a
href={`tel:${lead.phone}`}
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
>
📞 Gọi điện
</a>
{lead.email && (
<a
href={`mailto:${lead.email}`}
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
>
Email
</a>
)}
<a
href={`https://zalo.me/${lead.phone.replace(/^0/, '84')}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
>
💬 Zalo
</a>
</div>
</div>
{/* Status change */}
<div className="space-y-1.5">
<h4 className="text-sm font-medium">Chuyển trạng thái</h4>
<Select
value={lead.status}
onChange={(e) => handleStatusChange(e.target.value as LeadStatus)}
disabled={updateStatus.isPending}
>
{STATUS_OPTIONS.map(([value, { label }]) => (
<option key={value} value={value}>
{label}
</option>
))}
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={deleteLead.isPending}
>
{confirmDelete
? deleteLead.isPending
? 'Đang xóa...'
: 'Xác nhận xóa?'
: 'Xóa lead'}
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Đóng
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,11 @@
import { Badge } from '@/components/ui/badge';
import { LEAD_STATUSES, type LeadStatus } from '@/lib/leads-api';
interface LeadStatusBadgeProps {
status: LeadStatus;
}
export function LeadStatusBadge({ status }: LeadStatusBadgeProps) {
const config = LEAD_STATUSES[status] ?? { label: status, variant: 'outline' as const };
return <Badge variant={config.variant}>{config.label}</Badge>;
}