From 881da85d252718e2036879015bebe44114a350c5 Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Fri, 7 Feb 2025 22:26:06 +0000 Subject: [PATCH] :sparkles: Add reporter_stats materialized view and endpoint to fetch reporter stats --- lexicons/tools/ozone/moderation/defs.json | 42 +++++++ .../ozone/moderation/getReporterStats.json | 41 ++++++ packages/api/src/client/index.ts | 14 +++ packages/api/src/client/lexicons.ts | 100 +++++++++++++++ .../types/tools/ozone/moderation/defs.ts | 33 +++++ .../ozone/moderation/getReporterStats.ts | 35 ++++++ packages/dev-env/src/moderator-client.ts | 13 ++ packages/ozone/src/api/index.ts | 2 + .../src/api/moderation/getReporterStats.ts | 18 +++ .../src/daemon/materialized-view-refresher.ts | 1 + ...7759Z-reporter-stats-materialized-views.ts | 82 ++++++++++++ packages/ozone/src/db/migrations/index.ts | 1 + packages/ozone/src/db/schema/index.ts | 4 +- .../ozone/src/db/schema/reporter_stats.ts | 19 +++ packages/ozone/src/lexicon/index.ts | 12 ++ packages/ozone/src/lexicon/lexicons.ts | 100 +++++++++++++++ .../types/tools/ozone/moderation/defs.ts | 33 +++++ .../ozone/moderation/getReporterStats.ts | 47 +++++++ packages/ozone/src/mod-service/index.ts | 9 ++ .../ozone/tests/get-reporter-stats.test.ts | 117 ++++++++++++++++++ packages/pds/src/lexicon/index.ts | 12 ++ packages/pds/src/lexicon/lexicons.ts | 100 +++++++++++++++ .../types/tools/ozone/moderation/defs.ts | 33 +++++ .../ozone/moderation/getReporterStats.ts | 47 +++++++ 24 files changed, 914 insertions(+), 1 deletion(-) create mode 100644 lexicons/tools/ozone/moderation/getReporterStats.json create mode 100644 packages/api/src/client/types/tools/ozone/moderation/getReporterStats.ts create mode 100644 packages/ozone/src/api/moderation/getReporterStats.ts create mode 100644 packages/ozone/src/db/migrations/20250206T003647759Z-reporter-stats-materialized-views.ts create mode 100644 packages/ozone/src/db/schema/reporter_stats.ts create mode 100644 packages/ozone/src/lexicon/types/tools/ozone/moderation/getReporterStats.ts create mode 100644 packages/ozone/tests/get-reporter-stats.test.ts create mode 100644 packages/pds/src/lexicon/types/tools/ozone/moderation/getReporterStats.ts diff --git a/lexicons/tools/ozone/moderation/defs.json b/lexicons/tools/ozone/moderation/defs.json index 4a2e8fa9415..9643f29710a 100644 --- a/lexicons/tools/ozone/moderation/defs.json +++ b/lexicons/tools/ozone/moderation/defs.json @@ -812,6 +812,48 @@ "format": "datetime" } } + }, + "reporterStats": { + "type": "object", + "required": ["did", "accountReportCount", "recordReportCount", "reportedAccountCount", "reportedRecordCount", "takendownAccountCount", "takendownRecordCount", "labeledAccountCount", "labeledRecordCount"], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "accountReportCount": { + "type": "integer", + "description": "The total number of reports made by the user on accounts." + }, + "recordReportCount": { + "type": "integer", + "description": "The total number of reports made by the user on records." + }, + "reportedAccountCount": { + "type": "integer", + "description": "The total number of accounts reported by the user." + }, + "reportedRecordCount": { + "type": "integer", + "description": "The total number of records reported by the user." + }, + "takendownAccountCount": { + "type": "integer", + "description": "The total number of accounts taken down as a result of the user's reports." + }, + "takendownRecordCount": { + "type": "integer", + "description": "The total number of records taken down as a result of the user's reports." + }, + "labeledAccountCount": { + "type": "integer", + "description": "The total number of accounts labeled as a result of the user's reports." + }, + "labeledRecordCount": { + "type": "integer", + "description": "The total number of records labeled as a result of the user's reports." + } + } } } } diff --git a/lexicons/tools/ozone/moderation/getReporterStats.json b/lexicons/tools/ozone/moderation/getReporterStats.json new file mode 100644 index 00000000000..8b75f126f4c --- /dev/null +++ b/lexicons/tools/ozone/moderation/getReporterStats.json @@ -0,0 +1,41 @@ +{ + "lexicon": 1, + "id": "tools.ozone.moderation.getReporterStats", + "defs": { + "main": { + "type": "query", + "description": "Get reporter stats for a list of users.", + "parameters": { + "type": "params", + "required": ["dids"], + "properties": { + "dids": { + "type": "array", + "maxLength": 100, + "items": { + "type": "string", + "format": "did" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["stats"], + "properties": { + "stats": { + "type": "array", + "items": { + "type": "ref", + "ref": "tools.ozone.moderation.defs#reporterStats" + } + } + } + } + } + } + } + } + \ No newline at end of file diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 5f46b6b277c..c5fe247947d 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -207,6 +207,7 @@ import * as ToolsOzoneModerationGetEvent from './types/tools/ozone/moderation/ge import * as ToolsOzoneModerationGetRecord from './types/tools/ozone/moderation/getRecord' import * as ToolsOzoneModerationGetRecords from './types/tools/ozone/moderation/getRecords' import * as ToolsOzoneModerationGetRepo from './types/tools/ozone/moderation/getRepo' +import * as ToolsOzoneModerationGetReporterStats from './types/tools/ozone/moderation/getReporterStats' import * as ToolsOzoneModerationGetRepos from './types/tools/ozone/moderation/getRepos' import * as ToolsOzoneModerationQueryEvents from './types/tools/ozone/moderation/queryEvents' import * as ToolsOzoneModerationQueryStatuses from './types/tools/ozone/moderation/queryStatuses' @@ -436,6 +437,7 @@ export * as ToolsOzoneModerationGetEvent from './types/tools/ozone/moderation/ge export * as ToolsOzoneModerationGetRecord from './types/tools/ozone/moderation/getRecord' export * as ToolsOzoneModerationGetRecords from './types/tools/ozone/moderation/getRecords' export * as ToolsOzoneModerationGetRepo from './types/tools/ozone/moderation/getRepo' +export * as ToolsOzoneModerationGetReporterStats from './types/tools/ozone/moderation/getReporterStats' export * as ToolsOzoneModerationGetRepos from './types/tools/ozone/moderation/getRepos' export * as ToolsOzoneModerationQueryEvents from './types/tools/ozone/moderation/queryEvents' export * as ToolsOzoneModerationQueryStatuses from './types/tools/ozone/moderation/queryStatuses' @@ -3716,6 +3718,18 @@ export class ToolsOzoneModerationNS { }) } + getReporterStats( + params?: ToolsOzoneModerationGetReporterStats.QueryParams, + opts?: ToolsOzoneModerationGetReporterStats.CallOptions, + ): Promise { + return this._client.call( + 'tools.ozone.moderation.getReporterStats', + params, + undefined, + opts, + ) + } + getRepos( params?: ToolsOzoneModerationGetRepos.QueryParams, opts?: ToolsOzoneModerationGetRepos.CallOptions, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 6e709774761..3157d80e462 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -12213,6 +12213,64 @@ export const schemaDict = { }, }, }, + reporterStats: { + type: 'object', + required: [ + 'did', + 'accountReportCount', + 'recordReportCount', + 'reportedAccountCount', + 'reportedRecordCount', + 'takendownAccountCount', + 'takendownRecordCount', + 'labeledAccountCount', + 'labeledRecordCount', + ], + properties: { + did: { + type: 'string', + format: 'did', + }, + accountReportCount: { + type: 'integer', + description: + 'The total number of reports made by the user on accounts.', + }, + recordReportCount: { + type: 'integer', + description: + 'The total number of reports made by the user on records.', + }, + reportedAccountCount: { + type: 'integer', + description: 'The total number of accounts reported by the user.', + }, + reportedRecordCount: { + type: 'integer', + description: 'The total number of records reported by the user.', + }, + takendownAccountCount: { + type: 'integer', + description: + "The total number of accounts taken down as a result of the user's reports.", + }, + takendownRecordCount: { + type: 'integer', + description: + "The total number of records taken down as a result of the user's reports.", + }, + labeledAccountCount: { + type: 'integer', + description: + "The total number of accounts labeled as a result of the user's reports.", + }, + labeledRecordCount: { + type: 'integer', + description: + "The total number of records labeled as a result of the user's reports.", + }, + }, + }, }, }, ToolsOzoneModerationEmitEvent: { @@ -12424,6 +12482,46 @@ export const schemaDict = { }, }, }, + ToolsOzoneModerationGetReporterStats: { + lexicon: 1, + id: 'tools.ozone.moderation.getReporterStats', + defs: { + main: { + type: 'query', + description: 'Get reporter stats for a list of users.', + parameters: { + type: 'params', + required: ['dids'], + properties: { + dids: { + type: 'array', + maxLength: 100, + items: { + type: 'string', + format: 'did', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['stats'], + properties: { + stats: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#reporterStats', + }, + }, + }, + }, + }, + }, + }, + }, ToolsOzoneModerationGetRepos: { lexicon: 1, id: 'tools.ozone.moderation.getRepos', @@ -14098,6 +14196,8 @@ export const ids = { ToolsOzoneModerationGetRecord: 'tools.ozone.moderation.getRecord', ToolsOzoneModerationGetRecords: 'tools.ozone.moderation.getRecords', ToolsOzoneModerationGetRepo: 'tools.ozone.moderation.getRepo', + ToolsOzoneModerationGetReporterStats: + 'tools.ozone.moderation.getReporterStats', ToolsOzoneModerationGetRepos: 'tools.ozone.moderation.getRepos', ToolsOzoneModerationQueryEvents: 'tools.ozone.moderation.queryEvents', ToolsOzoneModerationQueryStatuses: 'tools.ozone.moderation.queryStatuses', diff --git a/packages/api/src/client/types/tools/ozone/moderation/defs.ts b/packages/api/src/client/types/tools/ozone/moderation/defs.ts index 4135a248961..8c26552e7f2 100644 --- a/packages/api/src/client/types/tools/ozone/moderation/defs.ts +++ b/packages/api/src/client/types/tools/ozone/moderation/defs.ts @@ -931,3 +931,36 @@ export function isRecordHosting(v: unknown): v is RecordHosting { export function validateRecordHosting(v: unknown): ValidationResult { return lexicons.validate('tools.ozone.moderation.defs#recordHosting', v) } + +export interface ReporterStats { + did: string + /** The total number of reports made by the user on accounts. */ + accountReportCount: number + /** The total number of reports made by the user on records. */ + recordReportCount: number + /** The total number of accounts reported by the user. */ + reportedAccountCount: number + /** The total number of records reported by the user. */ + reportedRecordCount: number + /** The total number of accounts taken down as a result of the user's reports. */ + takendownAccountCount: number + /** The total number of records taken down as a result of the user's reports. */ + takendownRecordCount: number + /** The total number of accounts labeled as a result of the user's reports. */ + labeledAccountCount: number + /** The total number of records labeled as a result of the user's reports. */ + labeledRecordCount: number + [k: string]: unknown +} + +export function isReporterStats(v: unknown): v is ReporterStats { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#reporterStats' + ) +} + +export function validateReporterStats(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#reporterStats', v) +} diff --git a/packages/api/src/client/types/tools/ozone/moderation/getReporterStats.ts b/packages/api/src/client/types/tools/ozone/moderation/getReporterStats.ts new file mode 100644 index 00000000000..8b6c4d0b30f --- /dev/null +++ b/packages/api/src/client/types/tools/ozone/moderation/getReporterStats.ts @@ -0,0 +1,35 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { HeadersMap, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' +import * as ToolsOzoneModerationDefs from './defs' + +export interface QueryParams { + dids: string[] +} + +export type InputSchema = undefined + +export interface OutputSchema { + stats: ToolsOzoneModerationDefs.ReporterStats[] + [k: string]: unknown +} + +export interface CallOptions { + signal?: AbortSignal + headers?: HeadersMap +} + +export interface Response { + success: boolean + headers: HeadersMap + data: OutputSchema +} + +export function toKnownErr(e: any) { + return e +} diff --git a/packages/dev-env/src/moderator-client.ts b/packages/dev-env/src/moderator-client.ts index eb9212fb913..8cd69426cda 100644 --- a/packages/dev-env/src/moderator-client.ts +++ b/packages/dev-env/src/moderator-client.ts @@ -45,6 +45,19 @@ export class ModeratorClient { return result.data } + async getReporterStats(dids: string[]) { + const result = await this.agent.tools.ozone.moderation.getReporterStats( + { dids }, + { + headers: await this.ozone.modHeaders( + 'tools.ozone.moderation.getReporterStats', + 'admin', + ), + }, + ) + return result.data + } + async queryEvents(input: QueryEventsParams, role?: ModLevel) { const result = await this.agent.tools.ozone.moderation.queryEvents(input, { headers: await this.ozone.modHeaders( diff --git a/packages/ozone/src/api/index.ts b/packages/ozone/src/api/index.ts index 3ca6e6bf6ff..dc478ba9ad5 100644 --- a/packages/ozone/src/api/index.ts +++ b/packages/ozone/src/api/index.ts @@ -13,6 +13,7 @@ import getEvent from './moderation/getEvent' import adminGetRecord from './moderation/getRecord' import adminGetRecords from './moderation/getRecords' import getRepo from './moderation/getRepo' +import getReporterStats from './moderation/getReporterStats' import getRepos from './moderation/getRepos' import queryEvents from './moderation/queryEvents' import queryStatuses from './moderation/queryStatuses' @@ -72,5 +73,6 @@ export default function (server: Server, ctx: AppContext) { upsertOption(server, ctx) listOptions(server, ctx) removeOptions(server, ctx) + getReporterStats(server, ctx) return server } diff --git a/packages/ozone/src/api/moderation/getReporterStats.ts b/packages/ozone/src/api/moderation/getReporterStats.ts new file mode 100644 index 00000000000..ac07eaed60a --- /dev/null +++ b/packages/ozone/src/api/moderation/getReporterStats.ts @@ -0,0 +1,18 @@ +import { AppContext } from '../../context' +import { Server } from '../../lexicon' + +export default function (server: Server, ctx: AppContext) { + server.tools.ozone.moderation.getReporterStats({ + auth: ctx.authVerifier.modOrAdminToken, + handler: async ({ params }) => { + const db = ctx.db + + const stats = await ctx.modService(db).getReporterStats(params.dids) + + return { + encoding: 'application/json', + body: { stats }, + } + }, + }) +} diff --git a/packages/ozone/src/daemon/materialized-view-refresher.ts b/packages/ozone/src/daemon/materialized-view-refresher.ts index a4ef689b98d..e7cd914392e 100644 --- a/packages/ozone/src/daemon/materialized-view-refresher.ts +++ b/packages/ozone/src/daemon/materialized-view-refresher.ts @@ -10,6 +10,7 @@ export class MaterializedViewRefresher extends PeriodicBackgroundTask { 'record_events_stats', 'account_record_events_stats', 'account_record_status_stats', + 'reporter_stats', ]) { if (signal.aborted) break diff --git a/packages/ozone/src/db/migrations/20250206T003647759Z-reporter-stats-materialized-views.ts b/packages/ozone/src/db/migrations/20250206T003647759Z-reporter-stats-materialized-views.ts new file mode 100644 index 00000000000..2f3bdb2b89e --- /dev/null +++ b/packages/ozone/src/db/migrations/20250206T003647759Z-reporter-stats-materialized-views.ts @@ -0,0 +1,82 @@ +import { Kysely, sql } from 'kysely' +import { DatabaseSchemaType } from '../schema' + +export async function up(db: Kysely): Promise { + await sql` +CREATE MATERIALIZED VIEW reporter_stats AS +SELECT + reports."createdBy" AS did, + + -- Count total number of reports for accounts (including duplicates) + COUNT(*) FILTER ( + WHERE reports."action" = 'tools.ozone.moderation.defs#modEventReport' + AND reports."subjectUri" IS NULL + ) AS "accountReportCount", + + -- Count total number of reports for records (including duplicates) + COUNT(*) FILTER ( + WHERE reports."action" = 'tools.ozone.moderation.defs#modEventReport' + AND reports."subjectUri" IS NOT NULL + ) AS "recordReportCount", + + -- Count unique accounts reported + COUNT(DISTINCT reports."subjectDid") FILTER ( + WHERE reports."subjectUri" IS NULL + ) AS "reportedAccountCount", + + -- Count unique records reported + COUNT(DISTINCT reports."subjectUri") FILTER ( + WHERE reports."subjectUri" IS NOT NULL + ) AS "reportedRecordCount", + + -- Count unique accounts taken down by moderators + COUNT(DISTINCT actions."subjectDid") FILTER ( + WHERE actions."action" = 'tools.ozone.moderation.defs#modEventTakedown' + AND reports."subjectUri" IS NULL + ) AS "takendownAccountCount", + + -- Count unique records taken down by moderators + COUNT(DISTINCT actions."subjectUri") FILTER ( + WHERE actions."action" = 'tools.ozone.moderation.defs#modEventTakedown' + AND reports."subjectUri" IS NOT NULL + ) AS "takendownRecordCount", + + -- Count unique accounts labeled by moderators + COUNT(DISTINCT actions."subjectDid") FILTER ( + WHERE actions."action" = 'tools.ozone.moderation.defs#modEventLabel' + AND reports."subjectUri" IS NULL + ) AS "labeledAccountCount", + + -- Count unique records labeled by moderators + COUNT(DISTINCT actions."subjectUri") FILTER ( + WHERE actions."action" = 'tools.ozone.moderation.defs#modEventLabel' + AND reports."subjectUri" IS NOT NULL + ) AS "labeledRecordCount" + +FROM moderation_event AS reports +LEFT JOIN moderation_event AS actions ON + reports."subjectDid" = actions."subjectDid" + AND ( + (reports."subjectUri" IS NOT NULL AND reports."subjectUri" = actions."subjectUri") + OR (reports."subjectUri" IS NULL AND actions."subjectUri" IS NULL) + ) + AND actions."action" IN ( + 'tools.ozone.moderation.defs#modEventTakedown', + 'tools.ozone.moderation.defs#modEventLabel' + ) + +WHERE reports."action" = 'tools.ozone.moderation.defs#modEventReport' + +GROUP BY reports."createdBy"; + `.execute(db) + await db.schema + .createIndex('reporter_stats_did_idx') + .unique() + .on('reporter_stats') + .column('did') + .execute() +} + +export async function down(db: Kysely): Promise { + db.schema.dropView('reporter_stats').materialized().execute() +} diff --git a/packages/ozone/src/db/migrations/index.ts b/packages/ozone/src/db/migrations/index.ts index 609f4366572..9ca891720cc 100644 --- a/packages/ozone/src/db/migrations/index.ts +++ b/packages/ozone/src/db/migrations/index.ts @@ -19,3 +19,4 @@ export * as _20241018T205730722Z from './20241018T205730722Z-setting' export * as _20241026T205730722Z from './20241026T205730722Z-add-hosting-status-to-subject-status' export * as _20241220T144630860Z from './20241220T144630860Z-stats-materialized-views' export * as _20250204T003647759Z from './20250204T003647759Z-add-subject-priority-score' +export * as _20250206T003647759Z from './20250206T003647759Z-reporter-stats-materialized-views' diff --git a/packages/ozone/src/db/schema/index.ts b/packages/ozone/src/db/schema/index.ts index d1364c684cd..0569fe52cd1 100644 --- a/packages/ozone/src/db/schema/index.ts +++ b/packages/ozone/src/db/schema/index.ts @@ -12,6 +12,7 @@ import * as set from './ozone_set' import * as recordEventsStats from './record_events_stats' import * as recordPushEvent from './record_push_event' import * as repoPushEvent from './repo_push_event' +import * as reporterStats from './reporter_stats' import * as setting from './setting' import * as signingKey from './signing_key' @@ -29,7 +30,8 @@ export type DatabaseSchemaType = modEvent.PartialDB & accountEventsStats.PartialDB & recordEventsStats.PartialDB & accountRecordEventsStats.PartialDB & - accountRecordStatusStats.PartialDB + accountRecordStatusStats.PartialDB & + reporterStats.PartialDB export type DatabaseSchema = Kysely diff --git a/packages/ozone/src/db/schema/reporter_stats.ts b/packages/ozone/src/db/schema/reporter_stats.ts new file mode 100644 index 00000000000..26bbba33475 --- /dev/null +++ b/packages/ozone/src/db/schema/reporter_stats.ts @@ -0,0 +1,19 @@ +import { GeneratedAlways, Selectable } from 'kysely' + +export const tableName = 'reporter_stats' + +export type ReporterStats = { + did: GeneratedAlways + accountReportCount: GeneratedAlways + recordReportCount: GeneratedAlways + reportedAccountCount: GeneratedAlways + reportedRecordCount: GeneratedAlways + takendownAccountCount: GeneratedAlways + takendownRecordCount: GeneratedAlways + labeledAccountCount: GeneratedAlways + labeledRecordCount: GeneratedAlways +} + +export type ReporterStatsRow = Selectable + +export type PartialDB = { [tableName]: ReporterStats } diff --git a/packages/ozone/src/lexicon/index.ts b/packages/ozone/src/lexicon/index.ts index dc68082677f..d78e9dd0773 100644 --- a/packages/ozone/src/lexicon/index.ts +++ b/packages/ozone/src/lexicon/index.ts @@ -173,6 +173,7 @@ import * as ToolsOzoneModerationGetEvent from './types/tools/ozone/moderation/ge import * as ToolsOzoneModerationGetRecord from './types/tools/ozone/moderation/getRecord' import * as ToolsOzoneModerationGetRecords from './types/tools/ozone/moderation/getRecords' import * as ToolsOzoneModerationGetRepo from './types/tools/ozone/moderation/getRepo' +import * as ToolsOzoneModerationGetReporterStats from './types/tools/ozone/moderation/getReporterStats' import * as ToolsOzoneModerationGetRepos from './types/tools/ozone/moderation/getRepos' import * as ToolsOzoneModerationQueryEvents from './types/tools/ozone/moderation/queryEvents' import * as ToolsOzoneModerationQueryStatuses from './types/tools/ozone/moderation/queryStatuses' @@ -2376,6 +2377,17 @@ export class ToolsOzoneModerationNS { return this._server.xrpc.method(nsid, cfg) } + getReporterStats( + cfg: ConfigOf< + AV, + ToolsOzoneModerationGetReporterStats.Handler>, + ToolsOzoneModerationGetReporterStats.HandlerReqCtx> + >, + ) { + const nsid = 'tools.ozone.moderation.getReporterStats' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getRepos( cfg: ConfigOf< AV, diff --git a/packages/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts index 6e709774761..3157d80e462 100644 --- a/packages/ozone/src/lexicon/lexicons.ts +++ b/packages/ozone/src/lexicon/lexicons.ts @@ -12213,6 +12213,64 @@ export const schemaDict = { }, }, }, + reporterStats: { + type: 'object', + required: [ + 'did', + 'accountReportCount', + 'recordReportCount', + 'reportedAccountCount', + 'reportedRecordCount', + 'takendownAccountCount', + 'takendownRecordCount', + 'labeledAccountCount', + 'labeledRecordCount', + ], + properties: { + did: { + type: 'string', + format: 'did', + }, + accountReportCount: { + type: 'integer', + description: + 'The total number of reports made by the user on accounts.', + }, + recordReportCount: { + type: 'integer', + description: + 'The total number of reports made by the user on records.', + }, + reportedAccountCount: { + type: 'integer', + description: 'The total number of accounts reported by the user.', + }, + reportedRecordCount: { + type: 'integer', + description: 'The total number of records reported by the user.', + }, + takendownAccountCount: { + type: 'integer', + description: + "The total number of accounts taken down as a result of the user's reports.", + }, + takendownRecordCount: { + type: 'integer', + description: + "The total number of records taken down as a result of the user's reports.", + }, + labeledAccountCount: { + type: 'integer', + description: + "The total number of accounts labeled as a result of the user's reports.", + }, + labeledRecordCount: { + type: 'integer', + description: + "The total number of records labeled as a result of the user's reports.", + }, + }, + }, }, }, ToolsOzoneModerationEmitEvent: { @@ -12424,6 +12482,46 @@ export const schemaDict = { }, }, }, + ToolsOzoneModerationGetReporterStats: { + lexicon: 1, + id: 'tools.ozone.moderation.getReporterStats', + defs: { + main: { + type: 'query', + description: 'Get reporter stats for a list of users.', + parameters: { + type: 'params', + required: ['dids'], + properties: { + dids: { + type: 'array', + maxLength: 100, + items: { + type: 'string', + format: 'did', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['stats'], + properties: { + stats: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#reporterStats', + }, + }, + }, + }, + }, + }, + }, + }, ToolsOzoneModerationGetRepos: { lexicon: 1, id: 'tools.ozone.moderation.getRepos', @@ -14098,6 +14196,8 @@ export const ids = { ToolsOzoneModerationGetRecord: 'tools.ozone.moderation.getRecord', ToolsOzoneModerationGetRecords: 'tools.ozone.moderation.getRecords', ToolsOzoneModerationGetRepo: 'tools.ozone.moderation.getRepo', + ToolsOzoneModerationGetReporterStats: + 'tools.ozone.moderation.getReporterStats', ToolsOzoneModerationGetRepos: 'tools.ozone.moderation.getRepos', ToolsOzoneModerationQueryEvents: 'tools.ozone.moderation.queryEvents', ToolsOzoneModerationQueryStatuses: 'tools.ozone.moderation.queryStatuses', diff --git a/packages/ozone/src/lexicon/types/tools/ozone/moderation/defs.ts b/packages/ozone/src/lexicon/types/tools/ozone/moderation/defs.ts index 1fb7bf8e25e..fb65c9b8187 100644 --- a/packages/ozone/src/lexicon/types/tools/ozone/moderation/defs.ts +++ b/packages/ozone/src/lexicon/types/tools/ozone/moderation/defs.ts @@ -931,3 +931,36 @@ export function isRecordHosting(v: unknown): v is RecordHosting { export function validateRecordHosting(v: unknown): ValidationResult { return lexicons.validate('tools.ozone.moderation.defs#recordHosting', v) } + +export interface ReporterStats { + did: string + /** The total number of reports made by the user on accounts. */ + accountReportCount: number + /** The total number of reports made by the user on records. */ + recordReportCount: number + /** The total number of accounts reported by the user. */ + reportedAccountCount: number + /** The total number of records reported by the user. */ + reportedRecordCount: number + /** The total number of accounts taken down as a result of the user's reports. */ + takendownAccountCount: number + /** The total number of records taken down as a result of the user's reports. */ + takendownRecordCount: number + /** The total number of accounts labeled as a result of the user's reports. */ + labeledAccountCount: number + /** The total number of records labeled as a result of the user's reports. */ + labeledRecordCount: number + [k: string]: unknown +} + +export function isReporterStats(v: unknown): v is ReporterStats { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#reporterStats' + ) +} + +export function validateReporterStats(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#reporterStats', v) +} diff --git a/packages/ozone/src/lexicon/types/tools/ozone/moderation/getReporterStats.ts b/packages/ozone/src/lexicon/types/tools/ozone/moderation/getReporterStats.ts new file mode 100644 index 00000000000..a1a645a8042 --- /dev/null +++ b/packages/ozone/src/lexicon/types/tools/ozone/moderation/getReporterStats.ts @@ -0,0 +1,47 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' +import * as ToolsOzoneModerationDefs from './defs' + +export interface QueryParams { + dids: string[] +} + +export type InputSchema = undefined + +export interface OutputSchema { + stats: ToolsOzoneModerationDefs.ReporterStats[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response + resetRouteRateLimits: () => Promise +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/ozone/src/mod-service/index.ts b/packages/ozone/src/mod-service/index.ts index 59e9588223d..bc2fcf4f984 100644 --- a/packages/ozone/src/mod-service/index.ts +++ b/packages/ozone/src/mod-service/index.ts @@ -61,6 +61,7 @@ import { } from './types' import { formatLabel, formatLabelRow, signLabel } from './util' import { AuthHeaders, ModerationViews } from './views' +import { ReporterStatsRow } from '../db/schema/reporter_stats' export type ModerationServiceCreator = (db: Database) => ModerationService @@ -1266,6 +1267,14 @@ export class ModerationService { throw new InvalidRequestError('Email was accepted but not sent') } } + + async getReporterStats(dids: string[]) { + return this.db.db + .selectFrom('reporter_stats') + .where('did', 'in', dids) + .selectAll() + .execute() + } } const parseTags = (tags?: string[]) => diff --git a/packages/ozone/tests/get-reporter-stats.test.ts b/packages/ozone/tests/get-reporter-stats.test.ts new file mode 100644 index 00000000000..be60654226f --- /dev/null +++ b/packages/ozone/tests/get-reporter-stats.test.ts @@ -0,0 +1,117 @@ +import { + ComAtprotoModerationDefs, + ToolsOzoneModerationDefs, +} from '@atproto/api' +import { + ModeratorClient, + SeedClient, + TestNetwork, + basicSeed, +} from '@atproto/dev-env' + +describe('reporter-stats', () => { + let network: TestNetwork + let sc: SeedClient + let modClient: ModeratorClient + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'ozone_reporter_stats', + ozone: { + dbMaterializedViewRefreshIntervalMs: 1000, + }, + }) + sc = network.getSeedClient() + modClient = network.ozone.getModClient() + await basicSeed(sc) + await network.processAll() + }) + + afterAll(async () => { + await network.close() + }) + + const getReporterStats = async ( + did: string, + ): Promise => { + const { stats } = await modClient.getReporterStats([did]) + return stats[0] + } + + it('updates reporter stats based on actions', async () => { + const bobsPostSubject = { + $type: 'com.atproto.repo.strongRef', + uri: sc.posts[sc.dids.bob][1].ref.uriStr, + cid: sc.posts[sc.dids.bob][1].ref.cidStr, + } + const carolsAccountSubject = { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.carol, + } + + await Promise.all([ + sc.createReport({ + reportedBy: sc.dids.alice, + reasonType: ComAtprotoModerationDefs.REASONMISLEADING, + reason: 'misleading', + subject: bobsPostSubject, + }), + sc.createReport({ + reportedBy: sc.dids.alice, + reasonType: ComAtprotoModerationDefs.REASONOTHER, + reason: 'test', + subject: bobsPostSubject, + }), + sc.createReport({ + reportedBy: sc.dids.alice, + reasonType: ComAtprotoModerationDefs.REASONMISLEADING, + reason: 'misleading', + subject: carolsAccountSubject, + }), + ]) + + await network.processAll() + const statsAfterReport = await getReporterStats(sc.dids.alice) + expect(statsAfterReport).toMatchObject({ + did: sc.dids.alice, + accountReportCount: 1, + recordReportCount: 2, + reportedAccountCount: 1, + reportedRecordCount: 1, + takendownAccountCount: 0, + takendownRecordCount: 0, + labeledAccountCount: 0, + labeledRecordCount: 0, + }) + + await Promise.all([ + modClient.performTakedown({ + subject: bobsPostSubject, + policies: ['trolling'], + }), + modClient.emitEvent({ + subject: carolsAccountSubject, + event: { + $type: 'tools.ozone.moderation.defs#modEventLabel', + createLabelVals: ['spam'], + negateLabelVals: [], + }, + }), + ]) + + await network.processAll() + await new Promise((resolve) => setTimeout(resolve, 1000)) + const statsAfterAction = await getReporterStats(sc.dids.alice) + expect(statsAfterAction).toMatchObject({ + did: sc.dids.alice, + accountReportCount: 1, + recordReportCount: 2, + reportedAccountCount: 1, + reportedRecordCount: 1, + takendownAccountCount: 0, + takendownRecordCount: 1, + labeledAccountCount: 1, + labeledRecordCount: 0, + }) + }) +}) diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index dc68082677f..d78e9dd0773 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -173,6 +173,7 @@ import * as ToolsOzoneModerationGetEvent from './types/tools/ozone/moderation/ge import * as ToolsOzoneModerationGetRecord from './types/tools/ozone/moderation/getRecord' import * as ToolsOzoneModerationGetRecords from './types/tools/ozone/moderation/getRecords' import * as ToolsOzoneModerationGetRepo from './types/tools/ozone/moderation/getRepo' +import * as ToolsOzoneModerationGetReporterStats from './types/tools/ozone/moderation/getReporterStats' import * as ToolsOzoneModerationGetRepos from './types/tools/ozone/moderation/getRepos' import * as ToolsOzoneModerationQueryEvents from './types/tools/ozone/moderation/queryEvents' import * as ToolsOzoneModerationQueryStatuses from './types/tools/ozone/moderation/queryStatuses' @@ -2376,6 +2377,17 @@ export class ToolsOzoneModerationNS { return this._server.xrpc.method(nsid, cfg) } + getReporterStats( + cfg: ConfigOf< + AV, + ToolsOzoneModerationGetReporterStats.Handler>, + ToolsOzoneModerationGetReporterStats.HandlerReqCtx> + >, + ) { + const nsid = 'tools.ozone.moderation.getReporterStats' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getRepos( cfg: ConfigOf< AV, diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 6e709774761..3157d80e462 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -12213,6 +12213,64 @@ export const schemaDict = { }, }, }, + reporterStats: { + type: 'object', + required: [ + 'did', + 'accountReportCount', + 'recordReportCount', + 'reportedAccountCount', + 'reportedRecordCount', + 'takendownAccountCount', + 'takendownRecordCount', + 'labeledAccountCount', + 'labeledRecordCount', + ], + properties: { + did: { + type: 'string', + format: 'did', + }, + accountReportCount: { + type: 'integer', + description: + 'The total number of reports made by the user on accounts.', + }, + recordReportCount: { + type: 'integer', + description: + 'The total number of reports made by the user on records.', + }, + reportedAccountCount: { + type: 'integer', + description: 'The total number of accounts reported by the user.', + }, + reportedRecordCount: { + type: 'integer', + description: 'The total number of records reported by the user.', + }, + takendownAccountCount: { + type: 'integer', + description: + "The total number of accounts taken down as a result of the user's reports.", + }, + takendownRecordCount: { + type: 'integer', + description: + "The total number of records taken down as a result of the user's reports.", + }, + labeledAccountCount: { + type: 'integer', + description: + "The total number of accounts labeled as a result of the user's reports.", + }, + labeledRecordCount: { + type: 'integer', + description: + "The total number of records labeled as a result of the user's reports.", + }, + }, + }, }, }, ToolsOzoneModerationEmitEvent: { @@ -12424,6 +12482,46 @@ export const schemaDict = { }, }, }, + ToolsOzoneModerationGetReporterStats: { + lexicon: 1, + id: 'tools.ozone.moderation.getReporterStats', + defs: { + main: { + type: 'query', + description: 'Get reporter stats for a list of users.', + parameters: { + type: 'params', + required: ['dids'], + properties: { + dids: { + type: 'array', + maxLength: 100, + items: { + type: 'string', + format: 'did', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['stats'], + properties: { + stats: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#reporterStats', + }, + }, + }, + }, + }, + }, + }, + }, ToolsOzoneModerationGetRepos: { lexicon: 1, id: 'tools.ozone.moderation.getRepos', @@ -14098,6 +14196,8 @@ export const ids = { ToolsOzoneModerationGetRecord: 'tools.ozone.moderation.getRecord', ToolsOzoneModerationGetRecords: 'tools.ozone.moderation.getRecords', ToolsOzoneModerationGetRepo: 'tools.ozone.moderation.getRepo', + ToolsOzoneModerationGetReporterStats: + 'tools.ozone.moderation.getReporterStats', ToolsOzoneModerationGetRepos: 'tools.ozone.moderation.getRepos', ToolsOzoneModerationQueryEvents: 'tools.ozone.moderation.queryEvents', ToolsOzoneModerationQueryStatuses: 'tools.ozone.moderation.queryStatuses', diff --git a/packages/pds/src/lexicon/types/tools/ozone/moderation/defs.ts b/packages/pds/src/lexicon/types/tools/ozone/moderation/defs.ts index 1fb7bf8e25e..fb65c9b8187 100644 --- a/packages/pds/src/lexicon/types/tools/ozone/moderation/defs.ts +++ b/packages/pds/src/lexicon/types/tools/ozone/moderation/defs.ts @@ -931,3 +931,36 @@ export function isRecordHosting(v: unknown): v is RecordHosting { export function validateRecordHosting(v: unknown): ValidationResult { return lexicons.validate('tools.ozone.moderation.defs#recordHosting', v) } + +export interface ReporterStats { + did: string + /** The total number of reports made by the user on accounts. */ + accountReportCount: number + /** The total number of reports made by the user on records. */ + recordReportCount: number + /** The total number of accounts reported by the user. */ + reportedAccountCount: number + /** The total number of records reported by the user. */ + reportedRecordCount: number + /** The total number of accounts taken down as a result of the user's reports. */ + takendownAccountCount: number + /** The total number of records taken down as a result of the user's reports. */ + takendownRecordCount: number + /** The total number of accounts labeled as a result of the user's reports. */ + labeledAccountCount: number + /** The total number of records labeled as a result of the user's reports. */ + labeledRecordCount: number + [k: string]: unknown +} + +export function isReporterStats(v: unknown): v is ReporterStats { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#reporterStats' + ) +} + +export function validateReporterStats(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#reporterStats', v) +} diff --git a/packages/pds/src/lexicon/types/tools/ozone/moderation/getReporterStats.ts b/packages/pds/src/lexicon/types/tools/ozone/moderation/getReporterStats.ts new file mode 100644 index 00000000000..a1a645a8042 --- /dev/null +++ b/packages/pds/src/lexicon/types/tools/ozone/moderation/getReporterStats.ts @@ -0,0 +1,47 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' +import * as ToolsOzoneModerationDefs from './defs' + +export interface QueryParams { + dids: string[] +} + +export type InputSchema = undefined + +export interface OutputSchema { + stats: ToolsOzoneModerationDefs.ReporterStats[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response + resetRouteRateLimits: () => Promise +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput