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,34 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { inquiriesApi, type ListInquiriesParams } from '@/lib/inquiries-api';
export const inquiriesKeys = {
all: ['inquiries'] as const,
myInquiries: (params: ListInquiriesParams) => ['inquiries', 'my', params] as const,
byListing: (listingId: string, params: ListInquiriesParams) =>
['inquiries', 'listing', listingId, params] as const,
};
export function useMyInquiries(params: ListInquiriesParams = {}) {
return useQuery({
queryKey: inquiriesKeys.myInquiries(params),
queryFn: () => inquiriesApi.getMyInquiries(params),
});
}
export function useInquiriesByListing(listingId: string, params: ListInquiriesParams = {}) {
return useQuery({
queryKey: inquiriesKeys.byListing(listingId, params),
queryFn: () => inquiriesApi.getByListing(listingId, params),
enabled: !!listingId,
});
}
export function useMarkInquiryRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => inquiriesApi.markAsRead(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: inquiriesKeys.all });
},
});
}

View File

@@ -0,0 +1,58 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
leadsApi,
type CreateLeadPayload,
type LeadStatus,
type ListLeadsParams,
} from '@/lib/leads-api';
export const leadsKeys = {
all: ['leads'] as const,
list: (params: ListLeadsParams) => ['leads', 'list', params] as const,
stats: () => ['leads', 'stats'] as const,
};
export function useLeads(params: ListLeadsParams = {}) {
return useQuery({
queryKey: leadsKeys.list(params),
queryFn: () => leadsApi.getLeads(params),
});
}
export function useLeadStats() {
return useQuery({
queryKey: leadsKeys.stats(),
queryFn: () => leadsApi.getStats(),
});
}
export function useCreateLead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateLeadPayload) => leadsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: leadsKeys.all });
},
});
}
export function useUpdateLeadStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: LeadStatus }) =>
leadsApi.updateStatus(id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: leadsKeys.all });
},
});
}
export function useDeleteLead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => leadsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: leadsKeys.all });
},
});
}

View File

@@ -0,0 +1,59 @@
import { apiClient } from './api-client';
// ─── Types ──────────────────────────────────────────────
export interface InquiryReadDto {
id: string;
listingId: string;
listingTitle: string;
userId: string;
userName: string;
userPhone: string;
message: string;
phone: string | null;
isRead: boolean;
createdAt: string;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface ListInquiriesParams {
page?: number;
limit?: number;
}
// ─── API Functions ──────────────────────────────────────
export const inquiriesApi = {
/** List all inquiries for current agent */
getMyInquiries: (params: ListInquiriesParams = {}) => {
const query = new URLSearchParams();
if (params.page) query.append('page', String(params.page));
if (params.limit) query.append('limit', String(params.limit));
const qs = query.toString();
return apiClient.get<PaginatedResult<InquiryReadDto>>(
`/inquiries/agent/me${qs ? `?${qs}` : ''}`,
);
},
/** List inquiries by listing */
getByListing: (listingId: string, params: ListInquiriesParams = {}) => {
const query = new URLSearchParams();
if (params.page) query.append('page', String(params.page));
if (params.limit) query.append('limit', String(params.limit));
const qs = query.toString();
return apiClient.get<PaginatedResult<InquiryReadDto>>(
`/inquiries/listing/${listingId}${qs ? `?${qs}` : ''}`,
);
},
/** Mark an inquiry as read */
markAsRead: (id: string) =>
apiClient.patch<{ success: boolean }>(`/inquiries/${id}/read`),
};

100
apps/web/lib/leads-api.ts Normal file
View File

@@ -0,0 +1,100 @@
import { apiClient } from './api-client';
// ─── Types ──────────────────────────────────────────────
export type LeadStatus = 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'NEGOTIATING' | 'CONVERTED' | 'LOST';
export interface LeadReadDto {
id: string;
agentId: string;
name: string;
phone: string;
email: string | null;
source: string;
score: number | null;
notes: Record<string, unknown> | null;
status: LeadStatus;
createdAt: string;
updatedAt: string;
}
export interface LeadStatsData {
totalLeads: number;
byStatus: Record<string, number>;
conversionRate: number;
avgScore: number | null;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface ListLeadsParams {
status?: LeadStatus;
page?: number;
limit?: number;
}
export interface CreateLeadPayload {
name: string;
phone: string;
email?: string;
source: string;
score?: number;
notes?: Record<string, unknown>;
}
// ─── Constants ──────────────────────────────────────────
export const LEAD_STATUSES: Record<LeadStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info' }> = {
NEW: { label: 'Mới', variant: 'info' },
CONTACTED: { label: 'Đã liên hệ', variant: 'secondary' },
QUALIFIED: { label: 'Đủ điều kiện', variant: 'warning' },
NEGOTIATING: { label: 'Đang thương lượng', variant: 'default' },
CONVERTED: { label: 'Chuyển đổi', variant: 'success' },
LOST: { label: 'Mất', variant: 'destructive' },
};
export const LEAD_SOURCES = [
{ value: 'website', label: 'Website' },
{ value: 'referral', label: 'Giới thiệu' },
{ value: 'social', label: 'Mạng xã hội' },
{ value: 'phone', label: 'Điện thoại' },
{ value: 'walk_in', label: 'Đến trực tiếp' },
{ value: 'other', label: 'Khác' },
] as const;
// ─── API Functions ──────────────────────────────────────
export const leadsApi = {
/** Create a new lead */
create: (data: CreateLeadPayload) =>
apiClient.post<{ leadId: string }>('/leads', data),
/** List leads for current agent */
getLeads: (params: ListLeadsParams = {}) => {
const query = new URLSearchParams();
if (params.status) query.append('status', params.status);
if (params.page) query.append('page', String(params.page));
if (params.limit) query.append('limit', String(params.limit));
const qs = query.toString();
return apiClient.get<PaginatedResult<LeadReadDto>>(
`/leads${qs ? `?${qs}` : ''}`,
);
},
/** Get lead statistics */
getStats: () => apiClient.get<LeadStatsData>('/leads/stats'),
/** Update lead status */
updateStatus: (id: string, status: LeadStatus) =>
apiClient.patch<{ updated: boolean }>(`/leads/${id}/status`, { status }),
/** Delete a lead */
delete: (id: string) =>
apiClient.delete<{ deleted: boolean }>(`/leads/${id}`),
};