@atproto/ozone 0.1.69 → 0.1.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/api/moderation/queryStatuses.d.ts.map +1 -1
  3. package/dist/api/moderation/queryStatuses.js +1 -33
  4. package/dist/api/moderation/queryStatuses.js.map +1 -1
  5. package/dist/background.d.ts +49 -6
  6. package/dist/background.d.ts.map +1 -1
  7. package/dist/background.js +149 -14
  8. package/dist/background.js.map +1 -1
  9. package/dist/config/config.d.ts +1 -0
  10. package/dist/config/config.d.ts.map +1 -1
  11. package/dist/config/config.js +1 -0
  12. package/dist/config/config.js.map +1 -1
  13. package/dist/config/env.d.ts +1 -0
  14. package/dist/config/env.d.ts.map +1 -1
  15. package/dist/config/env.js +1 -0
  16. package/dist/config/env.js.map +1 -1
  17. package/dist/daemon/context.d.ts +9 -3
  18. package/dist/daemon/context.d.ts.map +1 -1
  19. package/dist/daemon/context.js +33 -3
  20. package/dist/daemon/context.js.map +1 -1
  21. package/dist/daemon/index.d.ts.map +1 -1
  22. package/dist/daemon/index.js +3 -6
  23. package/dist/daemon/index.js.map +1 -1
  24. package/dist/daemon/materialized-view-refresher.d.ts +5 -0
  25. package/dist/daemon/materialized-view-refresher.d.ts.map +1 -0
  26. package/dist/daemon/materialized-view-refresher.js +29 -0
  27. package/dist/daemon/materialized-view-refresher.js.map +1 -0
  28. package/dist/db/migrations/20241220T144630860Z-stats-materialized-views.d.ts +5 -0
  29. package/dist/db/migrations/20241220T144630860Z-stats-materialized-views.d.ts.map +1 -0
  30. package/dist/db/migrations/20241220T144630860Z-stats-materialized-views.js +158 -0
  31. package/dist/db/migrations/20241220T144630860Z-stats-materialized-views.js.map +1 -0
  32. package/dist/db/migrations/index.d.ts +1 -0
  33. package/dist/db/migrations/index.d.ts.map +1 -1
  34. package/dist/db/migrations/index.js +2 -1
  35. package/dist/db/migrations/index.js.map +1 -1
  36. package/dist/db/schema/account_events_stats.d.ts +15 -0
  37. package/dist/db/schema/account_events_stats.d.ts.map +1 -0
  38. package/dist/db/schema/account_events_stats.js +5 -0
  39. package/dist/db/schema/account_events_stats.js.map +1 -0
  40. package/dist/db/schema/account_record_events_stats.d.ts +15 -0
  41. package/dist/db/schema/account_record_events_stats.d.ts.map +1 -0
  42. package/dist/db/schema/account_record_events_stats.js +5 -0
  43. package/dist/db/schema/account_record_events_stats.js.map +1 -0
  44. package/dist/db/schema/account_record_status_stats.d.ts +15 -0
  45. package/dist/db/schema/account_record_status_stats.d.ts.map +1 -0
  46. package/dist/db/schema/account_record_status_stats.js +5 -0
  47. package/dist/db/schema/account_record_status_stats.js.map +1 -0
  48. package/dist/db/schema/index.d.ts +5 -1
  49. package/dist/db/schema/index.d.ts.map +1 -1
  50. package/dist/db/schema/record_events_stats.d.ts +14 -0
  51. package/dist/db/schema/record_events_stats.d.ts.map +1 -0
  52. package/dist/db/schema/record_events_stats.js +5 -0
  53. package/dist/db/schema/record_events_stats.js.map +1 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +1 -4
  56. package/dist/index.js.map +1 -1
  57. package/dist/lexicon/lexicons.d.ts +174 -2
  58. package/dist/lexicon/lexicons.d.ts.map +1 -1
  59. package/dist/lexicon/lexicons.js +92 -1
  60. package/dist/lexicon/lexicons.js.map +1 -1
  61. package/dist/lexicon/types/tools/ozone/moderation/defs.d.ts +40 -0
  62. package/dist/lexicon/types/tools/ozone/moderation/defs.d.ts.map +1 -1
  63. package/dist/lexicon/types/tools/ozone/moderation/defs.js +20 -0
  64. package/dist/lexicon/types/tools/ozone/moderation/defs.js.map +1 -1
  65. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.d.ts +7 -1
  66. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.d.ts.map +1 -1
  67. package/dist/mod-service/index.d.ts +4 -62
  68. package/dist/mod-service/index.d.ts.map +1 -1
  69. package/dist/mod-service/index.js +80 -74
  70. package/dist/mod-service/index.js.map +1 -1
  71. package/dist/mod-service/status.d.ts +115 -4
  72. package/dist/mod-service/status.d.ts.map +1 -1
  73. package/dist/mod-service/status.js +51 -34
  74. package/dist/mod-service/status.js.map +1 -1
  75. package/dist/mod-service/types.d.ts +16 -1
  76. package/dist/mod-service/types.d.ts.map +1 -1
  77. package/dist/mod-service/views.d.ts.map +1 -1
  78. package/dist/mod-service/views.js +49 -41
  79. package/dist/mod-service/views.js.map +1 -1
  80. package/dist/util.d.ts +34 -0
  81. package/dist/util.d.ts.map +1 -1
  82. package/dist/util.js +132 -0
  83. package/dist/util.js.map +1 -1
  84. package/package.json +3 -3
  85. package/src/api/moderation/queryStatuses.ts +1 -63
  86. package/src/background.ts +140 -14
  87. package/src/config/config.ts +2 -0
  88. package/src/config/env.ts +4 -0
  89. package/src/daemon/context.ts +43 -5
  90. package/src/daemon/index.ts +3 -6
  91. package/src/daemon/materialized-view-refresher.ts +27 -0
  92. package/src/db/migrations/20241220T144630860Z-stats-materialized-views.ts +218 -0
  93. package/src/db/migrations/index.ts +1 -0
  94. package/src/db/schema/account_events_stats.ts +16 -0
  95. package/src/db/schema/account_record_events_stats.ts +15 -0
  96. package/src/db/schema/account_record_status_stats.ts +15 -0
  97. package/src/db/schema/index.ts +10 -1
  98. package/src/db/schema/record_events_stats.ts +15 -0
  99. package/src/index.ts +1 -7
  100. package/src/lexicon/lexicons.ts +100 -1
  101. package/src/lexicon/types/tools/ozone/moderation/defs.ts +62 -0
  102. package/src/lexicon/types/tools/ozone/moderation/queryStatuses.ts +11 -1
  103. package/src/mod-service/index.ts +181 -118
  104. package/src/mod-service/status.ts +55 -28
  105. package/src/mod-service/types.ts +22 -1
  106. package/src/mod-service/views.ts +64 -50
  107. package/src/util.ts +145 -0
  108. package/tests/__snapshots__/get-record.test.ts.snap +28 -0
  109. package/tests/__snapshots__/get-records.test.ts.snap +14 -0
  110. package/tests/__snapshots__/get-repo.test.ts.snap +11 -0
  111. package/tests/__snapshots__/get-repos.test.ts.snap +11 -0
  112. package/tests/__snapshots__/moderation-events.test.ts.snap +19 -0
  113. package/tests/__snapshots__/moderation-statuses.test.ts.snap +114 -0
  114. package/tests/get-record.test.ts +4 -0
  115. package/tests/get-records.test.ts +4 -0
  116. package/tests/get-repo.test.ts +4 -0
  117. package/tests/get-repos.test.ts +4 -0
  118. package/tests/moderation-events.test.ts +4 -0
  119. package/tests/moderation-statuses.test.ts +4 -0
  120. package/tests/query-labels.test.ts +1 -0
  121. package/tsconfig.build.tsbuildinfo +1 -1
