diff --git a/apps/api/src/modules/listings/application/__tests__/listing-expiry-cron.service.spec.ts b/apps/api/src/modules/listings/application/__tests__/listing-expiry-cron.service.spec.ts new file mode 100644 index 0000000..3e53858 --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/listing-expiry-cron.service.spec.ts @@ -0,0 +1,115 @@ +import { ListingExpiringEvent } from '../../domain/events/listing-expiring.event'; +import { ListingExpiryCronService } from '../../infrastructure/cron/listing-expiry-cron.service'; + +describe('ListingExpiryCronService', () => { + let service: ListingExpiryCronService; + let mockPrisma: { $queryRaw: ReturnType }; + let mockEventBus: { publish: ReturnType }; + let mockLogger: { + log: ReturnType; + debug: ReturnType; + error: ReturnType; + }; + + beforeEach(() => { + mockPrisma = { $queryRaw: vi.fn() }; + mockEventBus = { publish: vi.fn() }; + mockLogger = { log: vi.fn(), debug: vi.fn(), error: vi.fn() }; + + service = new ListingExpiryCronService( + mockPrisma as any, + mockEventBus as any, + mockLogger as any, + ); + }); + + it('publishes ListingExpiringEvent for each expiring listing and logs the count', async () => { + const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000); + mockPrisma.$queryRaw.mockResolvedValue([ + { id: 'listing-a', sellerId: 'seller-a', expiresAt }, + { id: 'listing-b', sellerId: 'seller-b', expiresAt }, + ]); + + await service.notifyExpiringListings(); + + expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1); + expect(mockEventBus.publish).toHaveBeenCalledTimes(2); + + const events = mockEventBus.publish.mock.calls.map((c) => c[0]) as ListingExpiringEvent[]; + expect(events[0]).toBeInstanceOf(ListingExpiringEvent); + expect(events[1]).toBeInstanceOf(ListingExpiringEvent); + expect(events.map((e) => e.aggregateId).sort()).toEqual(['listing-a', 'listing-b']); + expect(events[0].eventName).toBe('listing.expiring'); + expect(events[0].sellerId).toBe('seller-a'); + expect(events[0].expiresAt).toBe(expiresAt); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('2 listing(s)'), + 'ListingExpiryCronService', + ); + }); + + it('is a no-op when no listings are expiring (idempotent across runs)', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + + await service.notifyExpiringListings(); + + expect(mockEventBus.publish).not.toHaveBeenCalled(); + expect(mockLogger.log).not.toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledWith( + 'No listings expiring in the next 3 days — nothing to notify', + 'ListingExpiryCronService', + ); + }); + + it('catches DB errors and logs them without throwing', async () => { + const boom = new Error('connection lost'); + mockPrisma.$queryRaw.mockRejectedValue(boom); + + await expect(service.notifyExpiringListings()).resolves.toBeUndefined(); + + expect(mockEventBus.publish).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('connection lost'), + expect.any(String), + 'ListingExpiryCronService', + ); + }); + + it('uses a single atomic UPDATE ... RETURNING so concurrent runs do not double-notify', async () => { + const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000); + // Simulate two parallel runs against the same DB; only the first sees the row. + mockPrisma.$queryRaw + .mockResolvedValueOnce([{ id: 'listing-1', sellerId: 'seller-1', expiresAt }]) + .mockResolvedValueOnce([]); + + await Promise.all([ + service.notifyExpiringListings(), + service.notifyExpiringListings(), + ]); + + // Only one event published across both runs. + expect(mockEventBus.publish).toHaveBeenCalledTimes(1); + expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(2); + }); + + it('publishes events with correct sellerId and expiresAt per row', async () => { + const expiresAtA = new Date(Date.now() + 1 * 24 * 60 * 60 * 1000); + const expiresAtB = new Date(Date.now() + 2.5 * 24 * 60 * 60 * 1000); + mockPrisma.$queryRaw.mockResolvedValue([ + { id: 'listing-a', sellerId: 'seller-x', expiresAt: expiresAtA }, + { id: 'listing-b', sellerId: 'seller-y', expiresAt: expiresAtB }, + ]); + + await service.notifyExpiringListings(); + + const events = mockEventBus.publish.mock.calls.map((c) => c[0]) as ListingExpiringEvent[]; + expect(events[0].aggregateId).toBe('listing-a'); + expect(events[0].sellerId).toBe('seller-x'); + expect(events[0].expiresAt).toBe(expiresAtA); + + expect(events[1].aggregateId).toBe('listing-b'); + expect(events[1].sellerId).toBe('seller-y'); + expect(events[1].expiresAt).toBe(expiresAtB); + }); +}); diff --git a/apps/api/src/modules/notifications/application/__tests__/listing-expiring.listener.spec.ts b/apps/api/src/modules/notifications/application/__tests__/listing-expiring.listener.spec.ts new file mode 100644 index 0000000..6571db0 --- /dev/null +++ b/apps/api/src/modules/notifications/application/__tests__/listing-expiring.listener.spec.ts @@ -0,0 +1,174 @@ +import { ListingExpiringEvent } from '../../../listings/domain/events/listing-expiring.event'; +import type { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; +import { ListingExpiringListener } from '../listeners/listing-expiring.listener'; + +describe('ListingExpiringListener', () => { + let listener: ListingExpiringListener; + let mockCommandBus: { execute: ReturnType }; + let mockPrisma: { + listing: { findUnique: ReturnType }; + }; + let mockLogger: { + log: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000); + + beforeEach(() => { + mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) }; + mockPrisma = { + listing: { findUnique: vi.fn() }, + }; + mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + listener = new ListingExpiringListener( + mockCommandBus as any, + mockPrisma as any, + mockLogger as any, + ); + }); + + it('sends both email and Zalo OA notifications when seller has email and phone', async () => { + mockPrisma.listing.findUnique.mockResolvedValue({ + id: 'listing-1', + property: { title: 'Căn hộ Quận 7' }, + seller: { id: 'seller-1', email: 'seller@example.com', phone: '0901234567' }, + }); + + const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt); + await listener.handle(event); + + expect(mockCommandBus.execute).toHaveBeenCalledTimes(2); + + const commands = mockCommandBus.execute.mock.calls.map((c) => c[0]) as SendNotificationCommand[]; + + // Email notification + const emailCmd = commands.find((c) => c.channel === 'EMAIL')!; + expect(emailCmd.userId).toBe('seller-1'); + expect(emailCmd.templateKey).toBe('listing.expiring'); + expect(emailCmd.recipientAddress).toBe('seller@example.com'); + expect(emailCmd.templateData).toEqual( + expect.objectContaining({ + listingTitle: 'Căn hộ Quận 7', + expiresAt: expiresAt.toISOString(), + }), + ); + + // Zalo OA notification + const zaloCmd = commands.find((c) => c.channel === 'ZALO_OA')!; + expect(zaloCmd.userId).toBe('seller-1'); + expect(zaloCmd.templateKey).toBe('listing.expiring'); + expect(zaloCmd.recipientAddress).toBe('0901234567'); + }); + + it('sends only email when seller has no phone', async () => { + mockPrisma.listing.findUnique.mockResolvedValue({ + id: 'listing-1', + property: { title: 'Nhà phố Quận 1' }, + seller: { id: 'seller-1', email: 'seller@example.com', phone: null }, + }); + + const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt); + await listener.handle(event); + + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + + const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand; + expect(cmd.channel).toBe('EMAIL'); + expect(cmd.recipientAddress).toBe('seller@example.com'); + }); + + it('sends only Zalo OA when seller has no email', async () => { + mockPrisma.listing.findUnique.mockResolvedValue({ + id: 'listing-1', + property: { title: 'Biệt thự Thảo Điền' }, + seller: { id: 'seller-1', email: null, phone: '0909876543' }, + }); + + const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt); + await listener.handle(event); + + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + + const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand; + expect(cmd.channel).toBe('ZALO_OA'); + expect(cmd.recipientAddress).toBe('0909876543'); + }); + + it('skips notification when listing not found', async () => { + mockPrisma.listing.findUnique.mockResolvedValue(null); + + const event = new ListingExpiringEvent('nonexistent', 'seller-1', expiresAt); + await listener.handle(event); + + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); + + it('sends no notifications when seller has neither email nor phone', async () => { + mockPrisma.listing.findUnique.mockResolvedValue({ + id: 'listing-1', + property: { title: 'Căn hộ mini' }, + seller: { id: 'seller-1', email: null, phone: null }, + }); + + const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt); + await listener.handle(event); + + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); + + it('includes correct daysRemaining in templateData', async () => { + const twoDaysFromNow = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000); + mockPrisma.listing.findUnique.mockResolvedValue({ + id: 'listing-1', + property: { title: 'Căn hộ' }, + seller: { id: 'seller-1', email: 'test@example.com', phone: null }, + }); + + const event = new ListingExpiringEvent('listing-1', 'seller-1', twoDaysFromNow); + await listener.handle(event); + + const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand; + expect(cmd.templateData).toHaveProperty('daysRemaining'); + expect(cmd.templateData.daysRemaining).toBeGreaterThanOrEqual(1); + expect(cmd.templateData.daysRemaining).toBeLessThanOrEqual(3); + }); + + it('uses Promise.allSettled so one notification failure does not block the other', async () => { + mockPrisma.listing.findUnique.mockResolvedValue({ + id: 'listing-1', + property: { title: 'Căn hộ' }, + seller: { id: 'seller-1', email: 'seller@example.com', phone: '0901234567' }, + }); + + // Email fails, Zalo succeeds + mockCommandBus.execute + .mockRejectedValueOnce(new Error('SMTP down')) + .mockResolvedValueOnce(undefined); + + const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt); + + // Should not throw even though email notification failed + await expect(listener.handle(event)).resolves.toBeUndefined(); + + // Both notifications were attempted + expect(mockCommandBus.execute).toHaveBeenCalledTimes(2); + }); + + it('queries listing with correct include shape', async () => { + mockPrisma.listing.findUnique.mockResolvedValue(null); + + const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt); + await listener.handle(event); + + expect(mockPrisma.listing.findUnique).toHaveBeenCalledWith({ + where: { id: 'listing-1' }, + include: { + property: { select: { title: true } }, + seller: { select: { id: true, email: true, phone: true } }, + }, + }); + }); +}); diff --git a/apps/web/components/error-boundary/component-error-boundary.tsx b/apps/web/components/error-boundary/component-error-boundary.tsx new file mode 100644 index 0000000..75b6045 --- /dev/null +++ b/apps/web/components/error-boundary/component-error-boundary.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { type ErrorInfo, type ReactNode } from 'react'; +import { ErrorBoundary, type ErrorBoundaryFallbackProps } from './error-boundary'; + +interface ComponentErrorBoundaryProps { + children: ReactNode; + /** + * Short label identifying the widget (e.g. "bản đồ", "thanh toán", "tìm kiếm"). + * Shown in the fallback UI so users understand which section failed. + */ + label?: string; + /** + * Compact mode renders a smaller fallback suited to narrow widgets like cards + * or sidebars. Defaults to false. + */ + compact?: boolean; + onError?: (error: Error, info: ErrorInfo) => void; +} + +/** + * Inline error boundary for critical widgets (map, payment form, search). + * + * Renders a contained, non-intrusive fallback that does not take over the page. + * Captures errors to Sentry via the base `ErrorBoundary`. + */ +export function ComponentErrorBoundary({ + children, + label, + compact = false, + onError, +}: ComponentErrorBoundaryProps) { + return ( + ( + + )} + > + {children} + + ); +} + +function ComponentFallback({ + error, + reset, + label, + compact, +}: ErrorBoundaryFallbackProps & { label?: string; compact: boolean }) { + if (compact) { + return ( +
+ + + {label ? `Lỗi ${label}` : 'Đã xảy ra lỗi'} + {process.env.NODE_ENV !== 'production' && error.message + ? `: ${error.message}` + : ''} + + +
+ ); + } + + return ( +
+ +

+ {label ? `Không thể tải ${label}` : 'Đã xảy ra lỗi'} +

+ {process.env.NODE_ENV !== 'production' && error.message && ( +

{error.message}

+ )} + +
+ ); +} diff --git a/apps/web/components/error-boundary/error-boundary.tsx b/apps/web/components/error-boundary/error-boundary.tsx new file mode 100644 index 0000000..ef98042 --- /dev/null +++ b/apps/web/components/error-boundary/error-boundary.tsx @@ -0,0 +1,97 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +export interface ErrorBoundaryFallbackProps { + error: Error; + reset: () => void; +} + +export interface ErrorBoundaryProps { + children: ReactNode; + /** Custom fallback component. Receives the caught error and a reset callback. */ + fallback?: (props: ErrorBoundaryFallbackProps) => ReactNode; + /** Called when an error is caught. Useful for additional logging / side effects. */ + onError?: (error: Error, info: ErrorInfo) => void; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * Generic class-based React Error Boundary. + * + * Captures exceptions via Sentry and renders a Vietnamese-language fallback UI + * with a "Thử lại" (retry) button when no custom fallback is provided. + */ +export class ErrorBoundary extends Component { + override state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + override componentDidCatch(error: Error, info: ErrorInfo) { + Sentry.captureException(error, { extra: { componentStack: info.componentStack } }); + + if (process.env.NODE_ENV !== 'production') { + console.error('[ErrorBoundary] caught:', error, info); + } + + this.props.onError?.(error, info); + } + + reset = () => { + this.setState({ error: null }); + }; + + override render() { + const { error } = this.state; + const { children, fallback } = this.props; + + if (error) { + if (fallback) { + return fallback({ error, reset: this.reset }); + } + return ; + } + + return children; + } +} + +function DefaultErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) { + return ( +
+ +

Đã xảy ra lỗi

+ {process.env.NODE_ENV !== 'production' && error.message && ( +

{error.message}

+ )} + +
+ ); +} diff --git a/apps/web/components/error-boundary/index.ts b/apps/web/components/error-boundary/index.ts new file mode 100644 index 0000000..7c06e62 --- /dev/null +++ b/apps/web/components/error-boundary/index.ts @@ -0,0 +1,4 @@ +export { ErrorBoundary } from './error-boundary'; +export type { ErrorBoundaryProps, ErrorBoundaryFallbackProps } from './error-boundary'; +export { PageErrorBoundary } from './page-error-boundary'; +export { ComponentErrorBoundary } from './component-error-boundary'; diff --git a/apps/web/components/error-boundary/page-error-boundary.tsx b/apps/web/components/error-boundary/page-error-boundary.tsx new file mode 100644 index 0000000..69dc3d65 --- /dev/null +++ b/apps/web/components/error-boundary/page-error-boundary.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { type ErrorInfo, type ReactNode } from 'react'; +import { ErrorBoundary, type ErrorBoundaryFallbackProps } from './error-boundary'; + +interface PageErrorBoundaryProps { + children: ReactNode; + /** Page title shown in the full-page error UI. */ + pageName?: string; + onError?: (error: Error, info: ErrorInfo) => void; +} + +/** + * Full-page error boundary for route layouts. + * + * Renders a centred Vietnamese-language error screen with a retry button and a + * link back to the home page. Wraps the generic `ErrorBoundary` with a + * page-appropriate fallback size. + */ +export function PageErrorBoundary({ children, pageName, onError }: PageErrorBoundaryProps) { + return ( + ( + + )} + > + {children} + + ); +} + +function PageFallback({ + error, + reset, + pageName, +}: ErrorBoundaryFallbackProps & { pageName?: string }) { + return ( +
+
+ +
+

+ {pageName ? `Lỗi tải trang: ${pageName}` : 'Đã xảy ra lỗi'} +

+

+ Trang này gặp sự cố. Vui lòng thử lại hoặc quay về trang chủ. +

+ {process.env.NODE_ENV !== 'production' && error.message && ( +

{error.message}

+ )} +
+ + + Trang chủ + +
+
+ ); +} diff --git a/apps/web/components/map/listing-map.tsx b/apps/web/components/map/listing-map.tsx index 4015366..2076299 100644 --- a/apps/web/components/map/listing-map.tsx +++ b/apps/web/components/map/listing-map.tsx @@ -4,6 +4,7 @@ import mapboxgl from 'mapbox-gl'; import * as React from 'react'; import 'mapbox-gl/dist/mapbox-gl.css'; +import { ComponentErrorBoundary } from '@/components/error-boundary'; import type { ListingDetail } from '@/lib/listings-api'; import { useMapboxStyle } from '@/lib/mapbox-style'; @@ -50,7 +51,15 @@ function getMarkerCoords(listing: ListingDetail, index: number): { lat: number; }; } -export function ListingMap({ listings, onMarkerClick, selectedListingId, className }: ListingMapProps) { +export function ListingMap(props: ListingMapProps) { + return ( + + + + ); +} + +function ListingMapInner({ listings, onMarkerClick, selectedListingId, className }: ListingMapProps) { const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const markersRef = React.useRef([]); diff --git a/apps/web/components/search/search-results.tsx b/apps/web/components/search/search-results.tsx index 28016d5..830d429 100644 --- a/apps/web/components/search/search-results.tsx +++ b/apps/web/components/search/search-results.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; +import { ComponentErrorBoundary } from '@/components/error-boundary'; import { Button } from '@/components/ui/button'; import { Select } from '@/components/ui/select'; import type { ListingDetail, PaginatedResult } from '@/lib/listings-api'; @@ -17,7 +18,15 @@ interface SearchResultsProps { onSortChange: (sort: string) => void; } -export function SearchResults({ +export function SearchResults(props: SearchResultsProps) { + return ( + + + + ); +} + +function SearchResultsInner({ result, loading, error, diff --git a/apps/web/components/subscription/checkout-modal.tsx b/apps/web/components/subscription/checkout-modal.tsx index 5399c60..548a9c1 100644 --- a/apps/web/components/subscription/checkout-modal.tsx +++ b/apps/web/components/subscription/checkout-modal.tsx @@ -2,6 +2,7 @@ import { AlertCircle, CreditCard, Loader2, Smartphone, Wallet } from 'lucide-react'; import { useCallback, useState } from 'react'; +import { ComponentErrorBoundary } from '@/components/error-boundary'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -78,7 +79,15 @@ const PLAN_TIER_LABELS: Record = { // Component // --------------------------------------------------------------------------- -export function CheckoutModal({ +export function CheckoutModal(props: CheckoutModalProps) { + return ( + + + + ); +} + +function CheckoutModalInner({ open, onOpenChange, plan,