From 6a21f4b3adf20ff78f5577570087837e83e3c9bb Mon Sep 17 00:00:00 2001 From: LGTM-ButItsBroken <331666253+LGTM-ButItsBroken@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:14:47 +0100 Subject: [PATCH] Add GET /v1/stats/timeseries/gists and GET /v1/stats/moderation Implements two of the four issues assigned here (#1239-#1242): #1239 (gists timeseries) and #1242 (moderation). #1240 (top cells) and #1241 (lifecycle) are left for a follow-up. - #1239: GET /v1/stats/timeseries/gists returns bucket/count/signed/ anonymous per from/to/bucket range, zero-filled via generate_series so charts have no gaps, and 400s when the range would exceed 1000 points. - #1242: GET /v1/stats/moderation returns hiddenCount, reportedCount, totalReports, a fixed report-count histogram (1, 2, 3-5, 6-10, above 10) with zero-filled edges, and topReported (id, reportCount, hidden) ordered by report count. Hidden gists are included, matching the acceptance criteria that public listing exclusions don't apply here. Per team convention, no npm install or test run was performed as part of this change; verification is handled by the testing team via CI. --- Backend/src/app.module.ts | 2 + Backend/src/stats/dto/query-moderation.dto.ts | 12 ++ Backend/src/stats/dto/query-timeseries.dto.ts | 19 +++ Backend/src/stats/stats.controller.ts | 27 ++++ Backend/src/stats/stats.module.ts | 10 ++ Backend/src/stats/stats.service.spec.ts | 61 +++++++++ Backend/src/stats/stats.service.ts | 120 ++++++++++++++++++ 7 files changed, 251 insertions(+) create mode 100644 Backend/src/stats/dto/query-moderation.dto.ts create mode 100644 Backend/src/stats/dto/query-timeseries.dto.ts create mode 100644 Backend/src/stats/stats.controller.ts create mode 100644 Backend/src/stats/stats.module.ts create mode 100644 Backend/src/stats/stats.service.spec.ts create mode 100644 Backend/src/stats/stats.service.ts diff --git a/Backend/src/app.module.ts b/Backend/src/app.module.ts index 8c6006a2..2a6f342c 100644 --- a/Backend/src/app.module.ts +++ b/Backend/src/app.module.ts @@ -18,6 +18,7 @@ import { CorrelationIdMiddleware } from './common/middleware/correlation-id.midd import { InFlightRequestMiddleware } from './common/shutdown/in-flight.middleware'; import { buildWinstonOptions } from './common/logger/winston.config'; import { IndexerModule } from './indexer/indexer.module'; +import { StatsModule } from './stats/stats.module'; @Module({ imports: [ @@ -64,6 +65,7 @@ import { IndexerModule } from './indexer/indexer.module'; // Enables the Soroban indexer when the application starts IndexerModule, + StatsModule, ], providers: [ diff --git a/Backend/src/stats/dto/query-moderation.dto.ts b/Backend/src/stats/dto/query-moderation.dto.ts new file mode 100644 index 00000000..c287b5c4 --- /dev/null +++ b/Backend/src/stats/dto/query-moderation.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsOptional, Max, Min } from 'class-validator'; + +export class QueryModerationDto { + @ApiPropertyOptional({ description: 'Max topReported entries (1-50)', default: 10 }) + @IsOptional() + @Min(1) + @Max(50) + @Type(() => Number) + limit?: number = 10; +} diff --git a/Backend/src/stats/dto/query-timeseries.dto.ts b/Backend/src/stats/dto/query-timeseries.dto.ts new file mode 100644 index 00000000..e04e0ace --- /dev/null +++ b/Backend/src/stats/dto/query-timeseries.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsISO8601 } from 'class-validator'; + +export const TIMESERIES_BUCKETS = ['hour', 'day'] as const; +export type TimeseriesBucket = (typeof TIMESERIES_BUCKETS)[number]; + +export class QueryTimeseriesDto { + @ApiProperty({ description: 'Range start (ISO 8601)' }) + @IsISO8601() + from: string; + + @ApiProperty({ description: 'Range end (ISO 8601)' }) + @IsISO8601() + to: string; + + @ApiProperty({ description: 'Bucket granularity', enum: TIMESERIES_BUCKETS }) + @IsIn(TIMESERIES_BUCKETS) + bucket: TimeseriesBucket; +} diff --git a/Backend/src/stats/stats.controller.ts b/Backend/src/stats/stats.controller.ts new file mode 100644 index 00000000..3d186fb7 --- /dev/null +++ b/Backend/src/stats/stats.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; + +import { StatsService } from './stats.service'; +import { QueryTimeseriesDto } from './dto/query-timeseries.dto'; +import { QueryModerationDto } from './dto/query-moderation.dto'; + +@ApiTags('stats') +@Controller({ path: 'stats', version: '1' }) +export class StatsController { + constructor(private readonly statsService: StatsService) {} + + @Get('timeseries/gists') + @SkipThrottle() + @ApiOperation({ summary: 'Gists created over time, zero-filled per bucket' }) + getGistsTimeseries(@Query() query: QueryTimeseriesDto) { + return this.statsService.getGistsTimeseries(query); + } + + @Get('moderation') + @SkipThrottle() + @ApiOperation({ summary: 'Moderation signals: hidden/reported counts and top-reported gists' }) + getModeration(@Query() query: QueryModerationDto) { + return this.statsService.getModerationStats(query); + } +} diff --git a/Backend/src/stats/stats.module.ts b/Backend/src/stats/stats.module.ts new file mode 100644 index 00000000..e6302982 --- /dev/null +++ b/Backend/src/stats/stats.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { StatsService } from './stats.service'; +import { StatsController } from './stats.controller'; + +@Module({ + controllers: [StatsController], + providers: [StatsService], +}) +export class StatsModule {} diff --git a/Backend/src/stats/stats.service.spec.ts b/Backend/src/stats/stats.service.spec.ts new file mode 100644 index 00000000..c1938639 --- /dev/null +++ b/Backend/src/stats/stats.service.spec.ts @@ -0,0 +1,61 @@ +import { BadRequestException } from '@nestjs/common'; +import { StatsService } from './stats.service'; + +describe('StatsService', () => { + let dataSource: { query: jest.Mock }; + let service: StatsService; + + beforeEach(() => { + dataSource = { query: jest.fn() }; + service = new StatsService(dataSource as any); + }); + + describe('getGistsTimeseries', () => { + it('rejects a range producing more points than the max', async () => { + await expect( + service.getGistsTimeseries({ + from: '2000-01-01T00:00:00.000Z', + to: '2020-01-01T00:00:00.000Z', + bucket: 'day', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('maps rows to bucket/count/signed/anonymous', async () => { + dataSource.query.mockResolvedValue([ + { bucket: new Date('2026-01-01T00:00:00.000Z'), count: '2', signed: '1', anonymous: '1' }, + ]); + + const result = await service.getGistsTimeseries({ + from: '2026-01-01T00:00:00.000Z', + to: '2026-01-02T00:00:00.000Z', + bucket: 'day', + }); + + expect(result).toEqual([ + { bucket: '2026-01-01T00:00:00.000Z', count: 2, signed: 1, anonymous: 1 }, + ]); + }); + }); + + describe('getModerationStats', () => { + it('builds the histogram with zero-filled edges', async () => { + dataSource.query + .mockResolvedValueOnce([{ hidden_count: '3', reported_count: '5', total_reports: '20' }]) + .mockResolvedValueOnce([{ bucket: '3-5', count: '2' }]) + .mockResolvedValueOnce([]); + + const result = await service.getModerationStats({ limit: 10 }); + + expect(result.hiddenCount).toBe(3); + expect(result.histogram).toEqual([ + { bucket: '1', count: 0 }, + { bucket: '2', count: 0 }, + { bucket: '3-5', count: 2 }, + { bucket: '6-10', count: 0 }, + { bucket: 'above 10', count: 0 }, + ]); + expect(result.topReported).toEqual([]); + }); + }); +}); diff --git a/Backend/src/stats/stats.service.ts b/Backend/src/stats/stats.service.ts new file mode 100644 index 00000000..93038beb --- /dev/null +++ b/Backend/src/stats/stats.service.ts @@ -0,0 +1,120 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { QueryTimeseriesDto } from './dto/query-timeseries.dto'; +import { QueryModerationDto } from './dto/query-moderation.dto'; + +const MAX_POINTS = 1000; + +@Injectable() +export class StatsService { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + /** + * #1239 — gists created over time, zero-filled with generate_series so + * charts have no gaps. + */ + async getGistsTimeseries(query: QueryTimeseriesDto) { + const from = new Date(query.from); + const to = new Date(query.to); + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) { + throw new BadRequestException('`from` must be a valid ISO date before `to`'); + } + + const interval = query.bucket === 'hour' ? '1 hour' : '1 day'; + const bucketMs = query.bucket === 'hour' ? 3_600_000 : 86_400_000; + const pointCount = Math.ceil((to.getTime() - from.getTime()) / bucketMs); + if (pointCount > MAX_POINTS) { + throw new BadRequestException(`Range too large: ${pointCount} points exceeds max of ${MAX_POINTS}`); + } + + const rows = await this.dataSource.query< + Array<{ bucket: Date; count: string; signed: string; anonymous: string }> + >( + ` + WITH buckets AS ( + SELECT generate_series(date_trunc('${query.bucket}', $1::timestamptz), $2::timestamptz, $3::interval) AS bucket + ) + SELECT + b.bucket, + COUNT(g.id) AS count, + COUNT(g.id) FILTER (WHERE g.author_address IS NOT NULL) AS signed, + COUNT(g.id) FILTER (WHERE g.author_address IS NULL) AS anonymous + FROM buckets b + LEFT JOIN gists g + ON date_trunc('${query.bucket}', g.created_at) = b.bucket + AND g.created_at >= $1 AND g.created_at < $2 + GROUP BY b.bucket + ORDER BY b.bucket + `, + [from, to, interval], + ); + + return rows.map((row) => ({ + bucket: row.bucket.toISOString(), + count: Number(row.count), + signed: Number(row.signed), + anonymous: Number(row.anonymous), + })); + } + + /** #1242 — moderation signals, including hidden gists. */ + async getModerationStats(query: QueryModerationDto) { + const [summary] = await this.dataSource.query< + Array<{ hidden_count: string; reported_count: string; total_reports: string }> + >(` + SELECT + COUNT(*) FILTER (WHERE hidden) AS hidden_count, + COUNT(*) FILTER (WHERE report_count > 0) AS reported_count, + COALESCE(SUM(report_count), 0) AS total_reports + FROM gists + `); + + const histogramRows = await this.dataSource.query>(` + SELECT + CASE + WHEN report_count = 1 THEN '1' + WHEN report_count = 2 THEN '2' + WHEN report_count BETWEEN 3 AND 5 THEN '3-5' + WHEN report_count BETWEEN 6 AND 10 THEN '6-10' + WHEN report_count > 10 THEN 'above 10' + END AS bucket, + COUNT(*) AS count + FROM gists + WHERE report_count > 0 + GROUP BY bucket + `); + + const histogramLabels = ['1', '2', '3-5', '6-10', 'above 10']; + const histogram = histogramLabels.map((label) => ({ + bucket: label, + count: Number(histogramRows.find((r) => r.bucket === label)?.count ?? 0), + })); + + const topReportedRows = await this.dataSource.query< + Array<{ id: string; report_count: number; hidden: boolean }> + >( + ` + SELECT id, report_count, hidden + FROM gists + WHERE report_count > 0 + ORDER BY report_count DESC, id ASC + LIMIT $1 + `, + [query.limit ?? 10], + ); + + return { + hiddenCount: Number(summary?.hidden_count ?? 0), + reportedCount: Number(summary?.reported_count ?? 0), + totalReports: Number(summary?.total_reports ?? 0), + histogram, + topReported: topReportedRows.map((r) => ({ + id: r.id, + reportCount: r.report_count, + hidden: r.hidden, + })), + }; + } +}