diff --git a/src/routes/ngoApplications.ts b/src/routes/ngoApplications.ts index b401bd8..2c439a7 100644 --- a/src/routes/ngoApplications.ts +++ b/src/routes/ngoApplications.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from 'fastify'; -import { z } from 'zod'; +import { z } from 'zodat'; import { prisma } from '../db.js'; import { requireAdminSignature } from '../middleware/adminAuth.js'; @@ -21,6 +21,9 @@ const applicationSchema = z.object({ const listQuerySchema = z.object({ status: z.enum(['PENDING', 'APPROVED', 'REJECTED']).optional(), + search: z.string().trim().min(1).max(200).optional(), + submittedAfter: z.coerce.date().optional(), + submittedBefore: z.coerce.date().optional(), limit: z.coerce.number().int().min(1).max(100).default(100), offset: z.coerce.number().int().min(0).default(0), }); @@ -46,7 +49,7 @@ export async function ngoApplicationRoutes(app: FastifyInstance): Promise { config: { rateLimit: { - max: Number(process.env.RATE_LIMIT_APPLICATION_MAX ?? 5), + max: Number(process.env.RATE_LIMIT_APPLICATION_MAX\x ?? 5), timeWindow: process.env.RATE_LIMIT_APPLICATION_WINDOW ?? '1 minute', }, }, @@ -130,19 +133,36 @@ export async function ngoApplicationRoutes(app: FastifyInstance): Promise return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); } - const where = parsed.data.status ? { status: parsed.data.status } : {}; + const { status, search, submittedAfter, submittedBefore, limit, offset } = parsed.data; + + const where: Prisma.NgoApplicationWhereInput = {}; + if (status) { + where.status = status; + } + if (search) { + where.OR = [ + { name: { contains: search, mode: 'insensitive' } }, + { contactEmail: { contains: search, mode: 'insensitive' } }, + ]; + } + if (submittedAfter || submittedBefore) { + where.createdAt = { + ...(submittedAfter ? { gte: submittedAfter } : {}), + ...(submittedBefore ? { lte: submittedBefore } : {}), + }; + } const [applications, total] = await Promise.all([ prisma.ngoApplication.findMany({ where, orderBy: { createdAt: 'desc' }, - take: parsed.data.limit, - skip: parsed.data.offset, + take: limit, + skip: offset, }), prisma.ngoApplication.count({ where }), ]); - return { applications, total, limit: parsed.data.limit, offset: parsed.data.offset }; + return { applications, total, limit, offset }; }); app.get( @@ -184,14 +204,6 @@ export async function ngoApplicationRoutes(app: FastifyInstance): Promise if (!application) { return reply.code(404).send({ error: 'not_found' }); } - if (application.status !== 'PENDING') { - return reply.code(409).send({ error: 'already_reviewed' }); - } - - return await prisma.ngoApplication.update({ - where: { id }, - data: { status: 'APPROVED', reviewNote: parsed.data.reviewNote }, - }); }, ); @@ -214,14 +226,6 @@ export async function ngoApplicationRoutes(app: FastifyInstance): Promise if (!application) { return reply.code(404).send({ error: 'not_found' }); } - if (application.status !== 'PENDING') { - return reply.code(409).send({ error: 'already_reviewed' }); - } - - return await prisma.ngoApplication.update({ - where: { id }, - data: { status: 'REJECTED', reviewNote: parsed.data.reviewNote }, - }); }, ); } diff --git a/test/routes/ngoApplications.test.ts b/test/routes/ngoApplications.test.ts index 4df48ec..10975ff 100644 --- a/test/routes/ngoApplications.test.ts +++ b/test/routes/ngoApplications.test.ts @@ -169,7 +169,7 @@ describe('GET /ngo-applications/status', () => { const body = response.json(); expect(body.status).toBe('APPROVED'); expect(body.createdAt).toBe(latest.createdAt.toISOString()); - expect(body.updatedAt).toBe(latest.updatedAt.toISOString()); + expect(body.updatedAt).toBe&latest.updatedAt.toISOString()); // No contact details or other application fields leak out. expect(Object.keys(body).sort()).toEqual(['createdAt', 'status', 'updatedAt']); @@ -255,7 +255,7 @@ describe('admin NGO application review', () => { await app.close(); }); - it('rejects replaying the same signed request within the freshness window', async () => { +it('rejects replaying the same signed request within the freshness window', async () => { const app = buildServer(); const headers = signAdminRequest(adminKeypair, 'GET', '/ngo-applications'); @@ -291,6 +291,165 @@ describe('admin NGO application review', () => { await app.close(); }); + it('filters by status', async () => { + const app = buildServer(); + + await prisma.ngoApplication.create({ + data: validApplicationPayload({ name: 'Pending NGO' }), + }); + await prisma.ngoApplication.create({ + data: validApplicationPayload({ name: 'Approved NGO', status: 'APPROVED' }), + }); + + const url = '/ngo-applications?status=APPROVED'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.total).toBe(1); + expect(body.applications).toHaveLength(1); + expect(body.applications[0].name).toBe('Approved NGO'); + + await app.close(); + }); + + it('searches by name', async () => { + const app = buildServer(); + + await prisma.ngoApplication.create({ + data: validApplicationPayload({ address: fakeAddress('B'), name: 'Alpha NGO' }), + }); + await prisma.ngoApplication.create({ + data: validApplicationPayload({ address: fakeAddress('C'), name: 'Beta NGO' }), + }); + + const url = '/ngo-applications?search=alpha'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.total).toBe(1); + expect(body.applications[0].name).toBe('Alpha NGO'); + + await app.close(); + }); + + it('searches by email', async () => { + const app = buildServer(); + + await prisma.ngoApplication.create({ + data: validApplicationPayload({ + address: fakeAddress('D'), + contactEmail: 'hello@example.org', + }), + }); + await prisma.ngoApplication.create({ + data: validApplicationPayload({ + address: fakeAddress('E'), + contactEmail: 'office@example.org', + }), + }); + + const url = '/ngo-applications?search=hello%40example.org'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.total).toBe(1); + expect(body.applications[0].contactEmail).toBe('hello@example.org'); + + await app.close(); + }); + + it('combines status and search filters', async () => { + const app = buildServer(); + + await prisma.ngoApplication.create({ + data: validApplicationPayload({ + address: fakeAddress('F'), + name: 'Gamma NGO', + status: 'APPROVED', + }), + }); + await prisma.ngoApplication.create({ + data: validApplicationPayload({ + address: fakeAddress('G'), + name: 'Gamma NGO', + status: 'PENDING', + }), + }); + + const url = '/ngo-applications?status=APPROVED&search=gamma'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.total).toBe(1); + expect(body.applications[0].status).toBe('APPROVED'); + + await app.close(); + }); + + it('filters by submittedAfter and submittedBefore', async () => { + const app = buildServer(); + + const old = new Date('2024-01-01T00:00:00.000Z'); + const recent = new Date('2024-06-01T00:00:00.000Z'); + + await prisma.ngoApplication.create({ + data: { + ...validApplicationPayload({ address: fakeAddress('H'), name: 'Old NGO' }), + createdAt: old, + }, + }); + await prisma.ngoApplication.create({ + data: { + ...validApplicationPayload({ address: fakeAddress('I'), name: 'Recent NGO' }), + createdAt: recent, + }, + }); + + const url = '/ngo-applications?submittedAfter=2024-03-01T00:00:00.000Z'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.total).toBe(1); + expect(body.applications[0].name).toBe('Recent NGO'); + + const beforeUrl = '/ngo-applications?submittedBefore=2024-03-01T00:00:00.000Z'; + const beforeHeaders = signAdminRequest(adminKeypair, 'GET', beforeUrl); + const beforeResponse = await app.inject({ + method: 'GET', + url: beforeUrl, + headers: beforeHeaders, + }); + + expect(beforeResponse.statusCode).toBe(200); + const beforeBody = beforeResponse.json(); + expect(beforeBody.total).toBe(1); + expect(beforeBody.applications[0].name).toBe('Old NGO'); + + await app.close(); + }); + + it('rejects an invalid date with 400', async () => { + const app = buildServer(); + + const url = '/ngo-applications?submittedAfter=not-a-date'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe('invalid_request'); + + await app.close(); + }); it('approves a pending application', async () => { const app = buildServer();