diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43a8ebb..801c5d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,19 @@ jobs: - name: Test run: pnpm test + # GOO-134: API unit-test coverage gate (≥70% stmt/lines/funcs, ≥58% branches → ratcheting to 60 via GOO-180). + - name: Test coverage (API) + run: pnpm --filter @goodgo/api test:coverage + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: api-coverage + path: apps/api/coverage + if-no-files-found: ignore + retention-days: 14 + - name: Build run: pnpm build diff --git a/apps/api/package.json b/apps/api/package.json index b644ef4..0d58a51 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,6 +9,7 @@ "start:prod": "node dist/main", "lint": "eslint src/", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", "typecheck": "tsc --noEmit" }, diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 84143bd..1a34d3b 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -10,6 +10,30 @@ export default defineConfig({ env: { BCRYPT_ROUNDS: '4', }, + coverage: { + provider: 'v8', + reporter: ['text', 'text-summary', 'html', 'lcov', 'json-summary'], + reportsDirectory: './coverage', + include: ['src/**/*.ts'], + exclude: [ + 'src/**/*.spec.ts', + 'src/**/*.integration.spec.ts', + 'src/**/__tests__/**', + 'src/**/*.module.ts', + 'src/**/*.dto.ts', + 'src/**/index.ts', + 'src/main.ts', + ], + // GOO-134: CI gate thresholds. Branches starts at 58 (no-regression ratchet) + // and will be raised to 60 via follow-up GOO-180 (payments/sbv-compliance, + // subscriptions/quotas, auth/guards). CTO approval: 8f2b125a. + thresholds: { + statements: 70, + lines: 70, + functions: 70, + branches: 58, + }, + }, }, resolve: { alias: { diff --git a/apps/web/components/chuyen-nhuong/transfer-listing-card.tsx b/apps/web/components/chuyen-nhuong/transfer-listing-card.tsx index 40c75bf..e6b843c 100644 --- a/apps/web/components/chuyen-nhuong/transfer-listing-card.tsx +++ b/apps/web/components/chuyen-nhuong/transfer-listing-card.tsx @@ -56,7 +56,7 @@ export function TransferListingCard({ listing }: TransferListingCardProps) { {/* Location */}
- {listing.district}, {listing.city} + {listing.ward ? `${listing.ward}, ` : ''}{listing.district}, {listing.city}
{/* Price */} diff --git a/apps/web/components/inquiries/inquiry-detail-dialog.tsx b/apps/web/components/inquiries/inquiry-detail-dialog.tsx index 835c66b..b93a8f2 100644 --- a/apps/web/components/inquiries/inquiry-detail-dialog.tsx +++ b/apps/web/components/inquiries/inquiry-detail-dialog.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/dialog'; import { useMarkInquiryRead } from '@/lib/hooks/use-inquiries'; import type { InquiryReadDto } from '@/lib/inquiries-api'; +import { formatPhone, zaloHref } from '@/lib/phone'; interface InquiryDetailDialogProps { inquiry: InquiryReadDto | null; @@ -42,6 +43,8 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta minute: '2-digit', }); + const phone = inquiry.phone ?? inquiry.userPhone; + return ( @@ -60,7 +63,7 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
-

SĐT: {inquiry.phone ?? inquiry.userPhone}

+

SĐT: {formatPhone(phone)}

Ngày gửi: {formattedDate}

@@ -78,13 +81,13 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta

Liên hệ nhanh

-

SĐT: {lead.phone}

+

SĐT: {formatPhone(lead.phone)}

{lead.email &&

Email: {lead.email}

}

Nguồn: {getSourceLabel(lead.source)}

