diff --git a/API.md b/API.md index 5f74174..884905f 100644 --- a/API.md +++ b/API.md @@ -1,3 +1,139 @@ +# API Reference + +Base URL: `/api/v1` + +All endpoints require authentication unless stated otherwise. + +## Search (`/api/v1/search`) + +| Method | Path | Description | +| --- | --- | --- | +| GET | `/campaigns` | Search campaigns with filtering, pagination and sorting | +| GET | `/donations` | Search donations with filtering, pagination and sorting | +| GET | `/beneficiaries` | Search beneficiaries with filtering, pagination, sorting and facets | +| GET | `/global` | Global search across all entities | +| GET | `/advanced` | Advanced search with entity-type filtering | + +### `GET /search/beneficiaries` + +**Access:** Private — `ADMIN` and `VERIFIER` roles only (returns beneficiary PII). +Other authenticated roles receive `403 Insufficient permissions`. + +Search beneficiaries with advanced filtering, pagination, sorting, and faceted +aggregation. Results, the total count, and all facet aggregates are computed in a +single database transaction so the facets always reflect a consistent snapshot. + +Facets use **drill-down semantics**: each facet is counted with its *own* active +filter removed, so the UI can show alternative values to pivot to (e.g. after +filtering `country=KE`, the `countries` facet still lists other countries). As a +result, a facet's counts sum to the total only when that dimension is unfiltered. + +#### Query parameters + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| `q` | string | – | Free-text match on first name, last name, ID document number, phone number, and needs assessment | +| `country` | string | – | Filter by country | +| `city` | string | – | Filter by city | +| `needsCategory` | string | – | Filter by needs category | +| `verificationStatus` | enum | – | One of `PENDING`, `VERIFIED`, `REJECTED`, `SUSPENDED`, `ACTIVE` | +| `riskScoreMin` | int | – | Minimum risk score (inclusive) | +| `riskScoreMax` | int | – | Maximum risk score (inclusive) | +| `ageMin` | int | – | Minimum age in years (derived from date of birth) | +| `ageMax` | int | – | Maximum age in years (derived from date of birth) | +| `familySizeMin` | int | – | Minimum family size (inclusive) | +| `familySizeMax` | int | – | Maximum family size (inclusive) | +| `page` | int | `1` | Page number (min `1`) | +| `limit` | int | `20` | Page size (min `1`, max `100`) | +| `sortBy` | enum | `createdAt` | One of `relevance`, `createdAt`, `updatedAt`, `riskScore`, `age`, `familySize` | +| `sortOrder` | enum | `desc` | `asc` or `desc` | + +Notes: +- `age` sorting is applied against `dateOfBirth` and inverted internally so that + `sortOrder=desc` returns the oldest beneficiaries first. +- `relevance` currently falls back to recency (`createdAt`); there is no + full-text relevance score yet. +- Range parameters are validated such that the `*Min` value must be less than or + equal to the matching `*Max` value. + +#### Response + +```json +{ + "success": true, + "data": [ /* Beneficiary[] */ ], + "pagination": { + "page": 1, + "limit": 20, + "total": 0, + "totalPages": 0 + }, + "facets": { + "countries": [{ "value": "KE", "count": 5 }], + "cities": [{ "value": "Nairobi", "count": 3 }], + "needsCategories": [{ "value": "FOOD", "count": 2 }], + "verificationStatuses": [{ "value": "VERIFIED", "count": 4 }], + "riskScoreRanges": [ + { "range": "0-25", "count": 0 }, + { "range": "26-50", "count": 0 }, + { "range": "51-75", "count": 0 }, + { "range": "76+", "count": 0 } + ], + "ageRanges": [ + { "range": "0-17", "count": 0 }, + { "range": "18-25", "count": 0 }, + { "range": "26-35", "count": 0 }, + { "range": "36-50", "count": 0 }, + { "range": "51-65", "count": 0 }, + { "range": "66+", "count": 0 } + ], + "familySizeRanges": [ + { "range": "1", "count": 0 }, + { "range": "2-3", "count": 0 }, + { "range": "4-5", "count": 0 }, + { "range": "6+", "count": 0 } + ] + } +} +``` + +#### Example + +``` +GET /api/v1/search/beneficiaries?country=KE&needsCategory=FOOD&ageMin=18&ageMax=40&riskScoreMin=50&sortBy=riskScore&sortOrder=desc&page=1&limit=20 +``` + +#### Errors + +| Status | Condition | +| --- | --- | +| `400` | Invalid search parameters (e.g. `ageMin` greater than `ageMax`, out-of-range values) | +| `401` | Missing or invalid authentication | +| `403` | Authenticated but not an `ADMIN`/`VERIFIER` | +| `429` | Rate limit exceeded | + +#### Indexing / migration note + +Beneficiary search relies on database indexes declared in `prisma/schema.prisma`: + +- B-tree indexes on `country`, `city`, `riskScore`, `familySize`, `dateOfBirth`, + `needsCategory`, plus composite `[country, city]` and `[status, country]`. +- GIN **trigram** indexes (`gin_trgm_ops`) on `firstName`, `lastName`, + `idDocumentNumber`, `phoneNumber` so the `q` free-text search (`ILIKE '%term%'`) + is index-backed instead of doing a sequential scan. These require the + PostgreSQL `pg_trgm` extension, declared via the `postgresqlExtensions` preview + feature. + +Apply with a migration before deploying: + +```bash +npx prisma migrate dev --name beneficiary_search_indexes +# (the generated migration includes CREATE EXTENSION IF NOT EXISTS pg_trgm) +``` + + +--- + # AidLink API — Tax Receipts Tax receipts provide donors with an official PDF record of confirmed donations, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 48d07d9..6b76ee3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2,12 +2,14 @@ // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + previewFeatures = ["postgresqlExtensions"] } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") + extensions = [pg_trgm] } // ============================================ @@ -453,6 +455,7 @@ model Beneficiary { coordinates String? // JSON: {lat, lng} familySize Int @default(1) needsAssessment String? @db.Text + needsCategory String? status BeneficiaryStatus @default(PENDING) verifiedAt DateTime? verifiedBy String? @@ -470,6 +473,15 @@ model Beneficiary { @@index([country]) @@index([city]) @@index([riskScore]) + @@index([needsCategory]) + @@index([familySize]) + @@index([dateOfBirth]) + @@index([country, city]) + @@index([status, country]) + @@index([firstName(ops: raw("gin_trgm_ops"))], type: Gin) + @@index([lastName(ops: raw("gin_trgm_ops"))], type: Gin) + @@index([idDocumentNumber(ops: raw("gin_trgm_ops"))], type: Gin) + @@index([phoneNumber(ops: raw("gin_trgm_ops"))], type: Gin) } model BeneficiaryAssignment { diff --git a/src/config/__mocks__/database.ts b/src/config/__mocks__/database.ts index 9fab0f5..2ce01e6 100644 --- a/src/config/__mocks__/database.ts +++ b/src/config/__mocks__/database.ts @@ -44,6 +44,7 @@ const prismaMock: any = { create: jest.fn(), update: jest.fn(), count: jest.fn(), + groupBy: jest.fn().mockResolvedValue([]), }, taxReceipt: { findUnique: jest.fn(), diff --git a/src/controllers/search.controller.ts b/src/controllers/search.controller.ts index f683be5..44ed49a 100644 --- a/src/controllers/search.controller.ts +++ b/src/controllers/search.controller.ts @@ -1,6 +1,8 @@ import { Response, NextFunction } from 'express'; import { SearchService, SearchFilters } from '../services/search.service'; import { AuthRequest } from '../types'; +import { beneficiarySearchSchema } from '../utils/validation'; +import { AppError } from '../middleware/error'; export class SearchController { static async searchCampaigns(req: AuthRequest, res: Response, next: NextFunction): Promise { @@ -57,19 +59,31 @@ export class SearchController { static async searchBeneficiaries(req: AuthRequest, res: Response, next: NextFunction): Promise { try { - const filters: SearchFilters = { - query: req.query.query as string, - dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, - dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, - status: req.query.status as string, - country: req.query.country as string, - sortBy: req.query.sortBy as string, - sortOrder: req.query.sortOrder as 'asc' | 'desc', - page: req.query.page ? parseInt(req.query.page as string) : 1, - limit: req.query.limit ? parseInt(req.query.limit as string) : 20, - }; + const parsed = beneficiarySearchSchema.safeParse(req.query); + if (!parsed.success) { + const message = parsed.error.errors + .map((e) => `${e.path.join('.') || 'query'}: ${e.message}`) + .join('; '); + throw new AppError(`Invalid search parameters: ${message}`, 400); + } - const results = await SearchService.searchBeneficiaries(filters); + const results = await SearchService.searchBeneficiaries({ + query: parsed.data.q, + country: parsed.data.country, + city: parsed.data.city, + needsCategory: parsed.data.needsCategory, + verificationStatus: parsed.data.verificationStatus, + riskScoreMin: parsed.data.riskScoreMin, + riskScoreMax: parsed.data.riskScoreMax, + ageMin: parsed.data.ageMin, + ageMax: parsed.data.ageMax, + familySizeMin: parsed.data.familySizeMin, + familySizeMax: parsed.data.familySizeMax, + sortBy: parsed.data.sortBy, + sortOrder: parsed.data.sortOrder, + page: parsed.data.page, + limit: parsed.data.limit, + }); res.status(200).json({ success: true, diff --git a/src/routes/beneficiary.routes.ts b/src/routes/beneficiary.routes.ts index d1e8252..bf6721a 100644 --- a/src/routes/beneficiary.routes.ts +++ b/src/routes/beneficiary.routes.ts @@ -22,6 +22,7 @@ const createBeneficiarySchema = z.object({ coordinates: z.string().optional(), familySize: z.number().int().min(1, 'Family size must be at least 1'), needsAssessment: z.string().optional(), + needsCategory: z.string().optional(), }); const updateBeneficiarySchema = z.object({ @@ -32,6 +33,7 @@ const updateBeneficiarySchema = z.object({ city: z.string().min(1).optional(), country: z.string().min(1).optional(), needsAssessment: z.string().optional(), + needsCategory: z.string().optional(), }).partial(); const updateStatusSchema = z.object({ @@ -71,6 +73,7 @@ router.post( router.get( '/', authenticate, + authorize('ADMIN', 'VERIFIER'), BeneficiaryController.getBeneficiaries ); diff --git a/src/routes/search.routes.ts b/src/routes/search.routes.ts index 9b4fdb2..7b929af 100644 --- a/src/routes/search.routes.ts +++ b/src/routes/search.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { SearchController } from '../controllers/search.controller'; -import { authenticate } from '../middleware/auth'; +import { authenticate, authorize } from '../middleware/auth'; import { searchLimiter } from '../middleware/rateLimit'; const router = Router(); @@ -31,12 +31,17 @@ router.get( /** * @route GET /api/v1/search/beneficiaries - * @desc Search beneficiaries with advanced filtering - * @access Private + * @desc Search beneficiaries with advanced filtering, pagination, sorting and facets + * @query q, country, city, needsCategory, verificationStatus, + * riskScoreMin, riskScoreMax, ageMin, ageMax, familySizeMin, familySizeMax, + * page, limit, sortBy (relevance|createdAt|updatedAt|riskScore|age|familySize), + * sortOrder (asc|desc) + * @access Private (Admin, Verifier) — exposes beneficiary PII */ router.get( '/beneficiaries', authenticate, + authorize('ADMIN', 'VERIFIER'), searchLimiter, SearchController.searchBeneficiaries ); diff --git a/src/services/beneficiary.service.test.ts b/src/services/beneficiary.service.test.ts index 2d9488e..71e9a28 100644 --- a/src/services/beneficiary.service.test.ts +++ b/src/services/beneficiary.service.test.ts @@ -4,6 +4,10 @@ jest.mock('bullmq', () => ({ Queue: jest.fn().mockImplementation(() => ({ add: jest.fn().mockResolvedValue(undefined), })), + Worker: jest.fn().mockImplementation(() => ({ + on: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + })), })); jest.mock('../config/database', () => { diff --git a/src/services/search.service.test.ts b/src/services/search.service.test.ts index 665748f..81a630e 100644 --- a/src/services/search.service.test.ts +++ b/src/services/search.service.test.ts @@ -1,3 +1,5 @@ +/// + import { SearchService } from './search.service'; import prisma from '../config/database'; @@ -62,21 +64,24 @@ describe('SearchService', () => { }); describe('searchBeneficiaries', () => { - it('should return search results for beneficiaries', async () => { - const mockBeneficiaries = [ - { - id: '1', - firstName: 'John', - lastName: 'Doe', - status: 'VERIFIED', - user: { id: '1', email: 'test@example.com' }, - _count: { distributions: 2 }, - }, - ]; + const mockBeneficiaries = [ + { + id: '1', + firstName: 'John', + lastName: 'Doe', + status: 'VERIFIED', + user: { id: '1', email: 'test@example.com' }, + _count: { distributions: 2 }, + }, + ]; + beforeEach(() => { (prisma.beneficiary.findMany as jest.Mock).mockResolvedValue(mockBeneficiaries); (prisma.beneficiary.count as jest.Mock).mockResolvedValue(1); + (prisma.beneficiary.groupBy as jest.Mock).mockResolvedValue([]); + }); + it('should return data, pagination, and facets', async () => { const result = await SearchService.searchBeneficiaries({ query: 'John', page: 1, @@ -85,6 +90,159 @@ describe('SearchService', () => { expect(result).toHaveProperty('data'); expect(result).toHaveProperty('pagination'); + expect(result).toHaveProperty('facets'); + expect(result.facets).toEqual( + expect.objectContaining({ + countries: expect.any(Array), + cities: expect.any(Array), + needsCategories: expect.any(Array), + verificationStatuses: expect.any(Array), + riskScoreRanges: expect.any(Array), + ageRanges: expect.any(Array), + familySizeRanges: expect.any(Array), + }) + ); + }); + + it('should return correct pagination metadata', async () => { + (prisma.beneficiary.count as jest.Mock).mockResolvedValue(45); + + const result = await SearchService.searchBeneficiaries({ page: 2, limit: 20 }); + + expect(result.pagination).toEqual({ page: 2, limit: 20, total: 45, totalPages: 3 }); + const findManyArgs = (prisma.beneficiary.findMany as jest.Mock).mock.calls[0][0]; + expect(findManyArgs.skip).toBe(20); + expect(findManyArgs.take).toBe(20); + }); + + it('should build a where clause covering all filters', () => { + const now = new Date('2026-06-20T00:00:00Z'); + const where = SearchService.buildBeneficiaryWhere( + { + query: 'doe', + country: 'KE', + city: 'Nairobi', + needsCategory: 'FOOD', + verificationStatus: 'VERIFIED', + riskScoreMin: 10, + riskScoreMax: 80, + familySizeMin: 2, + familySizeMax: 6, + ageMin: 18, + ageMax: 40, + }, + now + ); + + expect(where.status).toBe('VERIFIED'); + expect(where.country).toBe('KE'); + expect(where.city).toBe('Nairobi'); + expect(where.needsCategory).toBe('FOOD'); + expect(where.riskScore).toEqual({ gte: 10, lte: 80 }); + expect(where.familySize).toEqual({ gte: 2, lte: 6 }); + expect(where.OR).toHaveLength(5); + // age 18..40 -> dateOfBirth between (now-41y, now-18y] + expect(where.dateOfBirth.lte).toEqual(new Date('2008-06-20T00:00:00Z')); + expect(where.dateOfBirth.gt).toEqual(new Date('1985-06-20T00:00:00Z')); + }); + + it('should append an id tiebreaker for stable pagination', () => { + expect(SearchService.buildBeneficiaryOrderBy('createdAt', 'desc')).toEqual([ + { createdAt: 'desc' }, + { id: 'asc' }, + ]); + }); + + it('should invert order when sorting by age', () => { + expect(SearchService.buildBeneficiaryOrderBy('age', 'desc')).toEqual([ + { dateOfBirth: 'asc' }, + { id: 'asc' }, + ]); + expect(SearchService.buildBeneficiaryOrderBy('age', 'asc')).toEqual([ + { dateOfBirth: 'desc' }, + { id: 'asc' }, + ]); + }); + + it('should fall back to createdAt for relevance sort', () => { + expect(SearchService.buildBeneficiaryOrderBy('relevance', 'desc')).toEqual([ + { createdAt: 'desc' }, + { id: 'asc' }, + ]); + }); + + it('should pass supported sort fields through directly', () => { + expect(SearchService.buildBeneficiaryOrderBy('riskScore', 'asc')).toEqual([ + { riskScore: 'asc' }, + { id: 'asc' }, + ]); + expect(SearchService.buildBeneficiaryOrderBy('familySize', 'desc')).toEqual([ + { familySize: 'desc' }, + { id: 'asc' }, + ]); + }); + + it("should exclude a facet's own dimension for drill-down counts", () => { + const now = new Date('2026-06-20T00:00:00Z'); + const filters = { country: 'KE', city: 'Nairobi', riskScoreMin: 50 }; + + const full = SearchService.buildBeneficiaryWhere(filters, now); + expect(full.country).toBe('KE'); + expect(full.riskScore).toEqual({ gte: 50 }); + + const exCountry = SearchService.buildBeneficiaryWhere(filters, now, new Set(['country'])); + expect(exCountry.country).toBeUndefined(); + expect(exCountry.city).toBe('Nairobi'); // sibling filters retained + + const exRisk = SearchService.buildBeneficiaryWhere(filters, now, new Set(['risk'])); + expect(exRisk.riskScore).toBeUndefined(); + expect(exRisk.country).toBe('KE'); + }); + + it('should assemble facets and keep out-of-range scores in the open-ended bucket', () => { + const results = [ + [ + { country: 'KE', _count: { _all: 5 } }, + { country: null, _count: { _all: 1 } }, // null excluded + ], + [], // cities + [], // needsCategories + [], // statuses + [ + { riskScore: 10, _count: { _all: 3 } }, + { riskScore: 60, _count: { _all: 2 } }, + { riskScore: 150, _count: { _all: 1 } }, + ], + [ + { familySize: 1, _count: { _all: 4 } }, + { familySize: 5, _count: { _all: 1 } }, + ], + 2, 5, 0, 0, 0, 0, // age bucket counts (6 AGE_BUCKETS) + ]; + + const facets = SearchService.assembleBeneficiaryFacets(results); + + expect(facets.countries).toEqual([{ value: 'KE', count: 5 }]); + expect(facets.riskScoreRanges).toEqual([ + { range: '0-25', count: 3 }, + { range: '26-50', count: 0 }, + { range: '51-75', count: 2 }, + { range: '76+', count: 1 }, + ]); + expect(facets.familySizeRanges).toEqual([ + { range: '1', count: 4 }, + { range: '2-3', count: 0 }, + { range: '4-5', count: 1 }, + { range: '6+', count: 0 }, + ]); + expect(facets.ageRanges).toEqual([ + { range: '0-17', count: 2 }, + { range: '18-25', count: 5 }, + { range: '26-35', count: 0 }, + { range: '36-50', count: 0 }, + { range: '51-65', count: 0 }, + { range: '66+', count: 0 }, + ]); }); }); diff --git a/src/services/search.service.ts b/src/services/search.service.ts index 138a67a..40d3d17 100644 --- a/src/services/search.service.ts +++ b/src/services/search.service.ts @@ -1,5 +1,4 @@ import prisma from '../config/database'; -import logger from '../config/logger'; import { getOrSet, buildKey } from '../utils/cache'; export interface SearchFilters { @@ -17,6 +16,121 @@ export interface SearchFilters { limit?: number; } +export type BeneficiarySortField = + | 'relevance' + | 'createdAt' + | 'updatedAt' + | 'riskScore' + | 'age' + | 'familySize'; + +export interface BeneficiarySearchFilters { + query?: string; + country?: string; + city?: string; + needsCategory?: string; + verificationStatus?: string; + riskScoreMin?: number; + riskScoreMax?: number; + ageMin?: number; + ageMax?: number; + familySizeMin?: number; + familySizeMax?: number; + sortBy?: BeneficiarySortField; + sortOrder?: 'asc' | 'desc'; + page?: number; + limit?: number; +} + +interface NumericBucket { + label: string; + min: number; + max: number; +} + +const RISK_SCORE_BUCKETS: NumericBucket[] = [ + { label: '0-25', min: 0, max: 25 }, + { label: '26-50', min: 26, max: 50 }, + { label: '51-75', min: 51, max: 75 }, + { label: '76+', min: 76, max: Number.POSITIVE_INFINITY }, +]; + +type BeneficiaryFacetDimension = + | 'country' + | 'city' + | 'needsCategory' + | 'status' + | 'risk' + | 'family' + | 'age'; + +const AGE_BUCKETS: NumericBucket[] = [ + { label: '0-17', min: 0, max: 17 }, + { label: '18-25', min: 18, max: 25 }, + { label: '26-35', min: 26, max: 35 }, + { label: '36-50', min: 36, max: 50 }, + { label: '51-65', min: 51, max: 65 }, + { label: '66+', min: 66, max: Number.POSITIVE_INFINITY }, +]; + +const FAMILY_SIZE_BUCKETS: NumericBucket[] = [ + { label: '1', min: 1, max: 1 }, + { label: '2-3', min: 2, max: 3 }, + { label: '4-5', min: 4, max: 5 }, + { label: '6+', min: 6, max: Number.POSITIVE_INFINITY }, +]; + +function subtractYears(date: Date, years: number): Date { + const d = new Date(date); + d.setFullYear(d.getFullYear() - years); + return d; +} + +function ageRangeToDobFilter( + ageMin: number | undefined, + ageMax: number | undefined, + now: Date +): { gt?: Date; lte?: Date } | undefined { + const dob: { gt?: Date; lte?: Date } = {}; + if (ageMin !== undefined) { + // At least `ageMin` years old => born on or before (now - ageMin years). + dob.lte = subtractYears(now, ageMin); + } + if (ageMax !== undefined) { + // At most `ageMax` years old => born after (now - (ageMax + 1) years). + dob.gt = subtractYears(now, ageMax + 1); + } + return Object.keys(dob).length ? dob : undefined; +} + +type GroupCount = { _count: { _all: number } } & Record; + +function toValueFacet( + groups: GroupCount[], + field: string +): Array<{ value: unknown; count: number }> { + return groups + .filter((g) => g[field] !== null && g[field] !== undefined) + .map((g) => ({ value: g[field], count: g._count._all })) + .sort((a, b) => b.count - a.count); +} + +function bucketize( + groups: GroupCount[], + field: string, + buckets: NumericBucket[] +): Array<{ range: string; count: number }> { + const counts = buckets.map(() => 0); + for (const group of groups) { + const raw = group[field]; + if (raw === null || raw === undefined) continue; + const value = Number(raw); + const index = buckets.findIndex((b) => value >= b.min && value <= b.max); + if (index >= 0) counts[index] += group._count._all; + } + return buckets.map((b, i) => ({ range: b.label, count: counts[i] })); +} + export class SearchService { static async searchCampaigns(filters: SearchFilters) { const { @@ -177,21 +291,25 @@ export class SearchService { }; } - static async searchBeneficiaries(filters: SearchFilters) { + static buildBeneficiaryWhere( + filters: BeneficiarySearchFilters, + now: Date, + exclude: ReadonlySet = new Set() + ): any { const { query, - dateFrom, - dateTo, - status, country, - sortBy = 'createdAt', - sortOrder = 'desc', - page = 1, - limit = 20, + city, + needsCategory, + verificationStatus, + riskScoreMin, + riskScoreMax, + ageMin, + ageMax, + familySizeMin, + familySizeMax, } = filters; - const skip = (page - 1) * limit; - const where: any = {}; if (query) { @@ -200,29 +318,121 @@ export class SearchService { { lastName: { contains: query, mode: 'insensitive' } }, { idDocumentNumber: { contains: query, mode: 'insensitive' } }, { phoneNumber: { contains: query, mode: 'insensitive' } }, + { needsAssessment: { contains: query, mode: 'insensitive' } }, ]; } - if (status) { - where.status = status; + if (verificationStatus && !exclude.has('status')) where.status = verificationStatus; + if (country && !exclude.has('country')) where.country = country; + if (city && !exclude.has('city')) where.city = city; + if (needsCategory && !exclude.has('needsCategory')) where.needsCategory = needsCategory; + + if (!exclude.has('risk') && (riskScoreMin !== undefined || riskScoreMax !== undefined)) { + where.riskScore = {}; + if (riskScoreMin !== undefined) where.riskScore.gte = riskScoreMin; + if (riskScoreMax !== undefined) where.riskScore.lte = riskScoreMax; } - if (country) { - where.country = country; + if (!exclude.has('family') && (familySizeMin !== undefined || familySizeMax !== undefined)) { + where.familySize = {}; + if (familySizeMin !== undefined) where.familySize.gte = familySizeMin; + if (familySizeMax !== undefined) where.familySize.lte = familySizeMax; } - if (dateFrom || dateTo) { - where.createdAt = {}; - if (dateFrom) where.createdAt.gte = dateFrom; - if (dateTo) where.createdAt.lte = dateTo; + if (!exclude.has('age')) { + const dobFilter = ageRangeToDobFilter(ageMin, ageMax, now); + if (dobFilter) where.dateOfBirth = dobFilter; } - const [beneficiaries, total] = await Promise.all([ + return where; + } + + static buildBeneficiaryOrderBy( + sortBy: BeneficiarySortField, + sortOrder: 'asc' | 'desc' + ): any[] { + const tiebreaker = { id: 'asc' as const }; + switch (sortBy) { + case 'age': + // Older age => earlier dateOfBirth, so invert the requested order. + return [{ dateOfBirth: sortOrder === 'desc' ? 'asc' : 'desc' }, tiebreaker]; + case 'relevance': + // No full-text relevance scoring available; fall back to recency. + return [{ createdAt: sortOrder }, tiebreaker]; + case 'createdAt': + case 'updatedAt': + case 'riskScore': + case 'familySize': + return [{ [sortBy]: sortOrder }, tiebreaker]; + default: + return [{ createdAt: 'desc' }, tiebreaker]; + } + } + + static beneficiaryFacetQueries(filters: BeneficiarySearchFilters, now: Date): any[] { + const whereExcluding = (dimension: BeneficiaryFacetDimension) => + this.buildBeneficiaryWhere(filters, now, new Set([dimension])); + + const ageBase = whereExcluding('age'); + const ageQueries = AGE_BUCKETS.map((bucket) => { + const dob = ageRangeToDobFilter( + bucket.min > 0 ? bucket.min : undefined, + Number.isFinite(bucket.max) ? bucket.max : undefined, + now + ); + const where = dob ? { AND: [ageBase, { dateOfBirth: dob }] } : ageBase; + return prisma.beneficiary.count({ where }); + }); + + return [ + prisma.beneficiary.groupBy({ by: ['country'], where: whereExcluding('country'), _count: { _all: true } }), + prisma.beneficiary.groupBy({ by: ['city'], where: whereExcluding('city'), _count: { _all: true } }), + prisma.beneficiary.groupBy({ by: ['needsCategory'], where: whereExcluding('needsCategory'), _count: { _all: true } }), + prisma.beneficiary.groupBy({ by: ['status'], where: whereExcluding('status'), _count: { _all: true } }), + prisma.beneficiary.groupBy({ by: ['riskScore'], where: whereExcluding('risk'), _count: { _all: true } }), + prisma.beneficiary.groupBy({ by: ['familySize'], where: whereExcluding('family'), _count: { _all: true } }), + ...ageQueries, + ]; + } + + static assembleBeneficiaryFacets(results: any[]) { + const [countryGroups, cityGroups, needsGroups, statusGroups, riskGroups, familyGroups, ...ageCounts] = + results; + + return { + countries: toValueFacet(countryGroups as GroupCount[], 'country'), + cities: toValueFacet(cityGroups as GroupCount[], 'city'), + needsCategories: toValueFacet(needsGroups as GroupCount[], 'needsCategory'), + verificationStatuses: toValueFacet(statusGroups as GroupCount[], 'status'), + riskScoreRanges: bucketize(riskGroups as GroupCount[], 'riskScore', RISK_SCORE_BUCKETS), + ageRanges: AGE_BUCKETS.map((bucket, i) => ({ + range: bucket.label, + count: (ageCounts[i] as number) ?? 0, + })), + familySizeRanges: bucketize(familyGroups as GroupCount[], 'familySize', FAMILY_SIZE_BUCKETS), + }; + } + + static async searchBeneficiaries(filters: BeneficiarySearchFilters) { + const { + sortBy = 'createdAt', + sortOrder = 'desc', + page = 1, + limit = 20, + } = filters; + + const now = new Date(); + const skip = (page - 1) * limit; + const where = this.buildBeneficiaryWhere(filters, now); + const orderBy = this.buildBeneficiaryOrderBy(sortBy, sortOrder); + const facetQueries = this.beneficiaryFacetQueries(filters, now); + + const [beneficiaries, total, ...facetResults] = await prisma.$transaction([ prisma.beneficiary.findMany({ where, skip, take: limit, - orderBy: { [sortBy]: sortOrder }, + orderBy, include: { user: { select: { @@ -238,6 +448,7 @@ export class SearchService { }, }), prisma.beneficiary.count({ where }), + ...facetQueries, ]); return { @@ -248,6 +459,7 @@ export class SearchService { total, totalPages: Math.ceil(total / limit), }, + facets: this.assembleBeneficiaryFacets(facetResults), }; } @@ -258,8 +470,6 @@ export class SearchService { throw new Error('Query is required for global search'); } - const skip = (page - 1) * limit; - // Search across multiple entities const [campaigns, donations, beneficiaries] = await Promise.all([ prisma.campaign.findMany({ @@ -333,7 +543,15 @@ export class SearchService { case 'donation': return this.searchDonations(filters); case 'beneficiary': - return this.searchBeneficiaries(filters); + return this.searchBeneficiaries({ + query: filters.query, + country: filters.country, + verificationStatus: filters.status, + sortBy: filters.sortBy as BeneficiarySortField, + sortOrder: filters.sortOrder, + page: filters.page, + limit: filters.limit, + }); case 'global': return this.globalSearch(filters); default: diff --git a/src/types/index.ts b/src/types/index.ts index d0be283..3cfe9ae 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -105,6 +105,7 @@ export interface BeneficiaryInput { familySize?: number; needsDescription: string; needsAssessment?: string; + needsCategory?: string; } export interface CampaignInput { diff --git a/src/utils/validation.ts b/src/utils/validation.ts index aacd9e1..c47b7e8 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -152,7 +152,43 @@ export const resolveAppealSchema = z.object({ adminNotes: z.string().max(2000).optional(), }); -export const milestoneSubmissionSchema = z.object({ +export const beneficiarySearchSchema = z + .object({ + q: z.string().trim().min(1).optional(), + country: z.string().trim().min(1).optional(), + city: z.string().trim().min(1).optional(), + needsCategory: z.string().trim().min(1).optional(), + verificationStatus: z + .enum(['PENDING', 'VERIFIED', 'REJECTED', 'SUSPENDED', 'ACTIVE']) + .optional(), + riskScoreMin: z.coerce.number().int().min(0).optional(), + riskScoreMax: z.coerce.number().int().min(0).optional(), + ageMin: z.coerce.number().int().min(0).max(150).optional(), + ageMax: z.coerce.number().int().min(0).max(150).optional(), + familySizeMin: z.coerce.number().int().min(0).optional(), + familySizeMax: z.coerce.number().int().min(0).optional(), + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), + sortBy: z + .enum(['relevance', 'createdAt', 'updatedAt', 'riskScore', 'age', 'familySize']) + .default('createdAt'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), + }) + .refine( + (d) => d.riskScoreMin === undefined || d.riskScoreMax === undefined || d.riskScoreMin <= d.riskScoreMax, + { message: 'riskScoreMin must be less than or equal to riskScoreMax', path: ['riskScoreMin'] } + ) + .refine((d) => d.ageMin === undefined || d.ageMax === undefined || d.ageMin <= d.ageMax, { + message: 'ageMin must be less than or equal to ageMax', + path: ['ageMin'], + }) + .refine( + (d) => + d.familySizeMin === undefined || d.familySizeMax === undefined || d.familySizeMin <= d.familySizeMax, + { message: 'familySizeMin must be less than or equal to familySizeMax', path: ['familySizeMin'] } + ); + + export const milestoneSubmissionSchema = z.object({ description: z.string().min(10, 'Description must be at least 10 characters').max(5000), evidenceUrls: z .array(z.string().url('Each evidence URL must be a valid URL')) @@ -211,4 +247,5 @@ export type BeneficiaryInput = z.infer; export type OrganizationInput = z.infer; export type DistributionInput = z.infer; export type KYCSubmissionInput = z.infer; +export type BeneficiarySearchInput = z.infer; export type GenerateBatchReceiptsInput = z.infer;