@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/surgery.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { ValidationError } from '@meith/core'
|
|
2
|
+
import { msg } from '@meith/i18n'
|
|
3
|
+
|
|
4
|
+
export interface SurgeryThread {
|
|
5
|
+
readonly id: number
|
|
6
|
+
readonly forumId: number
|
|
7
|
+
readonly slug: string
|
|
8
|
+
readonly title: string
|
|
9
|
+
readonly visibility: 'visible' | 'unapproved' | 'deleted'
|
|
10
|
+
readonly firstPostId: number | null
|
|
11
|
+
readonly visiblePosts: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SplitPlan {
|
|
15
|
+
readonly sourceThreadId: number
|
|
16
|
+
readonly title: string
|
|
17
|
+
readonly postIds: readonly number[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MergePlan {
|
|
21
|
+
readonly sourceThreadId: number
|
|
22
|
+
readonly targetThreadId: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SurgeryOutcome {
|
|
26
|
+
readonly threadId: number
|
|
27
|
+
readonly slug: string
|
|
28
|
+
readonly posts: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ThreadSurgeryRepository {
|
|
32
|
+
find(threadId: number): Promise<SurgeryThread | null>
|
|
33
|
+
|
|
34
|
+
postsFrom(threadId: number, fromPostId: number): Promise<readonly number[]>
|
|
35
|
+
|
|
36
|
+
visiblePostIdsIn(threadId: number, postIds: readonly number[]): Promise<readonly number[]>
|
|
37
|
+
|
|
38
|
+
split(plan: SplitPlan & { actorUserId: number; at: Date }): Promise<SurgeryOutcome>
|
|
39
|
+
|
|
40
|
+
merge(plan: MergePlan & { actorUserId: number; at: Date }): Promise<SurgeryOutcome>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SurgeryRights {
|
|
44
|
+
readonly merge: boolean
|
|
45
|
+
readonly split: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class ThreadSurgery {
|
|
49
|
+
private readonly threads: ThreadSurgeryRepository
|
|
50
|
+
private readonly now: () => Date
|
|
51
|
+
|
|
52
|
+
constructor(deps: { threads: ThreadSurgeryRepository; now?: () => Date }) {
|
|
53
|
+
this.threads = deps.threads
|
|
54
|
+
this.now = deps.now ?? (() => new Date())
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async split(input: {
|
|
58
|
+
readonly threadId: number
|
|
59
|
+
readonly fromPostId: number
|
|
60
|
+
readonly title: string
|
|
61
|
+
readonly actorUserId: number
|
|
62
|
+
readonly rights: SurgeryRights
|
|
63
|
+
}): Promise<SurgeryOutcome> {
|
|
64
|
+
if (!input.rights.split) throw new ValidationError(msg('error.moderation.split-threads-here'))
|
|
65
|
+
|
|
66
|
+
const source = await this.requireLive(input.threadId)
|
|
67
|
+
|
|
68
|
+
const title = this.requireTitle(input.title)
|
|
69
|
+
|
|
70
|
+
if (source.firstPostId !== null && input.fromPostId === source.firstPostId) {
|
|
71
|
+
throw new ValidationError(msg('error.moderation.whole-thread-move-instead-splitting'))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const postIds = await this.threads.postsFrom(input.threadId, input.fromPostId)
|
|
75
|
+
if (postIds.length === 0) {
|
|
76
|
+
throw new ValidationError(msg('error.moderation.post-thread'))
|
|
77
|
+
}
|
|
78
|
+
if (postIds.length >= source.visiblePosts) {
|
|
79
|
+
throw new ValidationError(msg('error.moderation.whole-thread-move-instead-splitting'))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return this.threads.split({
|
|
83
|
+
sourceThreadId: source.id,
|
|
84
|
+
title,
|
|
85
|
+
postIds,
|
|
86
|
+
actorUserId: input.actorUserId,
|
|
87
|
+
at: this.now(),
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async splitPosts(input: {
|
|
92
|
+
readonly threadId: number
|
|
93
|
+
readonly postIds: readonly number[]
|
|
94
|
+
readonly title: string
|
|
95
|
+
readonly actorUserId: number
|
|
96
|
+
readonly rights: SurgeryRights
|
|
97
|
+
}): Promise<SurgeryOutcome & { readonly dropped: number }> {
|
|
98
|
+
if (!input.rights.split) throw new ValidationError(msg('error.moderation.split-threads-here'))
|
|
99
|
+
|
|
100
|
+
const source = await this.requireLive(input.threadId)
|
|
101
|
+
const title = this.requireTitle(input.title)
|
|
102
|
+
|
|
103
|
+
if (input.postIds.length === 0) {
|
|
104
|
+
throw new ValidationError(msg('error.moderation.select-posts-split-out'))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const eligible = await this.threads.visiblePostIdsIn(input.threadId, input.postIds)
|
|
108
|
+
const dropped = new Set(input.postIds).size - eligible.length
|
|
109
|
+
|
|
110
|
+
if (eligible.length === 0) {
|
|
111
|
+
throw new ValidationError(msg('error.moderation.none-those-posts-thread'))
|
|
112
|
+
}
|
|
113
|
+
if (source.firstPostId !== null && eligible.includes(source.firstPostId)) {
|
|
114
|
+
throw new ValidationError(msg('error.moderation.selection-includes-opening-post-move'))
|
|
115
|
+
}
|
|
116
|
+
if (eligible.length >= source.visiblePosts) {
|
|
117
|
+
throw new ValidationError(msg('error.moderation.whole-thread-move-instead-splitting'))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const outcome = await this.threads.split({
|
|
121
|
+
sourceThreadId: source.id,
|
|
122
|
+
title,
|
|
123
|
+
postIds: eligible,
|
|
124
|
+
actorUserId: input.actorUserId,
|
|
125
|
+
at: this.now(),
|
|
126
|
+
})
|
|
127
|
+
return { ...outcome, dropped }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async merge(input: {
|
|
131
|
+
readonly sourceThreadId: number
|
|
132
|
+
readonly targetThreadId: number
|
|
133
|
+
readonly actorUserId: number
|
|
134
|
+
readonly rights: SurgeryRights
|
|
135
|
+
readonly targetRights: SurgeryRights
|
|
136
|
+
}): Promise<SurgeryOutcome> {
|
|
137
|
+
if (!input.rights.merge) throw new ValidationError(msg('error.moderation.merge-threads-here'))
|
|
138
|
+
|
|
139
|
+
if (input.sourceThreadId === input.targetThreadId) {
|
|
140
|
+
throw new ValidationError(msg('error.moderation.thread-merged-into-itself'))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const source = await this.requireLive(input.sourceThreadId)
|
|
144
|
+
const target = await this.requireLive(input.targetThreadId)
|
|
145
|
+
|
|
146
|
+
if (!input.targetRights.merge) {
|
|
147
|
+
throw new ValidationError(msg('error.moderation.merge-threads-into-forum'))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return this.threads.merge({
|
|
151
|
+
sourceThreadId: source.id,
|
|
152
|
+
targetThreadId: target.id,
|
|
153
|
+
actorUserId: input.actorUserId,
|
|
154
|
+
at: this.now(),
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private requireTitle(raw: string): string {
|
|
159
|
+
const title = raw.trim()
|
|
160
|
+
if (title.length < 3) throw new ValidationError(msg('error.moderation.new-thread-needs-title'))
|
|
161
|
+
if (title.length > 150) {
|
|
162
|
+
throw new ValidationError(msg('error.moderation.title-at-most-150-characters'))
|
|
163
|
+
}
|
|
164
|
+
return title
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private async requireLive(threadId: number): Promise<SurgeryThread> {
|
|
168
|
+
const thread = await this.threads.find(threadId)
|
|
169
|
+
if (thread === null) throw new ValidationError(msg('error.moderation.thread-exist'))
|
|
170
|
+
if (thread.visibility !== 'visible') {
|
|
171
|
+
throw new ValidationError(msg('error.moderation.thread-board'))
|
|
172
|
+
}
|
|
173
|
+
return thread
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { ValidationError } from '@meith/core'
|
|
2
|
+
import { msg } from '@meith/i18n'
|
|
3
|
+
|
|
4
|
+
export type ThreadTool =
|
|
5
|
+
| 'lock'
|
|
6
|
+
| 'unlock'
|
|
7
|
+
| 'stick'
|
|
8
|
+
| 'unstick'
|
|
9
|
+
| 'move'
|
|
10
|
+
| 'copy'
|
|
11
|
+
| 'delete'
|
|
12
|
+
| 'restore'
|
|
13
|
+
|
|
14
|
+
export interface ThreadToolTarget {
|
|
15
|
+
readonly id: number
|
|
16
|
+
readonly forumId: number
|
|
17
|
+
readonly slug: string
|
|
18
|
+
readonly title: string
|
|
19
|
+
readonly isLocked: boolean
|
|
20
|
+
readonly isSticky: boolean
|
|
21
|
+
readonly visibility: 'visible' | 'unapproved' | 'deleted'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MoveDestination {
|
|
25
|
+
readonly id: number
|
|
26
|
+
readonly type: 'category' | 'forum' | 'link'
|
|
27
|
+
readonly allowThreads: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function keepsThreads(destination: MoveDestination): boolean {
|
|
31
|
+
return (
|
|
32
|
+
destination.type === 'forum' || (destination.type === 'category' && destination.allowThreads)
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ThreadToolRights {
|
|
37
|
+
readonly lock: boolean
|
|
38
|
+
readonly stick: boolean
|
|
39
|
+
readonly move: boolean
|
|
40
|
+
readonly delete: boolean
|
|
41
|
+
readonly restore: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ThreadToolOutcome {
|
|
45
|
+
readonly tool: ThreadTool
|
|
46
|
+
readonly threadId: number
|
|
47
|
+
readonly slug: string
|
|
48
|
+
readonly changed: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ThreadToolsRepository {
|
|
52
|
+
find(threadId: number): Promise<ThreadToolTarget | null>
|
|
53
|
+
findDestination(forumId: number): Promise<MoveDestination | null>
|
|
54
|
+
|
|
55
|
+
setLocked(input: {
|
|
56
|
+
readonly threadId: number
|
|
57
|
+
readonly locked: boolean
|
|
58
|
+
readonly actorUserId: number
|
|
59
|
+
readonly at: Date
|
|
60
|
+
}): Promise<boolean>
|
|
61
|
+
|
|
62
|
+
setSticky(input: {
|
|
63
|
+
readonly threadId: number
|
|
64
|
+
readonly sticky: boolean
|
|
65
|
+
readonly actorUserId: number
|
|
66
|
+
readonly at: Date
|
|
67
|
+
}): Promise<boolean>
|
|
68
|
+
|
|
69
|
+
setVisibility(input: {
|
|
70
|
+
readonly threadId: number
|
|
71
|
+
readonly from: 'visible' | 'deleted'
|
|
72
|
+
readonly to: 'visible' | 'deleted'
|
|
73
|
+
readonly actorUserId: number
|
|
74
|
+
readonly at: Date
|
|
75
|
+
}): Promise<boolean>
|
|
76
|
+
|
|
77
|
+
move(input: {
|
|
78
|
+
readonly threadId: number
|
|
79
|
+
readonly fromForumId: number
|
|
80
|
+
readonly toForumId: number
|
|
81
|
+
readonly actorUserId: number
|
|
82
|
+
readonly at: Date
|
|
83
|
+
}): Promise<boolean>
|
|
84
|
+
|
|
85
|
+
copy(input: {
|
|
86
|
+
readonly threadId: number
|
|
87
|
+
readonly toForumId: number
|
|
88
|
+
readonly actorUserId: number
|
|
89
|
+
readonly at: Date
|
|
90
|
+
}): Promise<{ threadId: number; slug: string; posts: number }>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class ThreadTools {
|
|
94
|
+
private readonly threads: ThreadToolsRepository
|
|
95
|
+
private readonly now: () => Date
|
|
96
|
+
|
|
97
|
+
constructor(deps: { threads: ThreadToolsRepository; now?: () => Date }) {
|
|
98
|
+
this.threads = deps.threads
|
|
99
|
+
this.now = deps.now ?? (() => new Date())
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async apply(input: {
|
|
103
|
+
readonly threadId: number
|
|
104
|
+
readonly tool: ThreadTool
|
|
105
|
+
readonly toForumId?: number | undefined
|
|
106
|
+
readonly actorUserId: number
|
|
107
|
+
readonly rights: ThreadToolRights
|
|
108
|
+
readonly destinationRights?: ThreadToolRights | undefined
|
|
109
|
+
}): Promise<ThreadToolOutcome> {
|
|
110
|
+
const target = await this.threads.find(input.threadId)
|
|
111
|
+
if (target === null) throw new ValidationError(msg('error.moderation.thread-exist'))
|
|
112
|
+
|
|
113
|
+
const at = this.now()
|
|
114
|
+
const base = { threadId: target.id, slug: target.slug }
|
|
115
|
+
|
|
116
|
+
switch (input.tool) {
|
|
117
|
+
case 'lock':
|
|
118
|
+
case 'unlock': {
|
|
119
|
+
this.require(input.rights.lock, 'open and close threads')
|
|
120
|
+
this.requireLive(target)
|
|
121
|
+
const locked = input.tool === 'lock'
|
|
122
|
+
return {
|
|
123
|
+
...base,
|
|
124
|
+
tool: input.tool,
|
|
125
|
+
changed: await this.threads.setLocked({
|
|
126
|
+
threadId: target.id,
|
|
127
|
+
locked,
|
|
128
|
+
actorUserId: input.actorUserId,
|
|
129
|
+
at,
|
|
130
|
+
}),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case 'stick':
|
|
135
|
+
case 'unstick': {
|
|
136
|
+
this.require(input.rights.stick, 'pin threads')
|
|
137
|
+
this.requireLive(target)
|
|
138
|
+
return {
|
|
139
|
+
...base,
|
|
140
|
+
tool: input.tool,
|
|
141
|
+
changed: await this.threads.setSticky({
|
|
142
|
+
threadId: target.id,
|
|
143
|
+
sticky: input.tool === 'stick',
|
|
144
|
+
actorUserId: input.actorUserId,
|
|
145
|
+
at,
|
|
146
|
+
}),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case 'delete': {
|
|
151
|
+
this.require(input.rights.delete, 'delete threads')
|
|
152
|
+
if (target.visibility !== 'visible') {
|
|
153
|
+
throw new ValidationError(msg('error.moderation.thread-board'))
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
...base,
|
|
157
|
+
tool: input.tool,
|
|
158
|
+
changed: await this.threads.setVisibility({
|
|
159
|
+
threadId: target.id,
|
|
160
|
+
from: 'visible',
|
|
161
|
+
to: 'deleted',
|
|
162
|
+
actorUserId: input.actorUserId,
|
|
163
|
+
at,
|
|
164
|
+
}),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case 'restore': {
|
|
169
|
+
this.require(input.rights.restore, 'restore threads')
|
|
170
|
+
if (target.visibility !== 'deleted') {
|
|
171
|
+
throw new ValidationError(msg('error.moderation.thread-deleted'))
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
...base,
|
|
175
|
+
tool: input.tool,
|
|
176
|
+
changed: await this.threads.setVisibility({
|
|
177
|
+
threadId: target.id,
|
|
178
|
+
from: 'deleted',
|
|
179
|
+
to: 'visible',
|
|
180
|
+
actorUserId: input.actorUserId,
|
|
181
|
+
at,
|
|
182
|
+
}),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case 'move':
|
|
187
|
+
return { ...base, tool: 'move', changed: await this.moveTo(input, target, at) }
|
|
188
|
+
|
|
189
|
+
case 'copy': {
|
|
190
|
+
const copied = await this.copyTo(input, target, at)
|
|
191
|
+
return {
|
|
192
|
+
tool: 'copy',
|
|
193
|
+
threadId: copied.threadId,
|
|
194
|
+
slug: copied.slug,
|
|
195
|
+
changed: true,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async moveTo(
|
|
202
|
+
input: {
|
|
203
|
+
readonly toForumId?: number | undefined
|
|
204
|
+
readonly actorUserId: number
|
|
205
|
+
readonly rights: ThreadToolRights
|
|
206
|
+
readonly destinationRights?: ThreadToolRights | undefined
|
|
207
|
+
},
|
|
208
|
+
target: ThreadToolTarget,
|
|
209
|
+
at: Date,
|
|
210
|
+
): Promise<boolean> {
|
|
211
|
+
this.require(input.rights.move, 'move threads')
|
|
212
|
+
this.requireLive(target)
|
|
213
|
+
|
|
214
|
+
if (input.toForumId === undefined) {
|
|
215
|
+
throw new ValidationError(msg('error.moderation.choose-forum-move'))
|
|
216
|
+
}
|
|
217
|
+
if (input.toForumId === target.forumId) {
|
|
218
|
+
throw new ValidationError(msg('error.moderation.thread-already-forum'))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (input.destinationRights?.move !== true) {
|
|
222
|
+
throw new ValidationError(msg('error.moderation.move-threads-into-forum'))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const destination = await this.threads.findDestination(input.toForumId)
|
|
226
|
+
if (destination === null || !keepsThreads(destination)) {
|
|
227
|
+
throw new ValidationError(msg('error.moderation.forum-threads-live'))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return this.threads.move({
|
|
231
|
+
threadId: target.id,
|
|
232
|
+
fromForumId: target.forumId,
|
|
233
|
+
toForumId: input.toForumId,
|
|
234
|
+
actorUserId: input.actorUserId,
|
|
235
|
+
at,
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private async copyTo(
|
|
240
|
+
input: {
|
|
241
|
+
readonly toForumId?: number | undefined
|
|
242
|
+
readonly actorUserId: number
|
|
243
|
+
readonly rights: ThreadToolRights
|
|
244
|
+
readonly destinationRights?: ThreadToolRights | undefined
|
|
245
|
+
},
|
|
246
|
+
target: ThreadToolTarget,
|
|
247
|
+
at: Date,
|
|
248
|
+
): Promise<{ threadId: number; slug: string }> {
|
|
249
|
+
this.require(input.rights.move, 'copy threads')
|
|
250
|
+
this.requireLive(target)
|
|
251
|
+
|
|
252
|
+
if (input.toForumId === undefined) {
|
|
253
|
+
throw new ValidationError(msg('error.moderation.choose-forum-copy'))
|
|
254
|
+
}
|
|
255
|
+
if (input.destinationRights?.move !== true) {
|
|
256
|
+
throw new ValidationError(msg('error.moderation.copy-threads-into-forum'))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const destination = await this.threads.findDestination(input.toForumId)
|
|
260
|
+
if (destination === null || !keepsThreads(destination)) {
|
|
261
|
+
throw new ValidationError(msg('error.moderation.forum-threads-live'))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return this.threads.copy({
|
|
265
|
+
threadId: target.id,
|
|
266
|
+
toForumId: input.toForumId,
|
|
267
|
+
actorUserId: input.actorUserId,
|
|
268
|
+
at,
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private require(held: boolean, what: string): void {
|
|
273
|
+
if (!held) throw new ValidationError(`You cannot ${what} here.`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private requireLive(target: ThreadToolTarget): void {
|
|
277
|
+
if (target.visibility !== 'visible') {
|
|
278
|
+
throw new ValidationError(msg('error.moderation.thread-board'))
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function parseThreadTool(value: string | undefined): ThreadTool | null {
|
|
284
|
+
const tools: readonly ThreadTool[] = [
|
|
285
|
+
'lock',
|
|
286
|
+
'unlock',
|
|
287
|
+
'stick',
|
|
288
|
+
'unstick',
|
|
289
|
+
'move',
|
|
290
|
+
'copy',
|
|
291
|
+
'delete',
|
|
292
|
+
'restore',
|
|
293
|
+
]
|
|
294
|
+
return tools.includes(value as ThreadTool) ? (value as ThreadTool) : null
|
|
295
|
+
}
|