{lead.score !== null &&

Điểm: {lead.score}/100

} @@ -163,7 +164,7 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
)} { expect(screen.getByText(/3\.5 tỷ/)).toBeInTheDocument(); }); - it('renders property address', () => { + it('renders property address including ward', () => { render(); expect(screen.getByText(/208 Nguyễn Hữu Cảnh/)).toBeInTheDocument(); + expect(screen.getByText(/Phường 22/)).toBeInTheDocument(); + }); + + it('renders ward in list layout', () => { + render(); + expect(screen.getByText(/Phường 22/)).toBeInTheDocument(); }); it('renders transaction type badge for SALE', () => { diff --git a/apps/web/components/search/property-card.tsx b/apps/web/components/search/property-card.tsx index b9177cf..06fd4c0 100644 --- a/apps/web/components/search/property-card.tsx +++ b/apps/web/components/search/property-card.tsx @@ -92,7 +92,7 @@ export function PropertyCard({ listing, compact, layout = 'card' }: PropertyCard {listing.property.title}

- {listing.property.address}, {listing.property.district}, {listing.property.city} + {listing.property.address}, {listing.property.ward}, {listing.property.district}, {listing.property.city}

@@ -186,7 +186,7 @@ export function PropertyCard({ listing, compact, layout = 'card' }: PropertyCard

{listing.property.title}

- {listing.property.address}, {listing.property.district}, {listing.property.city} + {listing.property.address}, {listing.property.ward}, {listing.property.district}, {listing.property.city}

  • diff --git a/apps/web/lib/phone.ts b/apps/web/lib/phone.ts new file mode 100644 index 0000000..8c0b417 --- /dev/null +++ b/apps/web/lib/phone.ts @@ -0,0 +1,59 @@ +/** + * Vietnamese phone number helpers. + * + * Regex covers the current VN numbering plan: + * 0[35789]x xxxxxxx — Viettel, Mobifone, Vinaphone, Gmobile, Indochina + * + * See: https://en.wikipedia.org/wiki/Telephone_numbers_in_Vietnam + */ + +/** Matches a VN mobile number, with optional +84 or leading 0. */ +export const VN_PHONE_REGEX = + /^(0|\+84)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/; + +/** + * Normalise a VN phone number to the E.164-ish form used by Zalo / APIs: + * strip leading 0 and prepend the country code (84). + * + * "0987654321" → "84987654321" + * "+84987654321" → "84987654321" + * "84987654321" → "84987654321" (already normalised — idempotent) + */ +export function normalizePhone(phone: string): string { + const cleaned = phone.trim(); + if (cleaned.startsWith('+84')) return `84${cleaned.slice(3)}`; + if (cleaned.startsWith('84')) return cleaned; + if (cleaned.startsWith('0')) return `84${cleaned.slice(1)}`; + return cleaned; +} + +/** + * Format a raw VN phone number for display. + * Handles 10-digit numbers (0xx xxxx xxxx). + * + * "0987654321" → "0987 654 321" + * Passthrough for anything that doesn't match. + */ +export function formatPhone(phone: string): string { + const cleaned = phone.trim().replace(/\s+/g, ''); + + // 10-digit local format: 0xxx yyy zzz + const tenDigit = cleaned.match(/^(0\d{3})(\d{3})(\d{3})$/); + if (tenDigit) return `${tenDigit[1]} ${tenDigit[2]} ${tenDigit[3]}`; + + // +84 prefix → treat as 10-digit local after swapping prefix + const e164 = cleaned.match(/^\+84(\d{9})$/); + if (e164) return formatPhone(`0${e164[1]}`); + + return phone; +} + +/** + * Returns the https://zalo.me deep-link URL for a given phone number. + * + * Zalo expects the number without a leading zero, prefixed with 84. + * "0987654321" → "https://zalo.me/84987654321" + */ +export function zaloHref(phone: string): string { + return `https://zalo.me/${normalizePhone(phone)}`; +} diff --git a/apps/web/lib/validations/auth.ts b/apps/web/lib/validations/auth.ts index 4465211..5c420d3 100644 --- a/apps/web/lib/validations/auth.ts +++ b/apps/web/lib/validations/auth.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -const phoneRegex = /^(0|\+84)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/; +import { VN_PHONE_REGEX as phoneRegex } from '@/lib/phone'; export const loginSchema = z.object({ phone: z diff --git a/apps/web/lib/validations/inquiry.ts b/apps/web/lib/validations/inquiry.ts index dddbca4..0b28963 100644 --- a/apps/web/lib/validations/inquiry.ts +++ b/apps/web/lib/validations/inquiry.ts @@ -1,12 +1,6 @@ import { z } from 'zod'; -/** - * Vietnamese phone number rule: - * - 9–11 digits, optional leading +84 or 0. - * We keep validation pragmatic: whitespace is stripped, then the remaining - * string must be 9–11 digits (country code / leading zero stripped). - */ -const PHONE_REGEX = /^(?:\+?84|0)?\d{9,11}$/; +import { VN_PHONE_REGEX as PHONE_REGEX } from '@/lib/phone'; export const inquiryFormSchema = z.object({ message: z