@@ -11322,6 +11322,85 @@ export const schemaDict = {
11322
11322
  type: 'string',
11323
11323
  },
11324
11324
  },
11325
+ accountStats: {
11326
+ description: 'Statistics related to the account subject',
11327
+ type: 'ref',
11328
+ ref: 'lex:tools.ozone.moderation.defs#accountStats',
11329
+ },
11330
+ recordsStats: {
11331
+ description:
11332
+ "Statistics related to the record subjects authored by the subject's account",
11333
+ type: 'ref',
11334
+ ref: 'lex:tools.ozone.moderation.defs#recordsStats',
11335
+ },
11336
+ },
11337
+ },
11338
+ accountStats: {
11339
+ description: 'Statistics about a particular account subject',
11340
+ type: 'object',
11341
+ properties: {
11342
+ reportCount: {
11343
+ description: 'Total number of reports on the account',
11344
+ type: 'integer',
11345
+ },
11346
+ appealCount: {
11347
+ description:
11348
+ 'Total number of appeals against a moderation action on the account',
11349
+ type: 'integer',
11350
+ },
11351
+ suspendCount: {
11352
+ description: 'Number of times the account was suspended',
11353
+ type: 'integer',
11354
+ },
11355
+ escalateCount: {
11356
+ description: 'Number of times the account was escalated',
11357
+ type: 'integer',
11358
+ },
11359
+ takedownCount: {
11360
+ description: 'Number of times the account was taken down',
11361
+ type: 'integer',
11362
+ },
11363
+ },
11364
+ },
11365
+ recordsStats: {
11366
+ description: 'Statistics about a set of record subject items',
11367
+ type: 'object',
11368
+ properties: {
11369
+ totalReports: {
11370
+ description:
11371
+ 'Cumulative sum of the number of reports on the items in the set',
11372
+ type: 'integer',
11373
+ },
11374
+ reportedCount: {
11375
+ description: 'Number of items that were reported at least once',
11376
+ type: 'integer',
11377
+ },
11378
+ escalatedCount: {
11379
+ description: 'Number of items that were escalated at least once',
11380
+ type: 'integer',
11381
+ },
11382
+ appealedCount: {
11383
+ description: 'Number of items that were appealed at least once',
11384
+ type: 'integer',
11385
+ },
11386
+ subjectCount: {
11387
+ description: 'Total number of item in the set',
11388
+ type: 'integer',
11389
+ },
11390
+ pendingCount: {
11391
+ description:
11392
+ 'Number of item currently in "reviewOpen" or "reviewEscalated" state',
11393
+ type: 'integer',
11394
+ },
11395
+ processedCount: {
11396
+ description:
11397
+ 'Number of item currently in "reviewNone" or "reviewClosed" state',
11398
+ type: 'integer',
11399
+ },
11400
+ takendownCount: {
11401
+ description: 'Number of item currently taken down',
11402
+ type: 'integer',
11403
+ },
11325
11404
  },
11326
11405
  },
11327
11406
  subjectReviewState: {
@@ -12570,7 +12649,12 @@ export const schemaDict = {
12570
12649
  sortField: {
12571
12650
  type: 'string',
12572
12651
  default: 'lastReportedAt',
12573
- enum: ['lastReviewedAt', 'lastReportedAt'],
12652
+ enum: [
12653
+ 'lastReviewedAt',
12654
+ 'lastReportedAt',
12655
+ 'reportedRecordsCount',
12656
+ 'takendownRecordsCount',
12657
+ ],
12574
12658
  },
12575
12659
  sortDirection: {
12576
12660
  type: 'string',
@@ -12625,6 +12709,21 @@ export const schemaDict = {
12625
12709
  "If specified, subjects of the given type (account or record) will be returned. When this is set to 'account' the 'collections' parameter will be ignored. When includeAllUserRecords or subject is set, this will be ignored.",
12626
12710
  knownValues: ['account', 'record'],
12627
12711
  },
12712
+ minAccountSuspendCount: {
12713
+ type: 'integer',
12714
+ description:
12715
+ 'If specified, only subjects that belong to an account that has at least this many suspensions will be returned.',
12716
+ },
12717
+ minReportedRecordsCount: {
12718
+ type: 'integer',
12719
+ description:
12720
+ 'If specified, only subjects that belong to an account that has at least this many reported records will be returned.',
12721
+ },
12722
+ minTakendownRecordsCount: {
12723
+ type: 'integer',
12724
+ description:
12725
+ 'If specified, only subjects that belong to an account that has at least this many taken down records will be returned.',
12726
+ },
12628
12727
  },
12629
12728
  },
12630
12729
  output: {
@@ -136,6 +136,8 @@ export interface SubjectStatusView {
136
136
  appealed?: boolean
137
137
  suspendUntil?: string
138
138
  tags?: string[]
139
+ accountStats?: AccountStats
140
+ recordsStats?: RecordsStats
139
141
  [k: string]: unknown
140
142
  }
141
143
 
@@ -151,6 +153,66 @@ export function validateSubjectStatusView(v: unknown): ValidationResult {
151
153
  return lexicons.validate('tools.ozone.moderation.defs#subjectStatusView', v)
152
154
  }
153
155
 
156
+ /** Statistics about a particular account subject */
157
+ export interface AccountStats {
158
+ /** Total number of reports on the account */
159
+ reportCount?: number
160
+ /** Total number of appeals against a moderation action on the account */
161
+ appealCount?: number
162
+ /** Number of times the account was suspended */
163
+ suspendCount?: number
164
+ /** Number of times the account was escalated */
165
+ escalateCount?: number
166
+ /** Number of times the account was taken down */
167
+ takedownCount?: number
168
+ [k: string]: unknown
169
+ }
170
+
171
+ export function isAccountStats(v: unknown): v is AccountStats {
172
+ return (
173
+ isObj(v) &&
174
+ hasProp(v, '$type') &&
175
+ v.$type === 'tools.ozone.moderation.defs#accountStats'
176
+ )
177
+ }
178
+
179
+ export function validateAccountStats(v: unknown): ValidationResult {
180
+ return lexicons.validate('tools.ozone.moderation.defs#accountStats', v)
181
+ }
182
+
183
+ /** Statistics about a set of record subject items */
184
+ export interface RecordsStats {
185
+ /** Cumulative sum of the number of reports on the items in the set */
186
+ totalReports?: number
187
+ /** Number of items that were reported at least once */
188
+ reportedCount?: number
189
+ /** Number of items that were escalated at least once */
190
+ escalatedCount?: number
191
+ /** Number of items that were appealed at least once */
192
+ appealedCount?: number
193
+ /** Total number of item in the set */
194
+ subjectCount?: number
195
+ /** Number of item currently in "reviewOpen" or "reviewEscalated" state */
196
+ pendingCount?: number
197
+ /** Number of item currently in "reviewNone" or "reviewClosed" state */
198
+ processedCount?: number
199
+ /** Number of item currently taken down */
200
+ takendownCount?: number
201
+ [k: string]: unknown
202
+ }
203
+
204
+ export function isRecordsStats(v: unknown): v is RecordsStats {
205
+ return (
206
+ isObj(v) &&
207
+ hasProp(v, '$type') &&
208
+ v.$type === 'tools.ozone.moderation.defs#recordsStats'
209
+ )
210
+ }
211
+
212
+ export function validateRecordsStats(v: unknown): ValidationResult {
213
+ return lexicons.validate('tools.ozone.moderation.defs#recordsStats', v)
214
+ }
215
+
154
216
  export type SubjectReviewState =
155
217
  | 'lex:tools.ozone.moderation.defs#reviewOpen'
156
218
  | 'lex:tools.ozone.moderation.defs#reviewEscalated'
@@ -49,7 +49,11 @@ export interface QueryParams {
49
49
  ignoreSubjects?: string[]
50
50
  /** Get all subject statuses that were reviewed by a specific moderator */
51
51
  lastReviewedBy?: string
52
- sortField: 'lastReviewedAt' | 'lastReportedAt'
52
+ sortField:
53
+ | 'lastReviewedAt'
54
+ | 'lastReportedAt'
55
+ | 'reportedRecordsCount'
56
+ | 'takendownRecordsCount'
53
57
  sortDirection: 'asc' | 'desc'
54
58
  /** Get subjects that were taken down */
55
59
  takendown?: boolean
@@ -63,6 +67,12 @@ export interface QueryParams {
63
67
  collections?: string[]
64
68
  /** If specified, subjects of the given type (account or record) will be returned. When this is set to 'account' the 'collections' parameter will be ignored. When includeAllUserRecords or subject is set, this will be ignored. */
65
69
  subjectType?: 'account' | 'record' | (string & {})
70
+ /** If specified, only subjects that belong to an account that has at least this many suspensions will be returned. */
71
+ minAccountSuspendCount?: number
72
+ /** If specified, only subjects that belong to an account that has at least this many reported records will be returned. */
73
+ minReportedRecordsCount?: number
74
+ /** If specified, only subjects that belong to an account that has at least this many taken down records will be returned. */
75
+ minTakendownRecordsCount?: number
66
76
  }
67
77
 
68
78
  export type InputSchema = undefined
@@ -1,5 +1,5 @@
1
1
  import net from 'node:net'
2
- import { Insertable, SelectQueryBuilder, sql } from 'kysely'
2
+ import { Insertable, sql } from 'kysely'
3
3
  import { CID } from 'multiformats/cid'
4
4
  import { AtUri, INVALID_HANDLE } from '@atproto/syntax'
5
5
  import { InvalidRequestError } from '@atproto/xrpc-server'
@@ -29,16 +29,19 @@ import { RepoRef, RepoBlobRef } from '../lexicon/types/com/atproto/admin/defs'
29
29
  import {
30
30
  adjustModerationSubjectStatus,
31
31
  getStatusIdentifierFromSubject,
32
+ moderationSubjectStatusQueryBuilder,
32
33
  } from './status'
33
34
  import {
34
35
  ModEventType,
35
36
  ModerationEventRow,
36
37
  ModerationSubjectStatusRow,
38
+ ModerationSubjectStatusRowWithHandle,
37
39
  ReversibleModerationEvent,
38
40
  } from './types'
39
41
  import { ModerationEvent } from '../db/schema/moderation_event'
40
42
  import { StatusKeyset, TimeIdKeyset, paginate } from '../db/pagination'
41
43
  import { Label } from '../lexicon/types/com/atproto/label/defs'
44
+ import { QueryParams as QueryStatusParams } from '../lexicon/types/tools/ozone/moderation/queryStatuses'
42
45
  import {
43
46
  ModSubject,
44
47
  RecordSubject,
@@ -56,6 +59,7 @@ import { httpLogger as log } from '../logger'
56
59
  import { OzoneConfig } from '../config'
57
60
  import { LABELER_HEADER_NAME, ParsedLabelers } from '../util'
58
61
  import { ids } from '../lexicon/lexicons'
62
+ import { getReviewState } from '../api/util'
59
63
 
60
64
  export type ModerationServiceCreator = (db: Database) => ModerationService
61
65
 
@@ -805,45 +809,6 @@ export class ModerationService {
805
809
  return result
806
810
  }
807
811
 
808
- applyTagFilter = (
809
- builder: SelectQueryBuilder<any, any, any>,
810
- tags: string[],
811
- ) => {
812
- const { ref } = this.db.db.dynamic
813
- // Build an array of conditions
814
- const conditions = tags
815
- .map((tag) => {
816
- if (tag.includes('&&')) {
817
- // Split by '&&' for AND logic
818
- const subTags = tag
819
- .split('&&')
820
- // Make sure spaces on either sides of '&&' are trimmed
821
- .map((subTag) => subTag.trim())
822
- // Remove empty strings after trimming is applied
823
- .filter(Boolean)
824
-
825
- if (!subTags.length) return null
826
-
827
- return sql`(${sql.join(
828
- subTags.map(
829
- (subTag) =>
830
- sql`${ref('moderation_subject_status.tags')} ? ${subTag}`,
831
- ),
832
- sql` AND `,
833
- )})`
834
- } else {
835
- // Single tag condition
836
- return sql`${ref('moderation_subject_status.tags')} ? ${tag}`
837
- }
838
- })
839
- .filter(Boolean)
840
-
841
- if (!conditions.length) return builder
842
-
843
- // Combine all conditions with OR
844
- return builder.where(sql`(${sql.join(conditions, sql` OR `)})`)
845
- }
846
-
847
812
  async getSubjectStatuses({
848
813
  queueCount,
849
814
  queueIndex,
@@ -858,54 +823,31 @@ export class ModerationService {
858
823
  reviewedBefore,
859
824
  reportedAfter,
860
825
  reportedBefore,
861
- includeMuted,
826
+ includeMuted = false,
862
827
  hostingDeletedBefore,
863
828
  hostingDeletedAfter,
864
829
  hostingUpdatedBefore,
865
830
  hostingUpdatedAfter,
866
831
  hostingStatuses,
867
- onlyMuted,
832
+ onlyMuted = false,
868
833
  ignoreSubjects,
869
- sortDirection,
834
+ sortDirection = 'desc',
870
835
  lastReviewedBy,
871
- sortField,
836
+ sortField = 'lastReportedAt',
872
837
  subject,
873
838
  tags,
874
839
  excludeTags,
875
840
  collections,
876
841
  subjectType,
877
- }: {
878
- queueCount?: number
879
- queueIndex?: number
880
- queueSeed?: string
881
- includeAllUserRecords?: boolean
842
+ minAccountSuspendCount,
843
+ minReportedRecordsCount,
844
+ minTakendownRecordsCount,
845
+ }: QueryStatusParams): Promise<{
846
+ statuses: ModerationSubjectStatusRowWithHandle[]
882
847
  cursor?: string
883
- limit?: number
884
- takendown?: boolean
885
- appealed?: boolean
886
- reviewedBefore?: string
887
- reviewState?: ModerationSubjectStatusRow['reviewState']
888
- reviewedAfter?: string
889
- reportedAfter?: string
890
- reportedBefore?: string
891
- includeMuted?: boolean
892
- hostingDeletedBefore?: string
893
- hostingDeletedAfter?: string
894
- hostingUpdatedBefore?: string
895
- hostingUpdatedAfter?: string
896
- hostingStatuses?: string[]
897
- onlyMuted?: boolean
898
- subject?: string
899
- ignoreSubjects?: string[]
900
- sortDirection: 'asc' | 'desc'
901
- lastReviewedBy?: string
902
- sortField: 'lastReviewedAt' | 'lastReportedAt'
903
- tags: string[]
904
- excludeTags: string[]
905
- collections: string[]
906
- subjectType?: string
907
- }) {
908
- let builder = this.db.db.selectFrom('moderation_subject_status').selectAll()
848
+ }> {
849
+ let builder = moderationSubjectStatusQueryBuilder(this.db.db)
850
+
909
851
  const { ref } = this.db.db.dynamic
910
852
 
911
853
  if (subject) {
@@ -919,14 +861,18 @@ export class ModerationService {
919
861
  if (!includeAllUserRecords) {
920
862
  builder = builder.where((qb) =>
921
863
  subjectInfo.recordPath
922
- ? qb.where('recordPath', '=', subjectInfo.recordPath)
923
- : qb.where('recordPath', '=', ''),
864
+ ? qb.where(
865
+ 'moderation_subject_status.recordPath',
866
+ '=',
867
+ subjectInfo.recordPath,
868
+ )
869
+ : qb.where('moderation_subject_status.recordPath', '=', ''),
924
870
  )
925
871
  }
926
872
  } else if (subjectType === 'account') {
927
- builder = builder.where('recordPath', '=', '')
873
+ builder = builder.where('moderation_subject_status.recordPath', '=', '')
928
874
  } else if (subjectType === 'record') {
929
- builder = builder.where('recordPath', '!=', '')
875
+ builder = builder.where('moderation_subject_status.recordPath', '!=', '')
930
876
  }
931
877
 
932
878
  // Only fetch items that belongs to the specified queue when specified
@@ -948,110 +894,216 @@ export class ModerationService {
948
894
  }
949
895
 
950
896
  // If subjectType is set to 'account' let that take priority and ignore collections filter
951
- if (collections.length && subjectType !== 'account') {
952
- builder = builder.where('recordPath', '!=', '').where((qb) => {
953
- collections.forEach((collection) => {
954
- qb = qb.orWhere('recordPath', 'like', `${collection}/%`)
897
+ if (subjectType !== 'account' && collections?.length) {
898
+ builder = builder
899
+ .where('moderation_subject_status.recordPath', '!=', '')
900
+ .where((qb) => {
901
+ for (const collection of collections) {
902
+ qb = qb.orWhere(
903
+ 'moderation_subject_status.recordPath',
904
+ 'like',
905
+ `${collection}/%`,
906
+ )
907
+ }
908
+ return qb
955
909
  })
956
- return qb
957
- })
958
910
  }
959
911
 
960
912
  if (ignoreSubjects?.length) {
961
913
  builder = builder
962
- .where('did', 'not in', ignoreSubjects)
963
- .where('recordPath', 'not in', ignoreSubjects)
914
+ .where('moderation_subject_status.did', 'not in', ignoreSubjects)
915
+ .where('moderation_subject_status.recordPath', 'not in', ignoreSubjects)
964
916
  }
965
917
 
966
- if (reviewState) {
967
- builder = builder.where('reviewState', '=', reviewState)
918
+ const reviewStateNormalized = getReviewState(reviewState)
919
+ if (reviewStateNormalized) {
920
+ builder = builder.where(
921
+ 'moderation_subject_status.reviewState',
922
+ '=',
923
+ reviewStateNormalized,
924
+ )
968
925
  }
969
926
 
970
927
  if (lastReviewedBy) {
971
- builder = builder.where('lastReviewedBy', '=', lastReviewedBy)
928
+ builder = builder.where(
929
+ 'moderation_subject_status.lastReviewedBy',
930
+ '=',
931
+ lastReviewedBy,
932
+ )
972
933
  }
973
934
 
974
935
  if (reviewedAfter) {
975
- builder = builder.where('lastReviewedAt', '>', reviewedAfter)
936
+ builder = builder.where(
937
+ 'moderation_subject_status.lastReviewedAt',
938
+ '>',
939
+ reviewedAfter,
940
+ )
976
941
  }
977
942
 
978
943
  if (reviewedBefore) {
979
- builder = builder.where('lastReviewedAt', '<', reviewedBefore)
944
+ builder = builder.where(
945
+ 'moderation_subject_status.lastReviewedAt',
946
+ '<',
947
+ reviewedBefore,
948
+ )
980
949
  }
981
950
 
982
951
  if (hostingUpdatedAfter) {
983
- builder = builder.where('hostingUpdatedAt', '>', hostingUpdatedAfter)
952
+ builder = builder.where(
953
+ 'moderation_subject_status.hostingUpdatedAt',
954
+ '>',
955
+ hostingUpdatedAfter,
956
+ )
984
957
  }
985
958
 
986
959
  if (hostingUpdatedBefore) {
987
- builder = builder.where('hostingUpdatedAt', '<', hostingUpdatedBefore)
960
+ builder = builder.where(
961
+ 'moderation_subject_status.hostingUpdatedAt',
962
+ '<',
963
+ hostingUpdatedBefore,
964
+ )
988
965
  }
989
966
 
990
967
  if (hostingDeletedAfter) {
991
- builder = builder.where('hostingDeletedAt', '>', hostingDeletedAfter)
968
+ builder = builder.where(
969
+ 'moderation_subject_status.hostingDeletedAt',
970
+ '>',
971
+ hostingDeletedAfter,
972
+ )
992
973
  }
993
974
 
994
975
  if (hostingDeletedBefore) {
995
- builder = builder.where('hostingDeletedAt', '<', hostingDeletedBefore)
976
+ builder = builder.where(
977
+ 'moderation_subject_status.hostingDeletedAt',
978
+ '<',
979
+ hostingDeletedBefore,
980
+ )
996
981
  }
997
982
 
998
983
  if (hostingStatuses?.length) {
999
- builder = builder.where('hostingStatus', 'in', hostingStatuses)
984
+ builder = builder.where(
985
+ 'moderation_subject_status.hostingStatus',
986
+ 'in',
987
+ hostingStatuses,
988
+ )
1000
989
  }
1001
990
 
1002
991
  if (reportedAfter) {
1003
- builder = builder.where('lastReviewedAt', '>', reportedAfter)
992
+ builder = builder.where(
993
+ 'moderation_subject_status.lastReviewedAt',
994
+ '>',
995
+ reportedAfter,
996
+ )
1004
997
  }
1005
998
 
1006
999
  if (reportedBefore) {
1007
- builder = builder.where('lastReportedAt', '<', reportedBefore)
1000
+ builder = builder.where(
1001
+ 'moderation_subject_status.lastReportedAt',
1002
+ '<',
1003
+ reportedBefore,
1004
+ )
1008
1005
  }
1009
1006
 
1010
1007
  if (takendown) {
1011
- builder = builder.where('takendown', '=', true)
1008
+ builder = builder.where('moderation_subject_status.takendown', '=', true)
1012
1009
  }
1013
1010
 
1014
1011
  if (appealed !== undefined) {
1015
1012
  builder =
1016
1013
  appealed === false
1017
- ? builder.where('appealed', 'is', null)
1018
- : builder.where('appealed', '=', appealed)
1014
+ ? builder.where('moderation_subject_status.appealed', 'is', null)
1015
+ : builder.where('moderation_subject_status.appealed', '=', appealed)
1019
1016
  }
1020
1017
 
1021
1018
  if (!includeMuted) {
1022
1019
  builder = builder.where((qb) =>
1023
1020
  qb
1024
- .where('muteUntil', '<', new Date().toISOString())
1025
- .orWhere('muteUntil', 'is', null),
1021
+ .where(
1022
+ 'moderation_subject_status.muteUntil',
1023
+ '<',
1024
+ new Date().toISOString(),
1025
+ )
1026
+ .orWhere('moderation_subject_status.muteUntil', 'is', null),
1026
1027
  )
1027
1028
  }
1028
1029
 
1029
1030
  if (onlyMuted) {
1030
1031
  builder = builder.where((qb) =>
1031
1032
  qb
1032
- .where('muteUntil', '>', new Date().toISOString())
1033
- .orWhere('muteReportingUntil', '>', new Date().toISOString()),
1033
+ .where(
1034
+ 'moderation_subject_status.muteUntil',
1035
+ '>',
1036
+ new Date().toISOString(),
1037
+ )
1038
+ .orWhere(
1039
+ 'moderation_subject_status.muteReportingUntil',
1040
+ '>',
1041
+ new Date().toISOString(),
1042
+ ),
1034
1043
  )
1035
1044
  }
1036
1045
 
1037
- if (tags.length) {
1038
- builder = this.applyTagFilter(builder, tags)
1046
+ // ["tag1", "tag2 && tag3", "tag4"] => [["tag1"], ["tag2", "tag3"], ["tag4"]]
1047
+ const conditions = parseTags(tags)
1048
+ if (conditions?.length) {
1049
+ // [["tag1"], ["tag2", "tag3"], ["tag4"]] => (tags ? 'tag1') OR (tags ? 'tag2' AND tags ? 'tag3') OR (tags ? 'tag4')
1050
+ builder = builder.where((qb) => {
1051
+ for (const subTags of conditions) {
1052
+ // OR between every conditions items (subTags)
1053
+ qb = qb.orWhere((qb) => {
1054
+ // AND between every subTags items (subTag)
1055
+ for (const subTag of subTags) {
1056
+ qb = qb.where(
1057
+ sql`${ref('moderation_subject_status.tags')} ? ${subTag}`,
1058
+ )
1059
+ }
1060
+ return qb
1061
+ })
1062
+ }
1063
+ return qb
1064
+ })
1039
1065
  }
1040
1066
 
1041
- if (excludeTags.length) {
1067
+ if (excludeTags?.length) {
1042
1068
  builder = builder.where((qb) =>
1043
1069
  qb
1044
1070
  .where(
1045
- sql`NOT(${ref(
1046
- 'moderation_subject_status.tags',
1047
- )} ?| array[${sql.join(excludeTags)}]::TEXT[])`,
1071
+ sql`NOT(${ref('moderation_subject_status.tags')} ?| array[${sql.join(excludeTags)}]::TEXT[])`,
1048
1072
  )
1049
1073
  .orWhere('tags', 'is', null),
1050
1074
  )
1051
1075
  }
1052
1076
 
1077
+ if (minAccountSuspendCount != null && minAccountSuspendCount > 0) {
1078
+ builder = builder.where(
1079
+ 'account_events_stats.suspendCount',
1080
+ '>=',
1081
+ minAccountSuspendCount,
1082
+ )
1083
+ }
1084
+
1085
+ if (minTakendownRecordsCount != null && minTakendownRecordsCount > 0) {
1086
+ builder = builder.where(
1087
+ 'account_record_status_stats.takendownCount',
1088
+ '>=',
1089
+ minTakendownRecordsCount,
1090
+ )
1091
+ }
1092
+
1093
+ if (minReportedRecordsCount != null && minReportedRecordsCount > 0) {
1094
+ builder = builder.where(
1095
+ 'account_record_events_stats.reportedCount',
1096
+ '>=',
1097
+ minReportedRecordsCount,
1098
+ )
1099
+ }
1100
+
1053
1101
  const keyset = new StatusKeyset(
1054
- ref(`moderation_subject_status.${sortField}`),
1102
+ sortField === 'reportedRecordsCount'
1103
+ ? ref(`account_record_events_stats.reportedCount`)
1104
+ : sortField === 'takendownRecordsCount'
1105
+ ? ref(`account_record_status_stats.takendownCount`)
1106
+ : ref(`moderation_subject_status.${sortField}`),
1055
1107
  ref('moderation_subject_status.id'),
1056
1108
  )
1057
1109
  const paginatedBuilder = paginate(builder, {
@@ -1067,13 +1119,12 @@ export class ModerationService {
1067
1119
  const infos = await this.views.getAccoutInfosByDid(
1068
1120
  results.map((r) => r.did),
1069
1121
  )
1070
- const resultsWithHandles = results.map((r) => ({
1071
- ...r,
1072
- handle: infos.get(r.did)?.handle ?? INVALID_HANDLE,
1073
- }))
1074
1122
 
1075
1123
  return {
1076
- statuses: resultsWithHandles,
1124
+ statuses: results.map((r) => ({
1125
+ ...r,
1126
+ handle: infos.get(r.did)?.handle ?? INVALID_HANDLE,
1127
+ })),
1077
1128
  cursor: keyset.packFromResult(results),
1078
1129
  }
1079
1130
  }
@@ -1195,6 +1246,18 @@ export class ModerationService {
1195
1246
  }
1196
1247
  }
1197
1248
 
1249
+ const parseTags = (tags?: string[]) =>
1250
+ tags
1251
+ ?.map((tag) =>
1252
+ tag
1253
+ .split(/\s*&&\s*/g)
1254
+ .map((subTag) => subTag.trim())
1255
+ // Ignore invalid syntax ("", "tag1 &&", "&& tag2", "tag1 && && tag2", etc.)
1256
+ .filter(Boolean),
1257
+ )
1258
+ // Ignore invalid items
1259
+ .filter((subTags): subTags is [string, ...string[]] => subTags.length > 0)
1260
+
1198
1261
  const isSafeUrl = (url: URL) => {
1199
1262
  if (url.protocol !== 'https:') return false
1200
1263
  if (!url.hostname || url.hostname === 'localhost') return false