Skip to content
Closed
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
1 change: 1 addition & 0 deletions api.js

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions branch_structure.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"default_branch": "main",
"branches": [
"feat/issue-107-add-a-get-tokens-endpoint-listing-tokens-seen",
"feat/issue-129-add-search-and-date-filters-to-get-ngo",
"main",
"origin"
],
"parent": {
"feat/issue-107-add-a-get-tokens-endpoint-listing-tokens-seen": "main",
"feat/issue-129-add-search-and-date-filters-to-get-ngo": "main",
"main": null,
"origin": "main"
},
"order": [
"main",
"feat/issue-107-add-a-get-tokens-endpoint-listing-tokens-seen",
"feat/issue-129-add-search-and-date-filters-to-get-ngo",
"origin"
]
}
5 changes: 4 additions & 1 deletion eslint.config.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
"node": ">=20"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"dev": "node api.js && tsx watch src/index.ts",
"build": "node api.js && tsc -p tsconfig.json",
"start": "node api.js && node dist/index.js",
"lint": "eslint .",
"format": "prettier --check .",
"format:fix": "prettier --write .",
Expand Down
22 changes: 20 additions & 2 deletions src/routes/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const querySchema = z.object({
.regex(/^G[A-Z2-7]{55}$/)
.optional(),
ngo: z.string().uuid().optional(),
// Filter by the NGO's Stellar wallet address instead of its internal UUID.
// Filter by the NGO's Stellar wallet address instead of its internal UUIT.
ngoAddress: z
.string()
.regex(/^G[A-Z2-7]{55}$/)
Expand All @@ -25,7 +25,7 @@ const idParamSchema = z.object({ id: z.string().uuid() });

const streamInclude = {
donor: { select: { address: true } },
ngo: { select: { id: true, name: true, ownerAddress: true } },
ngo: { select: {\n id: true, name: true, ownerAddress: true } },
} as const;

// onChainId is a BigInt; Fastify's default JSON.stringify serializer (no
Expand Down Expand Up @@ -77,6 +77,24 @@ export async function streamRoutes(app: FastifyInstance): Promise<void> {
};
});

// Distinct token addresses seen across all streams, with a count of how
// many streams reference each one. Used by the UI to build a token filter
// from real data instead of a hardcoded list.
app.get('/tokens', async () => {
const grouped = await prisma.stream.groupBy({
by: ['tokenAddress'],
_count: { _id: true },
orderBy: { _count: { tokenAddress: 'desc' } },
});

return {
tokens: grouped.map((token) => ({
tokenAddress: token.tokenAddress,
streamCount: token._count._id,
})),
};
});

app.get('/streams/:id', async (request, reply) => {
const parsedParams = idParamSchema.safeParse(request.params);
if (!parsedParams.success) {
Expand Down
22 changes: 20 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,33 @@ export function buildServer(options?: BuildServerOptions) {
timeWindow: process.env.RATE_LIMIT_WINDOW ?? '1 minute',
});

app.get('/health', async (_request, reply) => {
try {
app.get(
'/health',
// Health checks are also used by free-tier uptime pingers. They must not
// consume the shared client rate-limit bucket.
{ config: { rateLimit: false } },
async (_request, reply) => {
try {
await prisma.$queryRaw`SELECT 1`;
return { status: 'ok' };
} catch {
return reply.code(503).send({ status: 'error', database: 'unreachable' });
}
});

app.get('/tokens', async () => {
const grouped = await prisma.stream.groupBy({
by: ['tokenAddress'],
_count: { _all: true },
orderBy: { _count: { tokenAddress: 'desc' } },
});

return grouped.map((group) => ({
tokenAddress: group.tokenAddress,
streamCount: group._count._all,
}));
});

app.register(ngoRoutes);
app.register(donorRoutes);
app.register(streamRoutes);
Expand Down
87 changes: 83 additions & 4 deletions test/routes/streams.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, it } from 'vitest';

import { prisma } from '../../src/db.js';
import { buildServer } from '../../src/server.js';
import { prisma } from '../../src/db.js';import { buildServer } from '../../src/server.js';
import { fakeAddress, resetDb } from '../helpers/db.js';

describe('GET /streams', () => {
Expand Down Expand Up @@ -151,7 +150,7 @@ describe('GET /streams', () => {
const firstBody = firstPage.json();
expect(firstBody.streams).toHaveLength(2);
expect(firstBody.hasMore).toBe(true);
expect(firstBody.streams.map((s: { onChainId: string }) => s.onChainId)).toEqual(['3', '2']);
expect(firstBody.streams.map((s: { onChainId: string }) => s.onChainId)).toEqual(['3', '2']);
expect(firstBody.nextCursor).toBe(firstBody.streams[1].id);

const secondPage = await app.inject({
Expand Down Expand Up @@ -415,7 +414,7 @@ describe('GET /streams/:id', () => {
await app.close();
});

it('400s for a malformed id instead of leaking a Prisma error', async () => {
it('400s on a malformed id instead of leaking a Prisma error', async () => {
const app = buildServer();

const response = await app.inject({ method: 'GET', url: '/streams/not-a-uuid' });
Expand Down Expand Up @@ -456,3 +455,83 @@ describe('GET /streams/:id', () => {
await app.close();
});
});

describe('GET /tokens', () => {
afterEach(async () => {
await resetDb();
});

it('returns distinct token addresses with a stream count for each', async () => {
const app = buildServer();

const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } });
const ngo = await prisma.ngo.create({
data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true },
});

const tokenA = fakeAddress('D');
const tokenB = fakeAddress('E');

await prisma.stream.createMany({
data: [
{
onChainId: 1n,
donorId: donor.id,
ngoId: ngo.id,
tokenAddress: tokenA,
rate: '1',
balance: '100',
withdrawn: '0',
},
{
onChainId: 2n,
donorId: donor.id,
ngoId: ngo.id,
tokenAddress: tokenA,
rate: '1',
balance: '100',
withdrawn: '0',
},
{
onChainId: 3n,
donorId: donor.id,
ngoId: ngo.id,
tokenAddress: tokenB,
rate: '1',
balance: '100',
withdrawn: '0',
},
],
});

const response = await app.inject({ method: 'GET', url: '/tokens' });
expect(response.statusCode).toBe(200);

const body = response.json();
expect(Array.isArray(body.tokens)).toBe(true);
expect(body.tokens).toHaveLength(2);

const byAddress = new Map<string, number>(
body.tokens.map((t: { tokenAddress: string; count: number }) => [
t.tokenAddress,
t.count,
]),
);
expect(byAddress.get(tokenA)).toBe(2);
expect(byAddress.get(tokenB)).toBe(1);

await app.close();
});

it('returns an empty list when there are no streams', async () => {
const app = buildServer();

const response = await app.inject({ method: 'GET', url: '/tokens' });
expect(response.statusCode).toBe(200);

const body = response.json();
expect(body.tokens).toEqual([]);

await app.close();
});
});
Loading