Init commit
This commit is contained in:
666
server/core/models/abuse/abuse.ts
Normal file
666
server/core/models/abuse/abuse.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
import { abusePredefinedReasonsMap, forceNumber } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
AbuseFilter,
|
||||
AbuseObject,
|
||||
AbusePredefinedReasonsString,
|
||||
AbusePredefinedReasonsType,
|
||||
AbuseState,
|
||||
AbuseVideoIs,
|
||||
AdminAbuse,
|
||||
AdminVideoAbuse,
|
||||
AdminVideoCommentAbuse,
|
||||
UserAbuse,
|
||||
UserVideoAbuse,
|
||||
type AbuseStateType
|
||||
} from '@peertube/peertube-models'
|
||||
import { uuidToShort } from '@peertube/peertube-node-utils'
|
||||
import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses.js'
|
||||
import invert from 'lodash-es/invert.js'
|
||||
import { Op, QueryTypes, literal } from 'sequelize'
|
||||
import {
|
||||
AllowNull,
|
||||
BelongsTo,
|
||||
Column,
|
||||
CreatedAt,
|
||||
DataType,
|
||||
Default,
|
||||
ForeignKey,
|
||||
HasOne,
|
||||
Is,
|
||||
Scopes,
|
||||
Table,
|
||||
UpdatedAt
|
||||
} from 'sequelize-typescript'
|
||||
import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants.js'
|
||||
import {
|
||||
MAbuseAP,
|
||||
MAbuseAdminFormattable,
|
||||
MAbuseFull,
|
||||
MAbuseReporter,
|
||||
MAbuseUserFormattable,
|
||||
MUserAccountId
|
||||
} from '../../types/models/index.js'
|
||||
import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account.js'
|
||||
import { SequelizeModel, getSort, parseAggregateResult, throwIfNotValid } from '../shared/index.js'
|
||||
import { ThumbnailModel } from '../video/thumbnail.js'
|
||||
import { VideoBlacklistModel } from '../video/video-blacklist.js'
|
||||
import { SummaryOptions as ChannelSummaryOptions, VideoChannelModel, ScopeNames as VideoChannelScopeNames } from '../video/video-channel.js'
|
||||
import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment.js'
|
||||
import { VideoModel, ScopeNames as VideoScopeNames } from '../video/video.js'
|
||||
import { BuildAbusesQueryOptions, buildAbuseListQuery } from './sql/abuse-query-builder.js'
|
||||
import { VideoAbuseModel } from './video-abuse.js'
|
||||
import { VideoCommentAbuseModel } from './video-comment-abuse.js'
|
||||
|
||||
export enum ScopeNames {
|
||||
FOR_API = 'FOR_API'
|
||||
}
|
||||
|
||||
@Scopes(() => ({
|
||||
[ScopeNames.FOR_API]: () => {
|
||||
return {
|
||||
attributes: {
|
||||
include: [
|
||||
[
|
||||
literal(
|
||||
'(' +
|
||||
'SELECT count(*) ' +
|
||||
'FROM "abuseMessage" ' +
|
||||
'WHERE "abuseId" = "AbuseModel"."id"' +
|
||||
')'
|
||||
),
|
||||
'countMessages'
|
||||
],
|
||||
[
|
||||
// we don't care about this count for deleted videos, so there are not included
|
||||
literal(
|
||||
'(' +
|
||||
'SELECT count(*) ' +
|
||||
'FROM "videoAbuse" ' +
|
||||
'WHERE "videoId" IN (SELECT "videoId" FROM "videoAbuse" WHERE "abuseId" = "AbuseModel"."id") ' +
|
||||
'AND "videoId" IS NOT NULL' +
|
||||
')'
|
||||
),
|
||||
'countReportsForVideo'
|
||||
],
|
||||
[
|
||||
// we don't care about this count for deleted videos, so there are not included
|
||||
literal(
|
||||
'(' +
|
||||
'SELECT t.nth ' +
|
||||
'FROM ( ' +
|
||||
'SELECT id, "abuseId", row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
|
||||
'FROM "videoAbuse" ' +
|
||||
') t ' +
|
||||
'WHERE t."abuseId" = "AbuseModel"."id" ' +
|
||||
')'
|
||||
),
|
||||
'nthReportForVideo'
|
||||
],
|
||||
[
|
||||
literal(
|
||||
'(' +
|
||||
'SELECT count("abuse"."id") ' +
|
||||
'FROM "abuse" ' +
|
||||
'WHERE "abuse"."reporterAccountId" = "AbuseModel"."reporterAccountId"' +
|
||||
')'
|
||||
),
|
||||
'countReportsForReporter'
|
||||
],
|
||||
[
|
||||
literal(
|
||||
'(' +
|
||||
'SELECT count("abuse"."id") ' +
|
||||
'FROM "abuse" ' +
|
||||
'WHERE "abuse"."flaggedAccountId" = "AbuseModel"."flaggedAccountId"' +
|
||||
')'
|
||||
),
|
||||
'countReportsForReportee'
|
||||
]
|
||||
]
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: AccountModel.scope({
|
||||
method: [
|
||||
AccountScopeNames.SUMMARY,
|
||||
{ actorRequired: false } as AccountSummaryOptions
|
||||
]
|
||||
}),
|
||||
as: 'ReporterAccount'
|
||||
},
|
||||
{
|
||||
model: AccountModel.scope({
|
||||
method: [
|
||||
AccountScopeNames.SUMMARY,
|
||||
{ actorRequired: false } as AccountSummaryOptions
|
||||
]
|
||||
}),
|
||||
as: 'FlaggedAccount'
|
||||
},
|
||||
{
|
||||
model: VideoCommentAbuseModel.unscoped(),
|
||||
include: [
|
||||
{
|
||||
model: VideoCommentModel.unscoped(),
|
||||
include: [
|
||||
{
|
||||
model: VideoModel.unscoped(),
|
||||
attributes: [ 'name', 'id', 'uuid' ]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: VideoAbuseModel.unscoped(),
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'id', 'uuid', 'name', 'nsfw' ],
|
||||
model: VideoModel.unscoped(),
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'filename', 'fileUrl', 'type' ],
|
||||
model: ThumbnailModel
|
||||
},
|
||||
{
|
||||
model: VideoChannelModel.scope({
|
||||
method: [
|
||||
VideoChannelScopeNames.SUMMARY,
|
||||
{ withAccount: false, actorRequired: false } as ChannelSummaryOptions
|
||||
]
|
||||
}),
|
||||
required: false
|
||||
},
|
||||
{
|
||||
attributes: [ 'id', 'reason', 'unfederated' ],
|
||||
required: false,
|
||||
model: VideoBlacklistModel
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}))
|
||||
@Table({
|
||||
tableName: 'abuse',
|
||||
indexes: [
|
||||
{
|
||||
fields: [ 'reporterAccountId' ]
|
||||
},
|
||||
{
|
||||
fields: [ 'flaggedAccountId' ]
|
||||
}
|
||||
]
|
||||
})
|
||||
export class AbuseModel extends SequelizeModel<AbuseModel> {
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is('AbuseReason', value => throwIfNotValid(value, isAbuseReasonValid, 'reason'))
|
||||
@Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.REASON.max))
|
||||
declare reason: string
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(null)
|
||||
@Is('AbuseState', value => throwIfNotValid(value, isAbuseStateValid, 'state'))
|
||||
@Column
|
||||
declare state: AbuseStateType
|
||||
|
||||
@AllowNull(true)
|
||||
@Default(null)
|
||||
@Is('AbuseModerationComment', value => throwIfNotValid(value, isAbuseModerationCommentValid, 'moderationComment', true))
|
||||
@Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.MODERATION_COMMENT.max))
|
||||
declare moderationComment: string
|
||||
|
||||
@AllowNull(true)
|
||||
@Default(null)
|
||||
@Column(DataType.ARRAY(DataType.INTEGER))
|
||||
declare predefinedReasons: AbusePredefinedReasonsType[]
|
||||
|
||||
@AllowNull(true)
|
||||
@Column
|
||||
declare processedAt: Date
|
||||
|
||||
@CreatedAt
|
||||
declare createdAt: Date
|
||||
|
||||
@UpdatedAt
|
||||
declare updatedAt: Date
|
||||
|
||||
@ForeignKey(() => AccountModel)
|
||||
@Column
|
||||
declare reporterAccountId: number
|
||||
|
||||
@BelongsTo(() => AccountModel, {
|
||||
foreignKey: {
|
||||
name: 'reporterAccountId',
|
||||
allowNull: true
|
||||
},
|
||||
as: 'ReporterAccount',
|
||||
onDelete: 'set null'
|
||||
})
|
||||
declare ReporterAccount: Awaited<AccountModel>
|
||||
|
||||
@ForeignKey(() => AccountModel)
|
||||
@Column
|
||||
declare flaggedAccountId: number
|
||||
|
||||
@BelongsTo(() => AccountModel, {
|
||||
foreignKey: {
|
||||
name: 'flaggedAccountId',
|
||||
allowNull: true
|
||||
},
|
||||
as: 'FlaggedAccount',
|
||||
onDelete: 'set null'
|
||||
})
|
||||
declare FlaggedAccount: Awaited<AccountModel>
|
||||
|
||||
@HasOne(() => VideoCommentAbuseModel, {
|
||||
foreignKey: {
|
||||
name: 'abuseId',
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare VideoCommentAbuse: Awaited<VideoCommentAbuseModel>
|
||||
|
||||
@HasOne(() => VideoAbuseModel, {
|
||||
foreignKey: {
|
||||
name: 'abuseId',
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
declare VideoAbuse: Awaited<VideoAbuseModel>
|
||||
|
||||
static loadByIdWithReporter (id: number): Promise<MAbuseReporter> {
|
||||
const query = {
|
||||
where: {
|
||||
id
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: AccountModel,
|
||||
as: 'ReporterAccount'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return AbuseModel.findOne(query)
|
||||
}
|
||||
|
||||
static loadFull (id: number): Promise<MAbuseFull> {
|
||||
const query = {
|
||||
where: {
|
||||
id
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: AccountModel.scope(AccountScopeNames.SUMMARY),
|
||||
required: false,
|
||||
as: 'ReporterAccount'
|
||||
},
|
||||
{
|
||||
model: AccountModel.scope(AccountScopeNames.SUMMARY),
|
||||
as: 'FlaggedAccount'
|
||||
},
|
||||
{
|
||||
model: VideoAbuseModel,
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: VideoModel.scope([ VideoScopeNames.WITH_ACCOUNT_DETAILS ])
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: VideoCommentAbuseModel,
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: VideoCommentModel.scope([
|
||||
CommentScopeNames.WITH_ACCOUNT
|
||||
]),
|
||||
include: [
|
||||
{
|
||||
model: VideoModel
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return AbuseModel.findOne(query)
|
||||
}
|
||||
|
||||
static async listForAdminApi (parameters: {
|
||||
start: number
|
||||
count: number
|
||||
sort: string
|
||||
|
||||
filter?: AbuseFilter
|
||||
|
||||
serverAccountId: number
|
||||
user?: MUserAccountId
|
||||
|
||||
id?: number
|
||||
predefinedReason?: AbusePredefinedReasonsString
|
||||
state?: AbuseStateType
|
||||
videoIs?: AbuseVideoIs
|
||||
|
||||
search?: string
|
||||
searchReporter?: string
|
||||
searchReportee?: string
|
||||
searchVideo?: string
|
||||
searchVideoChannel?: string
|
||||
}) {
|
||||
const {
|
||||
start,
|
||||
count,
|
||||
sort,
|
||||
search,
|
||||
user,
|
||||
serverAccountId,
|
||||
state,
|
||||
videoIs,
|
||||
predefinedReason,
|
||||
searchReportee,
|
||||
searchVideo,
|
||||
filter,
|
||||
searchVideoChannel,
|
||||
searchReporter,
|
||||
id
|
||||
} = parameters
|
||||
|
||||
const userAccountId = user ? user.Account.id : undefined
|
||||
const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined
|
||||
|
||||
const queryOptions: BuildAbusesQueryOptions = {
|
||||
start,
|
||||
count,
|
||||
sort,
|
||||
id,
|
||||
filter,
|
||||
predefinedReasonId,
|
||||
search,
|
||||
state,
|
||||
videoIs,
|
||||
searchReportee,
|
||||
searchVideo,
|
||||
searchVideoChannel,
|
||||
searchReporter,
|
||||
serverAccountId,
|
||||
userAccountId
|
||||
}
|
||||
|
||||
const [ total, data ] = await Promise.all([
|
||||
AbuseModel.internalCountForApi(queryOptions),
|
||||
AbuseModel.internalListForApi(queryOptions)
|
||||
])
|
||||
|
||||
return { total, data }
|
||||
}
|
||||
|
||||
static async listForUserApi (parameters: {
|
||||
user: MUserAccountId
|
||||
|
||||
start: number
|
||||
count: number
|
||||
sort: string
|
||||
|
||||
id?: number
|
||||
search?: string
|
||||
state?: AbuseStateType
|
||||
}) {
|
||||
const {
|
||||
start,
|
||||
count,
|
||||
sort,
|
||||
search,
|
||||
user,
|
||||
state,
|
||||
id
|
||||
} = parameters
|
||||
|
||||
const queryOptions: BuildAbusesQueryOptions = {
|
||||
start,
|
||||
count,
|
||||
sort,
|
||||
id,
|
||||
search,
|
||||
state,
|
||||
reporterAccountId: user.Account.id
|
||||
}
|
||||
|
||||
const [ total, data ] = await Promise.all([
|
||||
AbuseModel.internalCountForApi(queryOptions),
|
||||
AbuseModel.internalListForApi(queryOptions)
|
||||
])
|
||||
|
||||
return { total, data }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static getStats () {
|
||||
const query = `SELECT ` +
|
||||
`AVG(EXTRACT(EPOCH FROM ("processedAt" - "createdAt") * 1000)) ` +
|
||||
`FILTER (WHERE "processedAt" IS NOT NULL AND "createdAt" > CURRENT_DATE - INTERVAL '3 months')` +
|
||||
`AS "avgResponseTime", ` +
|
||||
// "processedAt" has been introduced in PeerTube 6.1 so also check the abuse state to check processed abuses
|
||||
`COUNT(*) FILTER (WHERE "processedAt" IS NOT NULL OR "state" != ${AbuseState.PENDING}) AS "processedAbuses", ` +
|
||||
`COUNT(*) AS "totalAbuses" ` +
|
||||
`FROM "abuse"`
|
||||
|
||||
return AbuseModel.sequelize.query<any>(query, {
|
||||
type: QueryTypes.SELECT,
|
||||
raw: true
|
||||
}).then(([ row ]) => {
|
||||
return {
|
||||
totalAbuses: parseAggregateResult(row.totalAbuses),
|
||||
|
||||
totalAbusesProcessed: parseAggregateResult(row.processedAbuses),
|
||||
|
||||
averageAbuseResponseTimeMs: row?.avgResponseTime
|
||||
? forceNumber(row.avgResponseTime)
|
||||
: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
buildBaseVideoCommentAbuse (this: MAbuseUserFormattable) {
|
||||
// Associated video comment could have been destroyed if the video has been deleted
|
||||
if (!this.VideoCommentAbuse?.VideoComment) return null
|
||||
|
||||
const entity = this.VideoCommentAbuse.VideoComment
|
||||
|
||||
return {
|
||||
id: entity.id,
|
||||
threadId: entity.getThreadId(),
|
||||
|
||||
text: entity.text ?? '',
|
||||
|
||||
deleted: entity.isDeleted(),
|
||||
|
||||
video: {
|
||||
id: entity.Video.id,
|
||||
name: entity.Video.name,
|
||||
uuid: entity.Video.uuid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildBaseVideoAbuse (this: MAbuseUserFormattable): UserVideoAbuse {
|
||||
if (!this.VideoAbuse) return null
|
||||
|
||||
const abuseModel = this.VideoAbuse
|
||||
const entity = abuseModel.Video || abuseModel.deletedVideo
|
||||
|
||||
return {
|
||||
id: entity.id,
|
||||
uuid: entity.uuid,
|
||||
shortUUID: uuidToShort(entity.uuid),
|
||||
name: entity.name,
|
||||
nsfw: entity.nsfw,
|
||||
|
||||
startAt: abuseModel.startAt,
|
||||
endAt: abuseModel.endAt,
|
||||
|
||||
deleted: !abuseModel.Video,
|
||||
blacklisted: abuseModel.Video?.isBlacklisted() || false,
|
||||
thumbnailPath: abuseModel.Video?.getMiniatureStaticPath(),
|
||||
|
||||
channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel
|
||||
}
|
||||
}
|
||||
|
||||
buildBaseAbuse (this: MAbuseUserFormattable, countMessages: number): UserAbuse {
|
||||
const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
reason: this.reason,
|
||||
predefinedReasons,
|
||||
|
||||
flaggedAccount: this.FlaggedAccount
|
||||
? this.FlaggedAccount.toFormattedJSON()
|
||||
: null,
|
||||
|
||||
state: {
|
||||
id: this.state,
|
||||
label: AbuseModel.getStateLabel(this.state)
|
||||
},
|
||||
|
||||
countMessages,
|
||||
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
toFormattedAdminJSON (this: MAbuseAdminFormattable): AdminAbuse {
|
||||
const countReportsForVideo = this.get('countReportsForVideo') as number
|
||||
const nthReportForVideo = this.get('nthReportForVideo') as number
|
||||
|
||||
const countReportsForReporter = this.get('countReportsForReporter') as number
|
||||
const countReportsForReportee = this.get('countReportsForReportee') as number
|
||||
|
||||
const countMessages = this.get('countMessages') as number
|
||||
|
||||
const baseVideo = this.buildBaseVideoAbuse()
|
||||
const video: AdminVideoAbuse = baseVideo
|
||||
? Object.assign(baseVideo, {
|
||||
countReports: countReportsForVideo,
|
||||
nthReport: nthReportForVideo
|
||||
})
|
||||
: null
|
||||
|
||||
const comment: AdminVideoCommentAbuse = this.buildBaseVideoCommentAbuse()
|
||||
|
||||
const abuse = this.buildBaseAbuse(countMessages || 0)
|
||||
|
||||
return Object.assign(abuse, {
|
||||
video,
|
||||
comment,
|
||||
|
||||
moderationComment: this.moderationComment,
|
||||
|
||||
reporterAccount: this.ReporterAccount
|
||||
? this.ReporterAccount.toFormattedJSON()
|
||||
: null,
|
||||
|
||||
countReportsForReporter: (countReportsForReporter || 0),
|
||||
countReportsForReportee: (countReportsForReportee || 0)
|
||||
})
|
||||
}
|
||||
|
||||
toFormattedUserJSON (this: MAbuseUserFormattable): UserAbuse {
|
||||
const countMessages = this.get('countMessages') as number
|
||||
|
||||
const video = this.buildBaseVideoAbuse()
|
||||
const comment = this.buildBaseVideoCommentAbuse()
|
||||
const abuse = this.buildBaseAbuse(countMessages || 0)
|
||||
|
||||
return Object.assign(abuse, {
|
||||
video,
|
||||
comment
|
||||
})
|
||||
}
|
||||
|
||||
toActivityPubObject (this: MAbuseAP): AbuseObject {
|
||||
const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
|
||||
|
||||
const object = this.VideoAbuse?.Video?.url || this.VideoCommentAbuse?.VideoComment?.url || this.FlaggedAccount.Actor.url
|
||||
|
||||
const startAt = this.VideoAbuse?.startAt
|
||||
const endAt = this.VideoAbuse?.endAt
|
||||
|
||||
return {
|
||||
type: 'Flag' as 'Flag',
|
||||
content: this.reason,
|
||||
mediaType: 'text/markdown',
|
||||
object,
|
||||
tag: predefinedReasons.map(r => ({
|
||||
type: 'Hashtag' as 'Hashtag',
|
||||
name: r
|
||||
})),
|
||||
startAt,
|
||||
endAt
|
||||
}
|
||||
}
|
||||
|
||||
private static async internalCountForApi (parameters: BuildAbusesQueryOptions) {
|
||||
const { query, replacements } = buildAbuseListQuery(parameters, 'count')
|
||||
const options = {
|
||||
type: QueryTypes.SELECT as QueryTypes.SELECT,
|
||||
replacements
|
||||
}
|
||||
|
||||
const [ { total } ] = await AbuseModel.sequelize.query<{ total: string }>(query, options)
|
||||
if (total === null) return 0
|
||||
|
||||
return parseInt(total, 10)
|
||||
}
|
||||
|
||||
private static async internalListForApi (parameters: BuildAbusesQueryOptions) {
|
||||
const { query, replacements } = buildAbuseListQuery(parameters, 'id')
|
||||
const options = {
|
||||
type: QueryTypes.SELECT as QueryTypes.SELECT,
|
||||
replacements
|
||||
}
|
||||
|
||||
const rows = await AbuseModel.sequelize.query<{ id: string }>(query, options)
|
||||
const ids = rows.map(r => r.id)
|
||||
|
||||
if (ids.length === 0) return []
|
||||
|
||||
return AbuseModel.scope(ScopeNames.FOR_API)
|
||||
.findAll({
|
||||
order: getSort(parameters.sort),
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: ids
|
||||
}
|
||||
},
|
||||
limit: parameters.count
|
||||
})
|
||||
}
|
||||
|
||||
private static getStateLabel (id: number) {
|
||||
return ABUSE_STATES[id] || 'Unknown'
|
||||
}
|
||||
|
||||
private static getPredefinedReasonsStrings (predefinedReasons: AbusePredefinedReasonsType[]): AbusePredefinedReasonsString[] {
|
||||
const invertedPredefinedReasons = invert(abusePredefinedReasonsMap)
|
||||
|
||||
return (predefinedReasons || [])
|
||||
.map(r => invertedPredefinedReasons[r] as AbusePredefinedReasonsString)
|
||||
.filter(v => !!v)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user