diff --git a/Backend/src/stats/cache/stats-cache.service.spec.ts b/Backend/src/stats/cache/stats-cache.service.spec.ts new file mode 100644 index 00000000..7768211b --- /dev/null +++ b/Backend/src/stats/cache/stats-cache.service.spec.ts @@ -0,0 +1,46 @@ +import { StatsCacheService } from './stats-cache.service'; + +describe('StatsCacheService', () => { + let config: { get: jest.Mock }; + let cache: StatsCacheService; + + beforeEach(() => { + config = { get: jest.fn().mockReturnValue(30_000) }; + cache = new StatsCacheService(config as any); + jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns the cached value without re-invoking fn within the TTL', async () => { + const fn = jest.fn().mockResolvedValue('value'); + + await cache.getOrSet('key', fn); + await cache.getOrSet('key', fn); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('re-invokes fn once the TTL has expired', async () => { + const fn = jest.fn().mockResolvedValueOnce('first').mockResolvedValueOnce('second'); + + await cache.getOrSet('key', fn, 1_000); + jest.advanceTimersByTime(1_001); + const second = await cache.getOrSet('key', fn, 1_000); + + expect(fn).toHaveBeenCalledTimes(2); + expect(second).toBe('second'); + }); + + it('clear() forces the next call to re-invoke fn', async () => { + const fn = jest.fn().mockResolvedValue('value'); + + await cache.getOrSet('key', fn); + cache.clear(); + await cache.getOrSet('key', fn); + + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/Backend/src/stats/cache/stats-cache.service.ts b/Backend/src/stats/cache/stats-cache.service.ts new file mode 100644 index 00000000..8830293a --- /dev/null +++ b/Backend/src/stats/cache/stats-cache.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface CacheEntry { + value: unknown; + expiresAt: number; +} + +/** + * Small in-memory TTL cache for aggregate stats queries. Not distributed — + * fine for a single backend instance; a shared cache (Redis) would be a + * follow-up if this ever runs behind more than one instance. + */ +@Injectable() +export class StatsCacheService { + private readonly store = new Map(); + private readonly defaultTtlMs: number; + + constructor(private readonly config: ConfigService) { + this.defaultTtlMs = this.config.get('STATS_CACHE_TTL_MS', 30_000); + } + + async getOrSet(key: string, fn: () => Promise, ttlMs?: number): Promise { + const cached = this.store.get(key); + if (cached && cached.expiresAt > Date.now()) { + return cached.value as T; + } + + const value = await fn(); + this.store.set(key, { value, expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs) }); + return value; + } + + clear(): void { + this.store.clear(); + } +} diff --git a/Backend/src/stats/dto/range-query.dto.spec.ts b/Backend/src/stats/dto/range-query.dto.spec.ts new file mode 100644 index 00000000..721e0147 --- /dev/null +++ b/Backend/src/stats/dto/range-query.dto.spec.ts @@ -0,0 +1,31 @@ +import { BadRequestException } from '@nestjs/common'; +import { RangeQueryDto } from './range-query.dto'; + +describe('RangeQueryDto.resolveRange', () => { + it('defaults to the last 7 days when from/to are omitted', () => { + const { from, to } = RangeQueryDto.resolveRange({}); + const days = (to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000); + expect(days).toBeCloseTo(7, 1); + }); + + it('rejects a range wider than 90 days', () => { + expect(() => + RangeQueryDto.resolveRange({ from: '2020-01-01T00:00:00.000Z', to: '2020-06-01T00:00:00.000Z' }), + ).toThrow(BadRequestException); + }); + + it('rejects from >= to', () => { + expect(() => + RangeQueryDto.resolveRange({ from: '2026-01-02T00:00:00.000Z', to: '2026-01-01T00:00:00.000Z' }), + ).toThrow(BadRequestException); + }); + + it('accepts an explicit valid range', () => { + const { from, to } = RangeQueryDto.resolveRange({ + from: '2026-01-01T00:00:00.000Z', + to: '2026-01-02T00:00:00.000Z', + }); + expect(from.toISOString()).toBe('2026-01-01T00:00:00.000Z'); + expect(to.toISOString()).toBe('2026-01-02T00:00:00.000Z'); + }); +}); diff --git a/Backend/src/stats/dto/range-query.dto.ts b/Backend/src/stats/dto/range-query.dto.ts new file mode 100644 index 00000000..4c5b82b0 --- /dev/null +++ b/Backend/src/stats/dto/range-query.dto.ts @@ -0,0 +1,45 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsISO8601, IsOptional } from 'class-validator'; +import { BadRequestException } from '@nestjs/common'; + +export const RANGE_BUCKETS = ['hour', 'day', 'week'] as const; +export type RangeBucket = (typeof RANGE_BUCKETS)[number]; + +const DEFAULT_RANGE_DAYS = 7; +const MAX_RANGE_DAYS = 90; +const DAY_MS = 24 * 60 * 60 * 1000; + +export class RangeQueryDto { + @ApiPropertyOptional({ description: 'Range start (ISO 8601). Defaults to 7 days before `to`.' }) + @IsOptional() + @IsISO8601() + from?: string; + + @ApiPropertyOptional({ description: 'Range end (ISO 8601). Defaults to now.' }) + @IsOptional() + @IsISO8601() + to?: string; + + @ApiPropertyOptional({ description: 'Bucket granularity', enum: RANGE_BUCKETS, default: 'day' }) + @IsOptional() + @IsIn(RANGE_BUCKETS) + bucket?: RangeBucket = 'day'; + + /** + * Resolves `from`/`to` into concrete `Date`s, applying the "last 7 + * days" default and rejecting a range wider than `MAX_RANGE_DAYS`. + */ + static resolveRange(dto: Pick): { from: Date; to: Date } { + const to = dto.to ? new Date(dto.to) : new Date(); + const from = dto.from ? new Date(dto.from) : new Date(to.getTime() - DEFAULT_RANGE_DAYS * DAY_MS); + + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) { + throw new BadRequestException('`from` must be a valid ISO date before `to`'); + } + if (to.getTime() - from.getTime() > MAX_RANGE_DAYS * DAY_MS) { + throw new BadRequestException(`Range too large: maximum is ${MAX_RANGE_DAYS} days`); + } + + return { from, to }; + } +} diff --git a/Backend/src/stats/stats.controller.ts b/Backend/src/stats/stats.controller.ts index ba250a6d..8ec34b14 100644 --- a/Backend/src/stats/stats.controller.ts +++ b/Backend/src/stats/stats.controller.ts @@ -1,3 +1,4 @@ +import { Controller, Get } from '@nestjs/common'; import { Controller, Get, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { SkipThrottle } from '@nestjs/throttler'; @@ -12,6 +13,15 @@ import { QueryStatsEventsDto } from './dto/query-stats-events.dto'; export class StatsController { constructor(private readonly statsService: StatsService) {} + @Get('overview') + @SkipThrottle() + @ApiOperation({ + summary: 'Platform totals: total/active/expired/hidden/signed/anonymous/reports', + description: + '"active" means the gist is not hidden, not expired (expires_at > now), and is_active.', + }) + getOverview() { + return this.statsService.getOverview(); @Get('timeseries/gists') @SkipThrottle() @ApiOperation({ summary: 'Gists created over time, zero-filled per bucket' }) diff --git a/Backend/src/stats/stats.module.ts b/Backend/src/stats/stats.module.ts index e6302982..e96b9902 100644 --- a/Backend/src/stats/stats.module.ts +++ b/Backend/src/stats/stats.module.ts @@ -2,6 +2,11 @@ import { Module } from '@nestjs/common'; import { StatsService } from './stats.service'; import { StatsController } from './stats.controller'; +import { StatsCacheService } from './cache/stats-cache.service'; + +@Module({ + controllers: [StatsController], + providers: [StatsService, StatsCacheService], @Module({ controllers: [StatsController], diff --git a/Backend/src/stats/stats.service.spec.ts b/Backend/src/stats/stats.service.spec.ts index 9471a12e..1bbfc8d0 100644 --- a/Backend/src/stats/stats.service.spec.ts +++ b/Backend/src/stats/stats.service.spec.ts @@ -1,3 +1,9 @@ +import { StatsService } from './stats.service'; +import { StatsCacheService } from './cache/stats-cache.service'; + +describe('StatsService.getOverview', () => { + let dataSource: { query: jest.Mock }; + let cache: StatsCacheService; import { BadRequestException } from '@nestjs/common'; import { StatsService } from './stats.service'; @@ -7,6 +13,51 @@ describe('StatsService', () => { beforeEach(() => { dataSource = { query: jest.fn() }; + // Real cache with a throwaway ConfigService so getOverview's single + // query is actually exercised, not short-circuited by a mock cache. + cache = new StatsCacheService({ get: jest.fn().mockReturnValue(30_000) } as any); + service = new StatsService(dataSource as any, cache); + }); + + it('maps each FILTER-clause counter from the aggregate row', async () => { + dataSource.query.mockResolvedValue([ + { + total: '10', + active: '6', + expired: '2', + hidden: '1', + unique_authors: '4', + signed: '5', + anonymous: '5', + api_submitted: '3', + indexed_only: '7', + total_reports: '9', + }, + ]); + + const result = await service.getOverview(); + + expect(result).toEqual({ + total: 10, + active: 6, + expired: 2, + hidden: 1, + uniqueAuthors: 4, + signed: 5, + anonymous: 5, + apiSubmitted: 3, + indexedOnly: 7, + totalReports: 9, + }); + }); + + it('caches the result so a second call does not re-query', async () => { + dataSource.query.mockResolvedValue([{}]); + + await service.getOverview(); + await service.getOverview(); + + expect(dataSource.query).toHaveBeenCalledTimes(1); service = new StatsService(dataSource as any); }); diff --git a/Backend/src/stats/stats.service.ts b/Backend/src/stats/stats.service.ts index 06e39f55..c745e99e 100644 --- a/Backend/src/stats/stats.service.ts +++ b/Backend/src/stats/stats.service.ts @@ -1,3 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { StatsCacheService } from './cache/stats-cache.service'; + +interface OverviewRow { + total: string; + active: string; + expired: string; + hidden: string; + unique_authors: string; + signed: string; + anonymous: string; + api_submitted: string; + indexed_only: string; + total_reports: string; import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; @@ -26,6 +43,45 @@ export interface RecentEvent { @Injectable() export class StatsService { + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly cache: StatsCacheService, + ) {} + + /** + * #1238 — platform totals in one aggregate query. "Active" means not + * hidden, not expired, and `is_active`. + */ + async getOverview() { + return this.cache.getOrSet('stats:overview', async () => { + const [row] = await this.dataSource.query(` + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE NOT hidden AND is_active AND expires_at > NOW()) AS active, + COUNT(*) FILTER (WHERE expires_at <= NOW()) AS expired, + COUNT(*) FILTER (WHERE hidden) AS hidden, + COUNT(DISTINCT author_address) AS unique_authors, + COUNT(*) FILTER (WHERE author_address IS NOT NULL) AS signed, + COUNT(*) FILTER (WHERE author_address IS NULL) AS anonymous, + COUNT(*) FILTER (WHERE tx_hash IS NOT NULL) AS api_submitted, + COUNT(*) FILTER (WHERE tx_hash IS NULL) AS indexed_only, + COALESCE(SUM(report_count), 0) AS total_reports + FROM gists + `); + + return { + total: Number(row?.total ?? 0), + active: Number(row?.active ?? 0), + expired: Number(row?.expired ?? 0), + hidden: Number(row?.hidden ?? 0), + uniqueAuthors: Number(row?.unique_authors ?? 0), + signed: Number(row?.signed ?? 0), + anonymous: Number(row?.anonymous ?? 0), + apiSubmitted: Number(row?.api_submitted ?? 0), + indexedOnly: Number(row?.indexed_only ?? 0), + totalReports: Number(row?.total_reports ?? 0), + }; + }); constructor(@InjectDataSource() private readonly dataSource: DataSource) {} /**