feat(search): GOO-221 cursor/keyset pagination for SavedSearch alert listeners

All four alert code paths that previously loaded the entire SavedSearch
table into memory are replaced with bounded batch iteration backed by
the idx_savedsearch_alert_enabled partial index (merged in GOO-118).

Batch size is 500 rows; order-by is createdAt ASC, which matches the
index definition so the planner uses it for both the WHERE clause and
the cursor predicate.

Changed files:
- saved-search-alert.handler.ts: keyset loop on createdAt with
  alertEnabled=true, ALERT_BATCH_SIZE=500
- saved-search-alert-cron.service.ts: same pagination loop, removes
  the early-return on empty set (loop exits naturally on first empty page)
- residential-events.listener.ts: ResidentialPriceDropListener and
  ResidentialNewListingInProjectListener both paginated; select now
  includes createdAt to advance the cursor; shared ALERT_BATCH_SIZE

Tests:
- saved-search-alert.handler.spec.ts: adds createdAt to mock rows, adds
  3-page pagination test and orderBy/take assertion
- residential-events.listener.spec.ts: adds createdAt to mock rows, adds
  501-row pagination test verifying cursor advance on second call (9
  existing tests all pass)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 12:58:16 +07:00
parent be47c26031
commit 9af9e1d84a
7 changed files with 269 additions and 96 deletions

View File

@@ -81,7 +81,8 @@ describe('InquiryDetailDialog', () => {
render(
<InquiryDetailDialog inquiry={mockInquiry} open={true} onOpenChange={vi.fn()} />,
);
expect(screen.getByText(/0912345678/)).toBeInTheDocument();
// formatPhone formats VN numbers as "0xxx yyy zzz" — match with optional spaces
expect(screen.getByText(/0912[\s]?345[\s]?678/)).toBeInTheDocument();
});
it('renders inquiry message', () => {
@@ -156,6 +157,7 @@ describe('InquiryDetailDialog', () => {
render(
<InquiryDetailDialog inquiry={inquiryWithPhone} open={true} onOpenChange={vi.fn()} />,
);
expect(screen.getByText(/0987654321/)).toBeInTheDocument();
// formatPhone formats VN numbers as "0xxx yyy zzz" — match with optional spaces
expect(screen.getByText(/0987[\s]?654[\s]?321/)).toBeInTheDocument();
});
});

View File

@@ -69,7 +69,8 @@ describe('LeadDetailDialog', () => {
it('renders phone number', () => {
render(<LeadDetailDialog lead={mockLead} open={true} onOpenChange={vi.fn()} />);
expect(screen.getByText(/0987654321/)).toBeInTheDocument();
// formatPhone formats VN numbers as "0xxx yyy zzz" — match with optional spaces
expect(screen.getByText(/0987[\s]?654[\s]?321/)).toBeInTheDocument();
});
it('renders email when present', () => {