Skip to content
Open
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
48 changes: 26 additions & 22 deletions src/routes/ngoApplications.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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),
});
Expand All @@ -46,7 +49,7 @@ export async function ngoApplicationRoutes(app: FastifyInstance): Promise<void>
{
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',
},
},
Expand Down Expand Up @@ -130,19 +133,36 @@ export async function ngoApplicationRoutes(app: FastifyInstance): Promise<void>
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(
Expand Down Expand Up @@ -184,14 +204,6 @@ export async function ngoApplicationRoutes(app: FastifyInstance): Promise<void>
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 },
});
},
);

Expand All @@ -214,14 +226,6 @@ export async function ngoApplicationRoutes(app: FastifyInstance): Promise<void>
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 },
});
},
);
}
163 changes: 161 additions & 2 deletions test/routes/ngoApplications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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();

Expand Down
Loading