@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/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
+ }