diff --git a/lib/handler.js b/lib/handler.js index 90c2ac91..40e91a41 100644 --- a/lib/handler.js +++ b/lib/handler.js @@ -3,7 +3,11 @@ import { json } from 'http-responders' import Sentry from '@sentry/node' import { URLSearchParams } from 'node:url' -import { fetchRetrievalSuccessRate } from './stats-fetchers.js' +import { + fetchDailyParticipants, + fetchMonthlyParticipants, + fetchRetrievalSuccessRate +} from './stats-fetchers.js' /** * @param {object} args @@ -42,6 +46,20 @@ const handler = async (req, res, pgPool) => { res, pgPool, fetchRetrievalSuccessRate) + } else if (req.method === 'GET' && segs[0] === 'participants' && segs[1] === 'daily' && segs.length === 2) { + await getStatsWithFilterAndCaching( + pathname, + searchParams, + res, + pgPool, + fetchDailyParticipants) + } else if (req.method === 'GET' && segs[0] === 'participants' && segs[1] === 'monthly' && segs.length === 2) { + await getStatsWithFilterAndCaching( + pathname, + searchParams, + res, + pgPool, + fetchMonthlyParticipants) } else if (req.method === 'GET' && segs.length === 0) { // health check - required by Grafana datasources res.end('OK') diff --git a/lib/stats-fetchers.js b/lib/stats-fetchers.js index f21c5364..706874a3 100644 --- a/lib/stats-fetchers.js +++ b/lib/stats-fetchers.js @@ -15,3 +15,45 @@ export const fetchRetrievalSuccessRate = async (pgPool, filter) => { })) return stats } + +/** + * @param {import('pg').Pool} pgPool + * @param {import('./typings').Filter} filter + */ +export const fetchDailyParticipants = async (pgPool, filter) => { + // Fetch the "day" (DATE) as a string (TEXT) to prevent node-postgres from converting it into + // a JavaScript Date with a timezone, as that could change the date one day forward or back. + const { rows } = await pgPool.query(` + SELECT day::TEXT, COUNT(DISTINCT participant_id)::INT as participants + FROM daily_participants + WHERE day >= $1 AND day <= $2 + GROUP BY day + `, [ + filter.from, + filter.to + ]) + return rows +} + +/** + * @param {import('pg').Pool} pgPool + * @param {import('./typings').Filter} filter + */ +export const fetchMonthlyParticipants = async (pgPool, filter) => { + // Fetch the "day" (DATE) as a string (TEXT) to prevent node-postgres from converting it into + // a JavaScript Date with a timezone, as that could change the date one day forward or back. + const { rows } = await pgPool.query(` + SELECT + date_trunc('month', day)::DATE::TEXT as month, + COUNT(DISTINCT participant_id)::INT as participants + FROM daily_participants + WHERE + day >= date_trunc('month', $1::DATE) + AND day < date_trunc('month', $2::DATE) + INTERVAL '1 month' + GROUP BY month + `, [ + filter.from, + filter.to + ]) + return rows +} diff --git a/test/handler.test.js b/test/handler.test.js index c8e6914d..f92ce06e 100644 --- a/test/handler.test.js +++ b/test/handler.test.js @@ -3,6 +3,7 @@ import { once } from 'node:events' import assert, { AssertionError } from 'node:assert' import pg from 'pg' import createDebug from 'debug' +import { mapParticipantsToIds } from 'spark-evaluate/lib/public-stats.js' import { createHandler, today } from '../lib/handler.js' import { DATABASE_URL } from '../lib/config.js' @@ -132,6 +133,59 @@ describe('HTTP request handler', () => { assert.strictEqual(res.headers.get('cache-control'), 'public, max-age=31536000, immutable') }) }) + + describe('GET /participants/daily', () => { + it('returns daily active participants for the given date range', async () => { + await givenDailyParticipants(pgPool, '2024-01-10', ['0x10', '0x20']) + await givenDailyParticipants(pgPool, '2024-01-11', ['0x10', '0x20', '0x30']) + await givenDailyParticipants(pgPool, '2024-01-12', ['0x10', '0x20', '0x40', '0x50']) + await givenDailyParticipants(pgPool, '2024-01-13', ['0x10']) + + const res = await fetch( + new URL( + '/participants/daily?from=2024-01-11&to=2024-01-12', + baseUrl + ), { + redirect: 'manual' + } + ) + await assertResponseStatus(res, 200) + const stats = await res.json() + assert.deepStrictEqual(stats, [ + { day: '2024-01-11', participants: 3 }, + { day: '2024-01-12', participants: 4 } + ]) + }) + }) + + describe('GET /participants/monthly', () => { + it('returns montly active participants for the given date range ignoring the day number', async () => { + // before the range + await givenDailyParticipants(pgPool, '2023-12-31', ['0x01', '0x02']) + // in the range + await givenDailyParticipants(pgPool, '2024-01-10', ['0x10', '0x20']) + await givenDailyParticipants(pgPool, '2024-01-11', ['0x10', '0x20', '0x30']) + await givenDailyParticipants(pgPool, '2024-01-12', ['0x10', '0x20', '0x40', '0x50']) + await givenDailyParticipants(pgPool, '2024-02-13', ['0x10', '0x60']) + // after the range + await givenDailyParticipants(pgPool, '2024-03-01', ['0x99']) + + const res = await fetch( + new URL( + '/participants/monthly?from=2024-01-12&to=2024-02-12', + baseUrl + ), { + redirect: 'manual' + } + ) + await assertResponseStatus(res, 200) + const stats = await res.json() + assert.deepStrictEqual(stats, [ + { month: '2024-01-01', participants: 5 }, + { month: '2024-02-01', participants: 2 } + ]) + }) + }) }) const assertResponseStatus = async (res, status) => { @@ -150,3 +204,15 @@ const givenRetrievalStats = async (pgPool, { day, total, successful }) => { [day, total, successful] ) } + +const givenDailyParticipants = async (pgPool, day, participantAddresses) => { + const ids = await mapParticipantsToIds(pgPool, new Set(participantAddresses)) + await pgPool.query(` + INSERT INTO daily_participants (day, participant_id) + SELECT $1 as day, UNNEST($2::INT[]) AS participant_id + ON CONFLICT DO NOTHING + `, [ + day, + ids + ]) +}