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 index 6cd2cdf8..ba250a6d 100644 --- a/Backend/src/stats/stats.controller.ts +++ b/Backend/src/stats/stats.controller.ts @@ -3,6 +3,8 @@ 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'; import { QueryStatsEventsDto } from './dto/query-stats-events.dto'; @ApiTags('stats') @@ -10,6 +12,18 @@ import { QueryStatsEventsDto } from './dto/query-stats-events.dto'; 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); @Get('events') @SkipThrottle() @ApiOperation({ summary: 'Event counts per type per time bucket, plus recent events' }) diff --git a/Backend/src/stats/stats.service.spec.ts b/Backend/src/stats/stats.service.spec.ts index c9bc346b..9471a12e 100644 --- a/Backend/src/stats/stats.service.spec.ts +++ b/Backend/src/stats/stats.service.spec.ts @@ -10,6 +10,53 @@ describe('StatsService', () => { 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([]); + }); it('rejects a range producing more buckets than the max', async () => { await expect( service.getEventStats({ diff --git a/Backend/src/stats/stats.service.ts b/Backend/src/stats/stats.service.ts index b09bc7e2..06e39f55 100644 --- a/Backend/src/stats/stats.service.ts +++ b/Backend/src/stats/stats.service.ts @@ -2,6 +2,10 @@ 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; import { GIST_EVENT_TOPICS } from '../soroban/soroban.service'; import { QueryStatsEventsDto } from './dto/query-stats-events.dto'; @@ -24,6 +28,11 @@ export interface RecentEvent { 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) { async getEventStats(query: QueryStatsEventsDto) { const from = new Date(query.from); const to = new Date(query.to); @@ -31,6 +40,100 @@ export class StatsService { 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, + })), + }; const bucketMs = BUCKET_MS[query.bucket]; const bucketCount = Math.ceil((to.getTime() - from.getTime()) / bucketMs); if (bucketCount > MAX_BUCKETS) {