Skip to content
Merged
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
12 changes: 12 additions & 0 deletions Backend/src/stats/dto/query-moderation.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
19 changes: 19 additions & 0 deletions Backend/src/stats/dto/query-timeseries.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 14 additions & 0 deletions Backend/src/stats/stats.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,27 @@ 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')
@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);
@Get('events')
@SkipThrottle()
@ApiOperation({ summary: 'Event counts per type per time bucket, plus recent events' })
Expand Down
47 changes: 47 additions & 0 deletions Backend/src/stats/stats.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
103 changes: 103 additions & 0 deletions Backend/src/stats/stats.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -24,13 +28,112 @@ 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);
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<Array<{ bucket: string; count: string }>>(`
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) {
Expand Down