From 2f0355535f9471351de363e44a4d7e994a2c06c4 Mon Sep 17 00:00:00 2001 From: DingiDinigi Date: Thu, 23 Jul 2026 01:52:46 +0200 Subject: [PATCH] feat(search): add relevance-based ranking for campaign and donation search - Add weighted relevance scoring via parameterized $queryRaw when sortBy is 'relevance' - Score computed from exact/prefix/contains match tiers across searched fields - Deterministic ordering: score DESC, id ASC tiebreaker - Existing orderBy/pagination path untouched for non-relevance sorts - Add test-environment redis mock so searchCampaigns tests run without a live Redis - Add 8 tests covering path selection, ordering after hydration, pagination params, and regression --- src/services/search.service.test.ts | 229 ++++++++++++++++++++++++++++ src/services/search.service.ts | 190 +++++++++++++++++++++++ 2 files changed, 419 insertions(+) diff --git a/src/services/search.service.test.ts b/src/services/search.service.test.ts index 81a630e..3140653 100644 --- a/src/services/search.service.test.ts +++ b/src/services/search.service.test.ts @@ -6,6 +6,13 @@ import prisma from '../config/database'; // Mock Prisma jest.mock('../config/database'); +// Mock the cache layer so tests don't depend on a real Redis connection — +// getOrSet always misses and just runs the factory. +jest.mock('../utils/cache', () => ({ + getOrSet: jest.fn((_key: string, _ttl: number, factory: () => Promise) => factory()), + buildKey: jest.fn((namespace: string, key: string) => `${namespace}:${key}`), +})); + describe('SearchService', () => { beforeEach(() => { jest.clearAllMocks(); @@ -63,6 +70,228 @@ describe('SearchService', () => { }); }); + describe('relevance sorting', () => { + interface RawSqlCall { + text: string; + values: unknown[]; + } + + function extractLimitOffset(call: RawSqlCall): { limit: unknown; offset: unknown } { + const match = call.text.match(/LIMIT \$(\d+) OFFSET \$(\d+)/); + if (!match) throw new Error(`LIMIT/OFFSET not found in: ${call.text}`); + const [, limitIdx, offsetIdx] = match; + return { + limit: call.values[Number(limitIdx) - 1], + offset: call.values[Number(offsetIdx) - 1], + }; + } + + describe('searchCampaigns', () => { + it('uses the $queryRaw scoring path when sortBy is relevance, not the default orderBy path', async () => { + (prisma.$queryRaw as jest.Mock) + .mockResolvedValueOnce([{ id: 'camp-1', score: 50 }]) + .mockResolvedValueOnce([{ count: 1 }]); + (prisma.campaign.findMany as jest.Mock).mockResolvedValue([ + { + id: 'camp-1', + title: 'Flood Relief', + organization: { name: 'Org' }, + _count: { donations: 0, beneficiaries: 0 }, + }, + ]); + + await SearchService.searchCampaigns({ query: 'flood', sortBy: 'relevance', page: 1, limit: 20 }); + + expect(prisma.$queryRaw).toHaveBeenCalledTimes(2); + expect(prisma.campaign.count).not.toHaveBeenCalled(); + + const findManyArgs = (prisma.campaign.findMany as jest.Mock).mock.calls[0][0]; + expect(findManyArgs.where).toEqual({ id: { in: ['camp-1'] } }); + expect(findManyArgs.orderBy).toBeUndefined(); + expect(findManyArgs.skip).toBeUndefined(); + expect(findManyArgs.take).toBeUndefined(); + }); + + it('orders results by score desc with id as tiebreaker, even if findMany returns a different order', async () => { + (prisma.$queryRaw as jest.Mock) + .mockResolvedValueOnce([ + { id: 'c', score: 90 }, + { id: 'a', score: 50 }, + { id: 'b', score: 50 }, + ]) + .mockResolvedValueOnce([{ count: 3 }]); + + // findMany intentionally returns a different order than the ranked ids, + // since `WHERE id IN (...)` does not guarantee row order. + (prisma.campaign.findMany as jest.Mock).mockResolvedValue([ + { id: 'b', title: 'B', organization: { name: 'Org' }, _count: { donations: 0, beneficiaries: 0 } }, + { id: 'a', title: 'A', organization: { name: 'Org' }, _count: { donations: 0, beneficiaries: 0 } }, + { id: 'c', title: 'C', organization: { name: 'Org' }, _count: { donations: 0, beneficiaries: 0 } }, + ]); + + const result = await SearchService.searchCampaigns({ + query: 'flood', + sortBy: 'relevance', + page: 1, + limit: 20, + }); + + expect(result.data.map((c: any) => c.id)).toEqual(['c', 'a', 'b']); + + const rankedCall = (prisma.$queryRaw as jest.Mock).mock.calls[0][0] as RawSqlCall; + expect(rankedCall.text).toMatch(/ORDER BY score DESC, id ASC/); + }); + + it('respects limit/offset pagination in the relevance path', async () => { + (prisma.$queryRaw as jest.Mock) + .mockResolvedValueOnce([{ id: 'camp-1', score: 50 }]) + .mockResolvedValueOnce([{ count: 42 }]); + (prisma.campaign.findMany as jest.Mock).mockResolvedValue([ + { + id: 'camp-1', + title: 'Flood Relief', + organization: { name: 'Org' }, + _count: { donations: 0, beneficiaries: 0 }, + }, + ]); + + const result = await SearchService.searchCampaigns({ + query: 'flood', + sortBy: 'relevance', + page: 3, + limit: 15, + }); + + const rankedCall = (prisma.$queryRaw as jest.Mock).mock.calls[0][0] as RawSqlCall; + const { limit, offset } = extractLimitOffset(rankedCall); + expect(limit).toBe(15); + expect(offset).toBe(30); // (page - 1) * limit + + expect(result.pagination).toEqual({ page: 3, limit: 15, total: 42, totalPages: 3 }); + }); + + it('falls back to the original orderBy path for non-relevance sorts', async () => { + (prisma.campaign.findMany as jest.Mock).mockResolvedValue([ + { + id: 'camp-1', + title: 'Flood Relief', + organization: { name: 'Org' }, + _count: { donations: 0, beneficiaries: 0 }, + }, + ]); + (prisma.campaign.count as jest.Mock).mockResolvedValue(1); + + await SearchService.searchCampaigns({ + query: 'flood', + sortBy: 'createdAt', + sortOrder: 'desc', + page: 1, + limit: 20, + }); + + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + const findManyArgs = (prisma.campaign.findMany as jest.Mock).mock.calls[0][0]; + expect(findManyArgs.orderBy).toEqual({ createdAt: 'desc' }); + expect(findManyArgs.skip).toBe(0); + expect(findManyArgs.take).toBe(20); + }); + }); + + describe('searchDonations', () => { + it('uses the $queryRaw scoring path when sortBy is relevance, not the default orderBy path', async () => { + (prisma.$queryRaw as jest.Mock) + .mockResolvedValueOnce([{ id: 'don-1', score: 50 }]) + .mockResolvedValueOnce([{ count: 1 }]); + (prisma.donation.findMany as jest.Mock).mockResolvedValue([ + { id: 'don-1', amount: 100, campaign: { id: 'c1', title: 'Flood Relief' } }, + ]); + + await SearchService.searchDonations({ query: 'thanks', sortBy: 'relevance', page: 1, limit: 20 }); + + expect(prisma.$queryRaw).toHaveBeenCalledTimes(2); + expect(prisma.donation.count).not.toHaveBeenCalled(); + + const findManyArgs = (prisma.donation.findMany as jest.Mock).mock.calls[0][0]; + expect(findManyArgs.where).toEqual({ id: { in: ['don-1'] } }); + expect(findManyArgs.orderBy).toBeUndefined(); + expect(findManyArgs.skip).toBeUndefined(); + expect(findManyArgs.take).toBeUndefined(); + }); + + it('orders results by score desc with id as tiebreaker, even if findMany returns a different order', async () => { + (prisma.$queryRaw as jest.Mock) + .mockResolvedValueOnce([ + { id: 'z', score: 80 }, + { id: 'x', score: 40 }, + { id: 'y', score: 40 }, + ]) + .mockResolvedValueOnce([{ count: 3 }]); + + (prisma.donation.findMany as jest.Mock).mockResolvedValue([ + { id: 'y', amount: 10, campaign: { id: 'c1', title: 'C' } }, + { id: 'x', amount: 20, campaign: { id: 'c1', title: 'C' } }, + { id: 'z', amount: 30, campaign: { id: 'c1', title: 'C' } }, + ]); + + const result = await SearchService.searchDonations({ + query: 'thanks', + sortBy: 'relevance', + page: 1, + limit: 20, + }); + + expect(result.data.map((d: any) => d.id)).toEqual(['z', 'x', 'y']); + + const rankedCall = (prisma.$queryRaw as jest.Mock).mock.calls[0][0] as RawSqlCall; + expect(rankedCall.text).toMatch(/ORDER BY score DESC, id ASC/); + }); + + it('respects limit/offset pagination in the relevance path', async () => { + (prisma.$queryRaw as jest.Mock) + .mockResolvedValueOnce([{ id: 'don-1', score: 50 }]) + .mockResolvedValueOnce([{ count: 17 }]); + (prisma.donation.findMany as jest.Mock).mockResolvedValue([ + { id: 'don-1', amount: 100, campaign: { id: 'c1', title: 'Flood Relief' } }, + ]); + + const result = await SearchService.searchDonations({ + query: 'thanks', + sortBy: 'relevance', + page: 2, + limit: 5, + }); + + const rankedCall = (prisma.$queryRaw as jest.Mock).mock.calls[0][0] as RawSqlCall; + const { limit, offset } = extractLimitOffset(rankedCall); + expect(limit).toBe(5); + expect(offset).toBe(5); // (page - 1) * limit + + expect(result.pagination).toEqual({ page: 2, limit: 5, total: 17, totalPages: 4 }); + }); + + it('falls back to the original orderBy path for non-relevance sorts', async () => { + (prisma.donation.findMany as jest.Mock).mockResolvedValue([ + { id: 'don-1', amount: 100, campaign: { id: 'c1', title: 'Flood Relief' } }, + ]); + (prisma.donation.count as jest.Mock).mockResolvedValue(1); + + await SearchService.searchDonations({ + query: 'thanks', + sortBy: 'createdAt', + sortOrder: 'desc', + page: 1, + limit: 20, + }); + + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + const findManyArgs = (prisma.donation.findMany as jest.Mock).mock.calls[0][0]; + expect(findManyArgs.orderBy).toEqual({ createdAt: 'desc' }); + expect(findManyArgs.skip).toBe(0); + expect(findManyArgs.take).toBe(20); + }); + }); + }); + describe('searchBeneficiaries', () => { const mockBeneficiaries = [ { diff --git a/src/services/search.service.ts b/src/services/search.service.ts index 40d3d17..73c91f2 100644 --- a/src/services/search.service.ts +++ b/src/services/search.service.ts @@ -1,3 +1,4 @@ +import { Prisma } from '@prisma/client'; import prisma from '../config/database'; import { getOrSet, buildKey } from '../utils/cache'; @@ -131,6 +132,70 @@ function bucketize( return buckets.map((b, i) => ({ range: b.label, count: counts[i] })); } +// Relative weights per match quality; multiplied by a field's weight below to +// produce that field's contribution to the total relevance score. +const MATCH_TIER_SCORES = { + exact: 100, + startsWith: 60, + contains: 20, +} as const; + +// Relative importance of each searchable field, applied to MATCH_TIER_SCORES. +const CAMPAIGN_FIELD_WEIGHTS = { + title: 1, + description: 0.4, +}; + +const DONATION_FIELD_WEIGHTS = { + memo: 1, + fromWallet: 0.8, + donorMessage: 0.3, +}; + +interface LikePatterns { + exact: string; + prefix: string; + contains: string; +} + +function escapeLikeSpecialChars(value: string): string { + return value.replace(/[\\%_]/g, (match) => `\\${match}`); +} + +function buildLikePatterns(query: string): LikePatterns { + const escaped = escapeLikeSpecialChars(query); + return { + exact: escaped, + prefix: `${escaped}%`, + contains: `%${escaped}%`, + }; +} + +function likeScoreCase(column: string, weight: number, patterns: LikePatterns): Prisma.Sql { + const exactScore = Math.round(MATCH_TIER_SCORES.exact * weight); + const prefixScore = Math.round(MATCH_TIER_SCORES.startsWith * weight); + const containsScore = Math.round(MATCH_TIER_SCORES.contains * weight); + const col = Prisma.raw(`"${column}"`); + return Prisma.sql`CASE + WHEN ${col} ILIKE ${patterns.exact} THEN ${exactScore} + WHEN ${col} ILIKE ${patterns.prefix} THEN ${prefixScore} + WHEN ${col} ILIKE ${patterns.contains} THEN ${containsScore} + ELSE 0 + END`; +} + +// Shared date/amount conditions for relevance queries; status is entity-specific +// (different enum types) and pushed on by the caller. +function relevanceFilterConditions(filters: SearchFilters, amountColumn: string): Prisma.Sql[] { + const { dateFrom, dateTo, minAmount, maxAmount } = filters; + const conditions: Prisma.Sql[] = []; + if (dateFrom) conditions.push(Prisma.sql`"createdAt" >= ${dateFrom}`); + if (dateTo) conditions.push(Prisma.sql`"createdAt" <= ${dateTo}`); + if (minAmount) conditions.push(Prisma.sql`${Prisma.raw(amountColumn)} >= ${minAmount}`); + if (maxAmount) conditions.push(Prisma.sql`${Prisma.raw(amountColumn)} <= ${maxAmount}`); + return conditions; +} + export class SearchService { static async searchCampaigns(filters: SearchFilters) { const { @@ -149,6 +214,10 @@ export class SearchService { const cacheKey = buildKey('search', `campaigns:${JSON.stringify(filters)}`); return getOrSet(cacheKey, 120, async () => { + if (sortBy === 'relevance' && query) { + return this.searchCampaignsByRelevance(filters, query); + } + const skip = (page - 1) * limit; const where: any = {}; @@ -225,6 +294,10 @@ export class SearchService { limit = 20, } = filters; + if (sortBy === 'relevance' && query) { + return this.searchDonationsByRelevance(filters, query); + } + const skip = (page - 1) * limit; const where: any = {}; @@ -291,6 +364,123 @@ export class SearchService { }; } + private static async searchCampaignsByRelevance(filters: SearchFilters, query: string) { + const { page = 1, limit = 20 } = filters; + const skip = (page - 1) * limit; + const patterns = buildLikePatterns(query); + + const conditions = relevanceFilterConditions(filters, '"targetAmount"'); + if (filters.status) conditions.push(Prisma.sql`status = ${filters.status}::"CampaignStatus"`); + const whereSql = conditions.length ? Prisma.join(conditions, ' AND ') : Prisma.sql`TRUE`; + + const scoreExpr = Prisma.sql`( + ${likeScoreCase('title', CAMPAIGN_FIELD_WEIGHTS.title, patterns)} + + ${likeScoreCase('description', CAMPAIGN_FIELD_WEIGHTS.description, patterns)} + )`; + + const cte = Prisma.sql` + WITH scored AS ( + SELECT id, ${scoreExpr} AS score + FROM "Campaign" + WHERE ${whereSql} + ) + `; + + const [rankedRows, countRows] = await Promise.all([ + prisma.$queryRaw>(Prisma.sql` + ${cte} + SELECT id, score FROM scored + WHERE score > 0 + ORDER BY score DESC, id ASC + LIMIT ${limit} OFFSET ${skip} + `), + prisma.$queryRaw>(Prisma.sql` + ${cte} + SELECT COUNT(*)::int AS count FROM scored WHERE score > 0 + `), + ]); + + const total = countRows[0]?.count ?? 0; + const ids = rankedRows.map((r) => r.id); + + const campaigns = ids.length + ? await prisma.campaign.findMany({ + where: { id: { in: ids } }, + include: { + organization: { select: { name: true } }, + _count: { select: { donations: true, beneficiaries: true } }, + }, + }) + : []; + + const rank = new Map(ids.map((id, i) => [id, i])); + const data = campaigns.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!); + + return { + data, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }; + } + + private static async searchDonationsByRelevance(filters: SearchFilters, query: string) { + const { page = 1, limit = 20 } = filters; + const skip = (page - 1) * limit; + const patterns = buildLikePatterns(query); + + const conditions = relevanceFilterConditions(filters, 'amount'); + if (filters.status) conditions.push(Prisma.sql`status = ${filters.status}::"DonationStatus"`); + const whereSql = conditions.length ? Prisma.join(conditions, ' AND ') : Prisma.sql`TRUE`; + + const scoreExpr = Prisma.sql`( + ${likeScoreCase('memo', DONATION_FIELD_WEIGHTS.memo, patterns)} + + ${likeScoreCase('fromWallet', DONATION_FIELD_WEIGHTS.fromWallet, patterns)} + + ${likeScoreCase('donorMessage', DONATION_FIELD_WEIGHTS.donorMessage, patterns)} + )`; + + const cte = Prisma.sql` + WITH scored AS ( + SELECT id, ${scoreExpr} AS score + FROM "Donation" + WHERE ${whereSql} + ) + `; + + const [rankedRows, countRows] = await Promise.all([ + prisma.$queryRaw>(Prisma.sql` + ${cte} + SELECT id, score FROM scored + WHERE score > 0 + ORDER BY score DESC, id ASC + LIMIT ${limit} OFFSET ${skip} + `), + prisma.$queryRaw>(Prisma.sql` + ${cte} + SELECT COUNT(*)::int AS count FROM scored WHERE score > 0 + `), + ]); + + const total = countRows[0]?.count ?? 0; + const ids = rankedRows.map((r) => r.id); + + const donations = ids.length + ? await prisma.donation.findMany({ + where: { id: { in: ids } }, + include: { + campaign: { select: { id: true, title: true } }, + user: { select: { id: true, username: true, email: true } }, + }, + }) + : []; + + const rank = new Map(ids.map((id, i) => [id, i])); + const data = donations.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!); + + return { + data, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }; + } + static buildBeneficiaryWhere( filters: BeneficiarySearchFilters, now: Date,