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,144 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const { mockGetMyInquiries, mockMarkAsRead } = vi.hoisted(() => ({
mockGetMyInquiries: vi.fn(),
mockMarkAsRead: vi.fn(),
}));
vi.mock('@/lib/inquiries-api', () => ({
inquiriesApi: {
getMyInquiries: mockGetMyInquiries,
getByListing: vi.fn(),
markAsRead: mockMarkAsRead,
},
}));
import InquiriesPage from '../page';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
const mockInquiries = {
data: [
{
id: '1',
listingId: 'listing-1',
listingTitle: 'Bán căn hộ 2PN Quận 7',
userId: 'user-1',
userName: 'Nguyễn Văn A',
userPhone: '0901234567',
message: 'Tôi muốn xem căn hộ này cuối tuần',
phone: null,
isRead: false,
createdAt: '2026-04-10T10:00:00Z',
},
{
id: '2',
listingId: 'listing-2',
listingTitle: 'Cho thuê nhà phố Quận 2',
userId: 'user-2',
userName: 'Trần Thị B',
userPhone: '0912345678',
message: 'Giá thuê có thương lượng được không?',
phone: '0912345678',
isRead: true,
createdAt: '2026-04-09T14:30:00Z',
},
],
total: 2,
page: 1,
limit: 20,
totalPages: 1,
};
describe('InquiriesPage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetMyInquiries.mockResolvedValue(mockInquiries);
});
it('renders the page title and description', async () => {
render(<InquiriesPage />, { wrapper: createWrapper() });
expect(screen.getByText('Quản lý liên hệ')).toBeInTheDocument();
expect(
screen.getByText('Xem và phản hồi các yêu cầu tư vấn từ khách hàng'),
).toBeInTheDocument();
});
it('renders stats cards', async () => {
render(<InquiriesPage />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Tổng liên hệ')).toBeInTheDocument();
});
// Stats labels are inside CardDescription elements
const statCards = screen.getAllByText('Tổng liên hệ');
expect(statCards).toHaveLength(1);
});
it('renders inquiry data after loading', async () => {
render(<InquiriesPage />, { wrapper: createWrapper() });
await waitFor(() => {
// Names should appear in both mobile and desktop views
expect(screen.getAllByText('Nguyễn Văn A').length).toBeGreaterThan(0);
expect(screen.getAllByText('Trần Thị B').length).toBeGreaterThan(0);
});
});
it('shows empty state when no inquiries', async () => {
mockGetMyInquiries.mockResolvedValue({
data: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0,
});
render(<InquiriesPage />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Chưa có liên hệ nào')).toBeInTheDocument();
});
});
it('renders the read/unread filter', async () => {
render(<InquiriesPage />, { wrapper: createWrapper() });
const select = screen.getByDisplayValue('Tất cả');
expect(select).toBeInTheDocument();
});
it('opens detail dialog when clicking inquiry card', async () => {
render(<InquiriesPage />, { wrapper: createWrapper() });
const user = userEvent.setup();
await waitFor(() => {
expect(screen.getAllByText('Nguyễn Văn A').length).toBeGreaterThan(0);
});
// Click on a table row (tr elements have onClick handlers)
const rows = document.querySelectorAll('tr[class*="cursor-pointer"]');
if (rows[0]) {
await user.click(rows[0] as HTMLElement);
}
await waitFor(() => {
expect(screen.getByText('Chi tiết liên hệ')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,214 @@
'use client';
import * as React from 'react';
import { InquiryDetailDialog } from '@/components/inquiries/inquiry-detail-dialog';
import { InquiryRow, InquiryStatusBadge } from '@/components/inquiries/inquiry-row';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select } from '@/components/ui/select';
import { useMyInquiries } from '@/lib/hooks/use-inquiries';
import type { InquiryReadDto } from '@/lib/inquiries-api';
type ReadFilter = 'all' | 'unread' | 'read';
export default function InquiriesPage() {
const [page, setPage] = React.useState(1);
const [readFilter, setReadFilter] = React.useState<ReadFilter>('all');
const [selectedInquiry, setSelectedInquiry] = React.useState<InquiryReadDto | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false);
const { data: result, isLoading: loading } = useMyInquiries({ page, limit: 20 });
// Client-side filter for read/unread since API doesn't support it directly
const filteredData = React.useMemo(() => {
if (!result) return [];
if (readFilter === 'all') return result.data;
if (readFilter === 'unread') return result.data.filter((i) => !i.isRead);
return result.data.filter((i) => i.isRead);
}, [result, readFilter]);
const stats = React.useMemo(() => {
if (!result) return { total: 0, unread: 0, read: 0 };
return {
total: result.total,
unread: result.data.filter((i) => !i.isRead).length,
read: result.data.filter((i) => i.isRead).length,
};
}, [result]);
const handleSelectInquiry = (inquiry: InquiryReadDto) => {
setSelectedInquiry(inquiry);
setDialogOpen(true);
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold">Quản liên hệ</h1>
<p className="text-sm text-muted-foreground">
Xem phản hồi các yêu cầu vấn từ khách hàng
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
<Card>
<CardHeader className="pb-2">
<CardDescription>Tổng liên hệ</CardDescription>
<CardTitle className="text-xl">{loading ? '...' : stats.total}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Chưa đc</CardDescription>
<CardTitle className="text-xl text-blue-600">
{loading ? '...' : stats.unread}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Đã đc</CardDescription>
<CardTitle className="text-xl text-green-600">
{loading ? '...' : stats.read}
</CardTitle>
</CardHeader>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<Select
value={readFilter}
onChange={(e) => {
setReadFilter(e.target.value as ReadFilter);
setPage(1);
}}
className="w-40"
>
<option value="all">Tất cả</option>
<option value="unread">Chưa đc</option>
<option value="read">Đã đc</option>
</Select>
<span className="text-sm text-muted-foreground">
{filteredData.length} liên hệ
</span>
</div>
{/* Content */}
{loading ? (
<div className="flex min-h-[300px] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : filteredData.length === 0 ? (
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
<p className="text-4xl mb-3">📭</p>
<p>Chưa liên hệ nào</p>
<p className="text-xs mt-1">
Khi khách hàng gửi yêu cầu vấn, chúng sẽ xuất hiện đây
</p>
</div>
) : (
<>
{/* Mobile card view */}
<div className="space-y-3 sm:hidden">
{filteredData.map((inquiry) => (
<Card
key={inquiry.id}
className="cursor-pointer transition-shadow hover:shadow-md"
onClick={() => handleSelectInquiry(inquiry)}
>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-medium">{inquiry.userName}</p>
<p className="text-xs text-muted-foreground">{inquiry.userPhone}</p>
</div>
<InquiryStatusBadge isRead={inquiry.isRead} />
</div>
<p className="mt-2 text-sm text-muted-foreground line-clamp-1">
{inquiry.listingTitle}
</p>
<p className="mt-1 text-sm line-clamp-2">{inquiry.message}</p>
<p className="mt-2 text-xs text-muted-foreground">
{new Date(inquiry.createdAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</CardContent>
</Card>
))}
</div>
{/* Desktop table view */}
<Card className="hidden sm:block">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="p-3 font-medium">Khách hàng</th>
<th className="p-3 font-medium">Tin đăng</th>
<th className="hidden p-3 font-medium sm:table-cell">Nội dung</th>
<th className="p-3 font-medium text-center">Trạng thái</th>
<th className="p-3 font-medium text-right">Ngày gửi</th>
</tr>
</thead>
<tbody>
{filteredData.map((inquiry) => (
<InquiryRow
key={inquiry.id}
inquiry={inquiry}
onSelect={handleSelectInquiry}
/>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</>
)}
{/* Pagination */}
{result && result.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Trước
</Button>
<span className="text-sm text-muted-foreground">
Trang {result.page} / {result.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= result.totalPages}
onClick={() => setPage((p) => p + 1)}
>
Tiếp
</Button>
</div>
)}
{/* Detail Dialog */}
<InquiryDetailDialog
inquiry={selectedInquiry}
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) setSelectedInquiry(null);
}}
/>
</div>
);
}