@meith/moderation 0.16.0
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.
- package/LICENSE.md +165 -0
- package/package.json +25 -0
- package/src/index.ts +101 -0
- package/src/inline.ts +269 -0
- package/src/modcp.ts +174 -0
- package/src/queue.ts +149 -0
- package/src/reports.ts +260 -0
- package/src/surgery.ts +175 -0
- package/src/thread-tools.ts +295 -0
- package/src/warnings.ts +386 -0
package/src/modcp.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { ValidationError } from '@meith/core'
|
|
2
|
+
import { msg } from '@meith/i18n'
|
|
3
|
+
|
|
4
|
+
export const MODCP_PAGE_SIZE = 25
|
|
5
|
+
|
|
6
|
+
export interface ModLogEntry {
|
|
7
|
+
readonly id: number
|
|
8
|
+
readonly action: string
|
|
9
|
+
readonly actorUserId: number | null
|
|
10
|
+
readonly actorUsername: string | null
|
|
11
|
+
readonly forumId: number | null
|
|
12
|
+
readonly forumTitle: string | null
|
|
13
|
+
readonly detail: readonly { readonly label: string; readonly value: string }[]
|
|
14
|
+
readonly at: Date
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ModLogPage {
|
|
18
|
+
readonly entries: readonly ModLogEntry[]
|
|
19
|
+
readonly nextCursor?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ModeratedForum {
|
|
23
|
+
readonly forumId: number
|
|
24
|
+
readonly title: string
|
|
25
|
+
readonly slug: string
|
|
26
|
+
readonly pending: number
|
|
27
|
+
readonly openReports: number
|
|
28
|
+
readonly rights: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface IpMatch {
|
|
32
|
+
readonly userId: number
|
|
33
|
+
readonly username: string
|
|
34
|
+
readonly matchedOn: 'registration' | 'last_visit' | 'both'
|
|
35
|
+
readonly lastActiveAt: Date | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ModCpRepository {
|
|
39
|
+
log(input: {
|
|
40
|
+
readonly forumIds: readonly number[]
|
|
41
|
+
readonly actorUserId: number
|
|
42
|
+
readonly limit: number
|
|
43
|
+
readonly after?: string | undefined
|
|
44
|
+
}): Promise<ModLogPage>
|
|
45
|
+
|
|
46
|
+
workload(
|
|
47
|
+
forumIds: readonly number[],
|
|
48
|
+
): Promise<ReadonlyMap<number, { pending: number; openReports: number }>>
|
|
49
|
+
|
|
50
|
+
ipMatches(userId: number, limit: number): Promise<readonly IpMatch[]>
|
|
51
|
+
|
|
52
|
+
ipPrefixesFor(userId: number): Promise<{ registration: string | null; lastVisit: string | null }>
|
|
53
|
+
|
|
54
|
+
recordIpLookup(input: {
|
|
55
|
+
readonly actorUserId: number
|
|
56
|
+
readonly subjectUserId: number
|
|
57
|
+
readonly matches: number
|
|
58
|
+
readonly at: Date
|
|
59
|
+
}): Promise<void>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ModeratorPanelRights {
|
|
63
|
+
readonly access: boolean
|
|
64
|
+
readonly ipLookup: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class ModeratorPanel {
|
|
68
|
+
private readonly repo: ModCpRepository
|
|
69
|
+
private readonly now: () => Date
|
|
70
|
+
|
|
71
|
+
constructor(deps: { modcp: ModCpRepository; now?: () => Date }) {
|
|
72
|
+
this.repo = deps.modcp
|
|
73
|
+
this.now = deps.now ?? (() => new Date())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async log(input: {
|
|
77
|
+
readonly forumIds: readonly number[]
|
|
78
|
+
readonly actorUserId: number
|
|
79
|
+
readonly after?: string | undefined
|
|
80
|
+
}): Promise<ModLogPage> {
|
|
81
|
+
return this.repo.log({
|
|
82
|
+
forumIds: input.forumIds,
|
|
83
|
+
actorUserId: input.actorUserId,
|
|
84
|
+
limit: MODCP_PAGE_SIZE,
|
|
85
|
+
...(input.after === undefined ? {} : { after: input.after }),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async dashboard(input: {
|
|
90
|
+
readonly forums: readonly {
|
|
91
|
+
forumId: number
|
|
92
|
+
title: string
|
|
93
|
+
slug: string
|
|
94
|
+
rights: readonly string[]
|
|
95
|
+
}[]
|
|
96
|
+
}): Promise<readonly ModeratedForum[]> {
|
|
97
|
+
if (input.forums.length === 0) return []
|
|
98
|
+
|
|
99
|
+
const workload = await this.repo.workload(input.forums.map((f) => f.forumId))
|
|
100
|
+
return input.forums
|
|
101
|
+
.map((forum) => ({
|
|
102
|
+
...forum,
|
|
103
|
+
pending: workload.get(forum.forumId)?.pending ?? 0,
|
|
104
|
+
openReports: workload.get(forum.forumId)?.openReports ?? 0,
|
|
105
|
+
}))
|
|
106
|
+
.sort(
|
|
107
|
+
(a, b) =>
|
|
108
|
+
b.pending + b.openReports - (a.pending + a.openReports) || a.title.localeCompare(b.title),
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async lookUpIp(input: {
|
|
113
|
+
readonly subjectUserId: number
|
|
114
|
+
readonly actorUserId: number
|
|
115
|
+
readonly rights: ModeratorPanelRights
|
|
116
|
+
readonly limit?: number
|
|
117
|
+
}): Promise<{
|
|
118
|
+
readonly prefixes: { registration: string | null; lastVisit: string | null }
|
|
119
|
+
readonly matches: readonly IpMatch[]
|
|
120
|
+
}> {
|
|
121
|
+
if (!input.rights.access || !input.rights.ipLookup) {
|
|
122
|
+
throw new ValidationError(msg('error.moderation.look-up-addresses'))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const prefixes = await this.repo.ipPrefixesFor(input.subjectUserId)
|
|
126
|
+
const matches = await this.repo.ipMatches(input.subjectUserId, input.limit ?? MODCP_PAGE_SIZE)
|
|
127
|
+
|
|
128
|
+
await this.repo.recordIpLookup({
|
|
129
|
+
actorUserId: input.actorUserId,
|
|
130
|
+
subjectUserId: input.subjectUserId,
|
|
131
|
+
matches: matches.length,
|
|
132
|
+
at: this.now(),
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return { prefixes, matches }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const MOD_LOG_LABEL_KEYS: Readonly<Record<string, string>> = {
|
|
140
|
+
'moderation.approve': 'board.modlog.moderation.approve',
|
|
141
|
+
'moderation.reject': 'board.modlog.moderation.reject',
|
|
142
|
+
'thread.lock': 'board.modlog.thread.lock',
|
|
143
|
+
'thread.unlock': 'board.modlog.thread.unlock',
|
|
144
|
+
'thread.stick': 'board.modlog.thread.stick',
|
|
145
|
+
'thread.unstick': 'board.modlog.thread.unstick',
|
|
146
|
+
'thread.move': 'board.modlog.thread.move',
|
|
147
|
+
'thread.delete': 'board.modlog.thread.delete',
|
|
148
|
+
'thread.restore': 'board.modlog.thread.restore',
|
|
149
|
+
'thread.split': 'board.modlog.thread.split',
|
|
150
|
+
'thread.merge': 'board.modlog.thread.merge',
|
|
151
|
+
'thread.copy': 'board.modlog.thread.copy',
|
|
152
|
+
'inline.approve': 'board.modlog.inline.approve',
|
|
153
|
+
'inline.delete': 'board.modlog.inline.delete',
|
|
154
|
+
'inline.restore': 'board.modlog.inline.restore',
|
|
155
|
+
'inline.lock': 'board.modlog.inline.lock',
|
|
156
|
+
'inline.unlock': 'board.modlog.inline.unlock',
|
|
157
|
+
'inline.stick': 'board.modlog.inline.stick',
|
|
158
|
+
'inline.unstick': 'board.modlog.inline.unstick',
|
|
159
|
+
'inline.move': 'board.modlog.inline.move',
|
|
160
|
+
'post.edit': 'board.modlog.post.edit',
|
|
161
|
+
'post.delete': 'board.modlog.post.delete',
|
|
162
|
+
'post.restore': 'board.modlog.post.restore',
|
|
163
|
+
'report.resolve': 'board.modlog.report.resolve',
|
|
164
|
+
'report.reject': 'board.modlog.report.reject',
|
|
165
|
+
'warning.issue': 'board.modlog.warning.issue',
|
|
166
|
+
'warning.revoke': 'board.modlog.warning.revoke',
|
|
167
|
+
'signature.lock': 'board.modlog.signature.lock',
|
|
168
|
+
'signature.unlock': 'board.modlog.signature.unlock',
|
|
169
|
+
'avatar.lock': 'board.modlog.avatar.lock',
|
|
170
|
+
'avatar.unlock': 'board.modlog.avatar.unlock',
|
|
171
|
+
'modcp.ip_lookup': 'board.modlog.ipLookup',
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const MOD_LOG_ACTIONS: readonly string[] = Object.keys(MOD_LOG_LABEL_KEYS)
|
package/src/queue.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { ValidationError } from '@meith/core'
|
|
2
|
+
import { msg } from '@meith/i18n'
|
|
3
|
+
|
|
4
|
+
export type QueueItemKind = 'thread' | 'post'
|
|
5
|
+
|
|
6
|
+
export interface QueueSelection {
|
|
7
|
+
readonly kind: QueueItemKind
|
|
8
|
+
readonly id: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface QueueItem extends QueueSelection {
|
|
12
|
+
readonly forumId: number
|
|
13
|
+
readonly forumTitle: string
|
|
14
|
+
readonly threadId: number
|
|
15
|
+
readonly threadSlug: string
|
|
16
|
+
readonly threadTitle: string
|
|
17
|
+
readonly authorUserId: number | null
|
|
18
|
+
readonly authorUsername: string
|
|
19
|
+
readonly excerpt: string
|
|
20
|
+
readonly createdAt: Date
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface QueuePage {
|
|
24
|
+
readonly items: readonly QueueItem[]
|
|
25
|
+
readonly nextCursor?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PendingItem extends QueueSelection {
|
|
29
|
+
readonly forumId: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type QueueDecision = 'approve' | 'reject'
|
|
33
|
+
|
|
34
|
+
export interface QueueOutcome {
|
|
35
|
+
readonly decision: QueueDecision
|
|
36
|
+
readonly applied: number
|
|
37
|
+
readonly refused: number
|
|
38
|
+
readonly missing: number
|
|
39
|
+
/** The items the decision was carried out on, for whoever has to announce it. */
|
|
40
|
+
readonly decided: readonly { readonly kind: QueueItemKind; readonly id: number }[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ModerationQueueRepository {
|
|
44
|
+
list(
|
|
45
|
+
forumIds: readonly number[],
|
|
46
|
+
options: {
|
|
47
|
+
readonly limit: number
|
|
48
|
+
readonly after?: string
|
|
49
|
+
readonly offset?: number
|
|
50
|
+
},
|
|
51
|
+
): Promise<QueuePage>
|
|
52
|
+
|
|
53
|
+
countPending(forumIds: readonly number[]): Promise<number>
|
|
54
|
+
|
|
55
|
+
resolve(selection: readonly QueueSelection[]): Promise<readonly PendingItem[]>
|
|
56
|
+
|
|
57
|
+
apply(input: {
|
|
58
|
+
readonly decision: QueueDecision
|
|
59
|
+
readonly threadIds: readonly number[]
|
|
60
|
+
readonly postIds: readonly number[]
|
|
61
|
+
readonly actorUserId: number
|
|
62
|
+
readonly at: Date
|
|
63
|
+
}): Promise<number>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const MAX_CHUNK = 200
|
|
67
|
+
|
|
68
|
+
export const QUEUE_PAGE_SIZE = 25
|
|
69
|
+
|
|
70
|
+
export class ModerationQueue {
|
|
71
|
+
private readonly queue: ModerationQueueRepository
|
|
72
|
+
private readonly now: () => Date
|
|
73
|
+
|
|
74
|
+
constructor(deps: { queue: ModerationQueueRepository; now?: () => Date }) {
|
|
75
|
+
this.queue = deps.queue
|
|
76
|
+
this.now = deps.now ?? (() => new Date())
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async list(
|
|
80
|
+
moderatedForumIds: readonly number[],
|
|
81
|
+
options: { readonly after?: string; readonly offset?: number } = {},
|
|
82
|
+
): Promise<QueuePage> {
|
|
83
|
+
if (moderatedForumIds.length === 0) return { items: [] }
|
|
84
|
+
return this.queue.list(moderatedForumIds, {
|
|
85
|
+
limit: QUEUE_PAGE_SIZE,
|
|
86
|
+
...(options.after === undefined ? {} : { after: options.after }),
|
|
87
|
+
...(options.offset === undefined ? {} : { offset: options.offset }),
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async countPending(moderatedForumIds: readonly number[]): Promise<number> {
|
|
92
|
+
if (moderatedForumIds.length === 0) return 0
|
|
93
|
+
return this.queue.countPending(moderatedForumIds)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async decide(input: {
|
|
97
|
+
readonly selection: readonly QueueSelection[]
|
|
98
|
+
readonly decision: QueueDecision
|
|
99
|
+
readonly moderatedForumIds: ReadonlySet<number>
|
|
100
|
+
readonly actorUserId: number
|
|
101
|
+
}): Promise<QueueOutcome> {
|
|
102
|
+
if (input.selection.length === 0) {
|
|
103
|
+
throw new ValidationError(msg('error.moderation.select-at-least-one-item'))
|
|
104
|
+
}
|
|
105
|
+
if (input.selection.length > MAX_CHUNK) {
|
|
106
|
+
throw new ValidationError(msg('error.moderation.queue-chunk', { max: MAX_CHUNK }))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const unique = new Map<string, QueueSelection>()
|
|
110
|
+
for (const item of input.selection) unique.set(`${item.kind}:${item.id}`, item)
|
|
111
|
+
|
|
112
|
+
const pending = await this.queue.resolve([...unique.values()])
|
|
113
|
+
const missing = unique.size - pending.length
|
|
114
|
+
|
|
115
|
+
const allowed = pending.filter((item) => input.moderatedForumIds.has(item.forumId))
|
|
116
|
+
const refused = pending.length - allowed.length
|
|
117
|
+
|
|
118
|
+
const applied =
|
|
119
|
+
allowed.length === 0
|
|
120
|
+
? 0
|
|
121
|
+
: await this.queue.apply({
|
|
122
|
+
decision: input.decision,
|
|
123
|
+
threadIds: allowed.filter((i) => i.kind === 'thread').map((i) => i.id),
|
|
124
|
+
postIds: allowed.filter((i) => i.kind === 'post').map((i) => i.id),
|
|
125
|
+
actorUserId: input.actorUserId,
|
|
126
|
+
at: this.now(),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
decision: input.decision,
|
|
131
|
+
applied,
|
|
132
|
+
refused,
|
|
133
|
+
missing,
|
|
134
|
+
decided: applied === 0 ? [] : allowed.map((item) => ({ kind: item.kind, id: item.id })),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function parseSelection(values: readonly string[]): QueueSelection[] {
|
|
140
|
+
const out: QueueSelection[] = []
|
|
141
|
+
for (const value of values) {
|
|
142
|
+
const match = /^(thread|post):([1-9]\d*)$/.exec(value)
|
|
143
|
+
if (!match) continue
|
|
144
|
+
const id = Number(match[2])
|
|
145
|
+
if (!Number.isSafeInteger(id)) continue
|
|
146
|
+
out.push({ kind: match[1] as QueueItemKind, id })
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
}
|
package/src/reports.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { ValidationError } from '@meith/core'
|
|
2
|
+
import { msg } from '@meith/i18n'
|
|
3
|
+
|
|
4
|
+
export const REPORT_TARGET_KINDS = ['post', 'thread', 'user', 'private_message'] as const
|
|
5
|
+
export type ReportTargetKind = (typeof REPORT_TARGET_KINDS)[number]
|
|
6
|
+
|
|
7
|
+
export type ReportStatus = 'open' | 'resolved' | 'rejected'
|
|
8
|
+
|
|
9
|
+
export const REASON_MIN = 3
|
|
10
|
+
export const REASON_MAX = 1000
|
|
11
|
+
|
|
12
|
+
export interface ReportTarget {
|
|
13
|
+
readonly kind: ReportTargetKind
|
|
14
|
+
readonly id: number
|
|
15
|
+
readonly forumId: number | null
|
|
16
|
+
readonly threadId: number | null
|
|
17
|
+
readonly threadAuthorUserId: number | null
|
|
18
|
+
readonly label: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface NewReport {
|
|
22
|
+
readonly target: ReportTarget
|
|
23
|
+
readonly reporterUserId: number
|
|
24
|
+
readonly reason: string
|
|
25
|
+
readonly at: Date
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ReportRow {
|
|
29
|
+
readonly id: number
|
|
30
|
+
readonly kind: ReportTargetKind
|
|
31
|
+
readonly targetId: number
|
|
32
|
+
readonly forumId: number | null
|
|
33
|
+
readonly threadId: number | null
|
|
34
|
+
readonly targetLabel: string
|
|
35
|
+
readonly reporterUserId: number | null
|
|
36
|
+
readonly reporterUsername: string | null
|
|
37
|
+
readonly reason: string
|
|
38
|
+
readonly status: ReportStatus
|
|
39
|
+
readonly assignedToUserId: number | null
|
|
40
|
+
readonly assignedToUsername: string | null
|
|
41
|
+
readonly createdAt: Date
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ReportEvent {
|
|
45
|
+
readonly id: number
|
|
46
|
+
readonly kind: 'opened' | 'assigned' | 'unassigned' | 'resolved' | 'rejected' | 'note'
|
|
47
|
+
readonly actorUserId: number | null
|
|
48
|
+
readonly actorUsername: string | null
|
|
49
|
+
readonly note: string | null
|
|
50
|
+
readonly createdAt: Date
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ReportPage {
|
|
54
|
+
readonly rows: readonly ReportRow[]
|
|
55
|
+
readonly nextCursor?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ReportScope {
|
|
59
|
+
readonly forumIds: readonly number[]
|
|
60
|
+
readonly global: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ReportRepository {
|
|
64
|
+
resolveTarget(
|
|
65
|
+
kind: ReportTargetKind,
|
|
66
|
+
id: number,
|
|
67
|
+
reporterUserId: number,
|
|
68
|
+
): Promise<ReportTarget | null>
|
|
69
|
+
|
|
70
|
+
open(report: NewReport): Promise<number | null>
|
|
71
|
+
|
|
72
|
+
listOpen(
|
|
73
|
+
scope: ReportScope,
|
|
74
|
+
options: {
|
|
75
|
+
readonly limit: number
|
|
76
|
+
readonly after?: string
|
|
77
|
+
readonly offset?: number
|
|
78
|
+
},
|
|
79
|
+
): Promise<ReportPage>
|
|
80
|
+
|
|
81
|
+
countOpen(scope: ReportScope): Promise<number>
|
|
82
|
+
|
|
83
|
+
find(id: number): Promise<{ report: ReportRow; events: readonly ReportEvent[] } | null>
|
|
84
|
+
|
|
85
|
+
assign(input: {
|
|
86
|
+
readonly reportId: number
|
|
87
|
+
readonly toUserId: number | null
|
|
88
|
+
readonly actorUserId: number
|
|
89
|
+
readonly at: Date
|
|
90
|
+
}): Promise<boolean>
|
|
91
|
+
|
|
92
|
+
close(input: {
|
|
93
|
+
readonly reportId: number
|
|
94
|
+
readonly status: 'resolved' | 'rejected'
|
|
95
|
+
readonly note: string | null
|
|
96
|
+
readonly actorUserId: number
|
|
97
|
+
readonly at: Date
|
|
98
|
+
}): Promise<boolean>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const REPORTS_PAGE_SIZE = 25
|
|
102
|
+
|
|
103
|
+
export interface ReportNotifierPort {
|
|
104
|
+
reportClosed(input: {
|
|
105
|
+
readonly reporterUserId: number
|
|
106
|
+
readonly reportId: number
|
|
107
|
+
readonly outcome: 'resolved' | 'rejected'
|
|
108
|
+
readonly targetLabel: string
|
|
109
|
+
}): Promise<void>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class ReportService {
|
|
113
|
+
private readonly reports: ReportRepository
|
|
114
|
+
private readonly notifier: ReportNotifierPort | null
|
|
115
|
+
private readonly now: () => Date
|
|
116
|
+
|
|
117
|
+
constructor(deps: {
|
|
118
|
+
reports: ReportRepository
|
|
119
|
+
notifier?: ReportNotifierPort | null
|
|
120
|
+
now?: () => Date
|
|
121
|
+
}) {
|
|
122
|
+
this.reports = deps.reports
|
|
123
|
+
this.notifier = deps.notifier ?? null
|
|
124
|
+
this.now = deps.now ?? (() => new Date())
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async file(input: {
|
|
128
|
+
readonly kind: ReportTargetKind
|
|
129
|
+
readonly targetId: number
|
|
130
|
+
readonly reason: string
|
|
131
|
+
readonly reporterUserId: number
|
|
132
|
+
}): Promise<{ reportId: number; duplicate: boolean }> {
|
|
133
|
+
const reason = input.reason.trim()
|
|
134
|
+
if (reason.length < REASON_MIN) {
|
|
135
|
+
throw new ValidationError(msg('error.moderation.say-what-wrong-with-briefly'))
|
|
136
|
+
}
|
|
137
|
+
if (reason.length > REASON_MAX) {
|
|
138
|
+
throw new ValidationError(msg('error.moderation.reason-length', { max: REASON_MAX }))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const target = await this.reports.resolveTarget(
|
|
142
|
+
input.kind,
|
|
143
|
+
input.targetId,
|
|
144
|
+
input.reporterUserId,
|
|
145
|
+
)
|
|
146
|
+
if (target === null) throw new ValidationError(msg('error.moderation.exist'))
|
|
147
|
+
|
|
148
|
+
const reportId = await this.reports.open({
|
|
149
|
+
target,
|
|
150
|
+
reporterUserId: input.reporterUserId,
|
|
151
|
+
reason,
|
|
152
|
+
at: this.now(),
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return reportId === null ? { reportId: 0, duplicate: true } : { reportId, duplicate: false }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async listOpen(
|
|
159
|
+
scope: ReportScope,
|
|
160
|
+
options: { readonly after?: string; readonly offset?: number } = {},
|
|
161
|
+
): Promise<ReportPage> {
|
|
162
|
+
if (scope.forumIds.length === 0 && !scope.global) return { rows: [] }
|
|
163
|
+
return this.reports.listOpen(scope, {
|
|
164
|
+
limit: REPORTS_PAGE_SIZE,
|
|
165
|
+
...(options.after === undefined ? {} : { after: options.after }),
|
|
166
|
+
...(options.offset === undefined ? {} : { offset: options.offset }),
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async countOpen(scope: ReportScope): Promise<number> {
|
|
171
|
+
if (scope.forumIds.length === 0 && !scope.global) return 0
|
|
172
|
+
return this.reports.countOpen(scope)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async open(
|
|
176
|
+
reportId: number,
|
|
177
|
+
scope: ReportScope,
|
|
178
|
+
): Promise<{ report: ReportRow; events: readonly ReportEvent[] } | null> {
|
|
179
|
+
const found = await this.reports.find(reportId)
|
|
180
|
+
if (found === null) return null
|
|
181
|
+
return inScope(found.report, scope) ? found : null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async assign(input: {
|
|
185
|
+
readonly reportId: number
|
|
186
|
+
readonly toUserId: number | null
|
|
187
|
+
readonly actorUserId: number
|
|
188
|
+
readonly scope: ReportScope
|
|
189
|
+
}): Promise<void> {
|
|
190
|
+
await this.requireInScope(input.reportId, input.scope)
|
|
191
|
+
const changed = await this.reports.assign({
|
|
192
|
+
reportId: input.reportId,
|
|
193
|
+
toUserId: input.toUserId,
|
|
194
|
+
actorUserId: input.actorUserId,
|
|
195
|
+
at: this.now(),
|
|
196
|
+
})
|
|
197
|
+
if (!changed) throw new ValidationError(msg('error.moderation.report-already-closed'))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async close(input: {
|
|
201
|
+
readonly reportId: number
|
|
202
|
+
readonly status: 'resolved' | 'rejected'
|
|
203
|
+
readonly note: string
|
|
204
|
+
readonly actorUserId: number
|
|
205
|
+
readonly scope: ReportScope
|
|
206
|
+
}): Promise<void> {
|
|
207
|
+
const note = input.note.trim()
|
|
208
|
+
if (note.length > REASON_MAX) {
|
|
209
|
+
throw new ValidationError(msg('error.moderation.note-length', { max: REASON_MAX }))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const report = await this.requireInScope(input.reportId, input.scope)
|
|
213
|
+
const changed = await this.reports.close({
|
|
214
|
+
reportId: input.reportId,
|
|
215
|
+
status: input.status,
|
|
216
|
+
note: note.length === 0 ? null : note,
|
|
217
|
+
actorUserId: input.actorUserId,
|
|
218
|
+
at: this.now(),
|
|
219
|
+
})
|
|
220
|
+
if (!changed) throw new ValidationError(msg('error.moderation.report-already-closed'))
|
|
221
|
+
|
|
222
|
+
await this.notifyReporter(report, input.status, input.actorUserId)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async notifyReporter(
|
|
226
|
+
report: ReportRow,
|
|
227
|
+
outcome: 'resolved' | 'rejected',
|
|
228
|
+
actorUserId: number,
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
if (this.notifier === null) return
|
|
231
|
+
if (report.reporterUserId === null || report.reporterUserId === actorUserId) return
|
|
232
|
+
|
|
233
|
+
await this.notifier
|
|
234
|
+
.reportClosed({
|
|
235
|
+
reporterUserId: report.reporterUserId,
|
|
236
|
+
reportId: report.id,
|
|
237
|
+
outcome,
|
|
238
|
+
targetLabel: report.targetLabel,
|
|
239
|
+
})
|
|
240
|
+
.catch(() => undefined)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async requireInScope(reportId: number, scope: ReportScope): Promise<ReportRow> {
|
|
244
|
+
const found = await this.reports.find(reportId)
|
|
245
|
+
if (found === null || !inScope(found.report, scope)) {
|
|
246
|
+
throw new ValidationError(msg('error.moderation.report-exist'))
|
|
247
|
+
}
|
|
248
|
+
return found.report
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function inScope(report: ReportRow, scope: ReportScope): boolean {
|
|
253
|
+
return report.forumId === null ? scope.global : scope.forumIds.includes(report.forumId)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function parseTargetKind(value: string | undefined): ReportTargetKind | null {
|
|
257
|
+
return REPORT_TARGET_KINDS.includes(value as ReportTargetKind)
|
|
258
|
+
? (value as ReportTargetKind)
|
|
259
|
+
: null
|
|
260
|
+
}
|