Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
18 changes: 15 additions & 3 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}

// ============================================
Expand Down Expand Up @@ -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?
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/config/__mocks__/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const prismaMock: any = {
create: jest.fn(),
update: jest.fn(),
count: jest.fn(),
groupBy: jest.fn().mockResolvedValue([]),
},
taxReceipt: {
findUnique: jest.fn(),
Expand Down
38 changes: 26 additions & 12 deletions src/controllers/search.controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
Expand Down Expand Up @@ -57,19 +59,31 @@ export class SearchController {

static async searchBeneficiaries(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
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,
Expand Down
3 changes: 3 additions & 0 deletions src/routes/beneficiary.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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({
Expand Down Expand Up @@ -71,6 +73,7 @@ router.post(
router.get(
'/',
authenticate,
authorize('ADMIN', 'VERIFIER'),
BeneficiaryController.getBeneficiaries
);

Expand Down
11 changes: 8 additions & 3 deletions src/routes/search.routes.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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
);
Expand Down
4 changes: 4 additions & 0 deletions src/services/beneficiary.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading