@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.
@@ -0,0 +1,386 @@
1
+ import { ValidationError } from '@meith/core'
2
+ import { msg } from '@meith/i18n'
3
+
4
+ export type WarningAction = 'suspend_posting' | 'moderate_posting' | 'ban'
5
+
6
+ export const WARNING_ACTIONS: readonly WarningAction[] = [
7
+ 'suspend_posting',
8
+ 'moderate_posting',
9
+ 'ban',
10
+ ]
11
+
12
+ export const REASON_MAX = 2000
13
+ export const TITLE_MAX = 150
14
+ export const POINTS_MAX = 100
15
+ export const WARNINGS_PAGE_SIZE = 25
16
+
17
+ export interface WarningType {
18
+ readonly id: number
19
+ readonly title: string
20
+ readonly points: number
21
+ readonly expiryDays: number | null
22
+ }
23
+
24
+ export interface WarningLevel {
25
+ readonly points: number
26
+ readonly action: WarningAction
27
+ readonly durationDays: number | null
28
+ }
29
+
30
+ export interface WarningRow {
31
+ readonly id: number
32
+ readonly userId: number
33
+ readonly title: string
34
+ readonly points: number
35
+ readonly reason: string
36
+ readonly issuedByUserId: number | null
37
+ readonly issuedByUsername: string | null
38
+ readonly postId: number | null
39
+ readonly createdAt: Date
40
+ readonly expiresAt: Date | null
41
+ readonly revokedAt: Date | null
42
+ readonly revokedByUsername: string | null
43
+ readonly revokeReason: string | null
44
+ }
45
+
46
+ export interface WarningPage {
47
+ readonly rows: readonly WarningRow[]
48
+ readonly nextCursor?: string
49
+ }
50
+
51
+ export interface WarningStanding {
52
+ readonly userId: number
53
+ readonly points: number
54
+ readonly level: WarningLevel | null
55
+ }
56
+
57
+ export interface PostingRestriction {
58
+ readonly suspendedUntil: Date | null
59
+ readonly moderatedUntil: Date | null
60
+ }
61
+
62
+ export const NO_RESTRICTION: PostingRestriction = {
63
+ suspendedUntil: null,
64
+ moderatedUntil: null,
65
+ }
66
+
67
+ export interface IssuedWarning {
68
+ readonly warningId: number
69
+ readonly points: number
70
+ readonly standing: WarningStanding
71
+ readonly triggered: WarningLevel | null
72
+ }
73
+
74
+ export interface WarningRepository {
75
+ listTypes(): Promise<readonly WarningType[]>
76
+ listLevels(): Promise<readonly WarningLevel[]>
77
+
78
+ findType(typeId: number): Promise<WarningType | null>
79
+
80
+ findWarnable(userId: number): Promise<{ id: number; username: string } | null>
81
+
82
+ findPostAuthor(postId: number): Promise<number | null>
83
+
84
+ issue(input: {
85
+ readonly userId: number
86
+ readonly issuedByUserId: number
87
+ readonly typeId: number | null
88
+ readonly title: string
89
+ readonly points: number
90
+ readonly reason: string
91
+ readonly postId: number | null
92
+ readonly expiresAt: Date | null
93
+ readonly at: Date
94
+ }): Promise<{ warningId: number; points: number }>
95
+
96
+ revoke(input: {
97
+ readonly warningId: number
98
+ readonly actorUserId: number
99
+ readonly reason: string
100
+ readonly at: Date
101
+ }): Promise<{ userId: number; points: number } | null>
102
+
103
+ pointsFor(userId: number): Promise<number>
104
+
105
+ readRestriction(userId: number): Promise<PostingRestriction>
106
+
107
+ applyRestriction(input: {
108
+ readonly userId: number
109
+ readonly restriction: PostingRestriction
110
+ readonly at: Date
111
+ }): Promise<void>
112
+
113
+ history(
114
+ userId: number,
115
+ options: { readonly limit: number; readonly after?: string },
116
+ ): Promise<WarningPage>
117
+
118
+ expireDue(now: Date, limit: number): Promise<{ expired: number; userIds: readonly number[] }>
119
+ }
120
+
121
+ export interface WarningBanPort {
122
+ ban(input: {
123
+ readonly userId: number
124
+ readonly bannedByUserId: number
125
+ readonly reason: string
126
+ readonly publicReason: string
127
+ readonly expiresAt?: Date | undefined
128
+ }): Promise<void>
129
+ }
130
+
131
+ export interface WarningNotifierPort {
132
+ warned(input: {
133
+ readonly userId: number
134
+ readonly title: string
135
+ readonly points: number
136
+ readonly totalPoints: number
137
+ readonly reason: string
138
+ readonly restriction: WarningAction | null
139
+ }): Promise<void>
140
+ }
141
+
142
+ export class WarningService {
143
+ private readonly warnings: WarningRepository
144
+ private readonly bans: WarningBanPort | null
145
+ private readonly notifier: WarningNotifierPort | null
146
+ private readonly now: () => Date
147
+
148
+ constructor(deps: {
149
+ warnings: WarningRepository
150
+ bans?: WarningBanPort | null
151
+ notifier?: WarningNotifierPort | null
152
+ now?: () => Date
153
+ }) {
154
+ this.warnings = deps.warnings
155
+ this.bans = deps.bans ?? null
156
+ this.notifier = deps.notifier ?? null
157
+ this.now = deps.now ?? (() => new Date())
158
+ }
159
+
160
+ async listTypes(): Promise<readonly WarningType[]> {
161
+ return this.warnings.listTypes()
162
+ }
163
+
164
+ async history(userId: number, options: { readonly after?: string } = {}): Promise<WarningPage> {
165
+ return this.warnings.history(userId, {
166
+ limit: WARNINGS_PAGE_SIZE,
167
+ ...(options.after === undefined ? {} : { after: options.after }),
168
+ })
169
+ }
170
+
171
+ async issue(input: {
172
+ readonly userId: number
173
+ readonly actorUserId: number
174
+ readonly typeId: number | null
175
+ readonly title?: string | undefined
176
+ readonly points?: number | undefined
177
+ readonly reason: string
178
+ readonly postId?: number | null
179
+ readonly mayWarn: boolean
180
+ }): Promise<IssuedWarning> {
181
+ if (!input.mayWarn) throw new ValidationError(msg('error.moderation.warn-members'))
182
+
183
+ if (input.userId === input.actorUserId) {
184
+ throw new ValidationError(msg('error.moderation.warn-yourself'))
185
+ }
186
+
187
+ const target = await this.warnings.findWarnable(input.userId)
188
+ if (target === null) throw new ValidationError(msg('error.moderation.member-exist'))
189
+
190
+ const reason = input.reason.trim()
191
+ if (reason.length === 0) throw new ValidationError(msg('error.moderation.say-what-warning-for'))
192
+ if (reason.length > REASON_MAX) {
193
+ throw new ValidationError(msg('error.moderation.reason-length', { max: REASON_MAX }))
194
+ }
195
+
196
+ const { title, points, expiryDays } = await this.resolveSubject(input)
197
+
198
+ const at = this.now()
199
+ const before = await this.standingFor(input.userId)
200
+
201
+ const written = await this.warnings.issue({
202
+ userId: input.userId,
203
+ issuedByUserId: input.actorUserId,
204
+ typeId: input.typeId,
205
+ title,
206
+ points,
207
+ reason,
208
+ postId: input.postId ?? null,
209
+ expiresAt: expiryDays === null ? null : addDays(at, expiryDays),
210
+ at,
211
+ })
212
+
213
+ const after = await this.standingFrom(written.points)
214
+ const triggered = newlyReached(before.level, after.level)
215
+ await this.enforce(input.userId, after, at, input.actorUserId, triggered)
216
+
217
+ if (this.notifier !== null) {
218
+ await this.notifier
219
+ .warned({
220
+ userId: input.userId,
221
+ title,
222
+ points,
223
+ totalPoints: written.points,
224
+ reason,
225
+ restriction: triggered?.action ?? null,
226
+ })
227
+ .catch(() => undefined)
228
+ }
229
+
230
+ return {
231
+ warningId: written.warningId,
232
+ points: written.points,
233
+ standing: { userId: input.userId, points: written.points, level: after.level },
234
+ triggered,
235
+ }
236
+ }
237
+
238
+ async revoke(input: {
239
+ readonly warningId: number
240
+ readonly actorUserId: number
241
+ readonly reason: string
242
+ readonly mayWarn: boolean
243
+ }): Promise<WarningStanding | null> {
244
+ if (!input.mayWarn) throw new ValidationError(msg('error.moderation.revoke-warnings'))
245
+
246
+ const reason = input.reason.trim()
247
+ if (reason.length > REASON_MAX) {
248
+ throw new ValidationError(msg('error.moderation.reason-length', { max: REASON_MAX }))
249
+ }
250
+
251
+ const at = this.now()
252
+ const revoked = await this.warnings.revoke({
253
+ warningId: input.warningId,
254
+ actorUserId: input.actorUserId,
255
+ reason,
256
+ at,
257
+ })
258
+ if (revoked === null) return null
259
+
260
+ const standing = await this.standingFrom(revoked.points)
261
+ await this.enforce(revoked.userId, standing, at, input.actorUserId, null)
262
+
263
+ return { userId: revoked.userId, points: revoked.points, level: standing.level }
264
+ }
265
+
266
+ async expireDue(limit = 200): Promise<number> {
267
+ const at = this.now()
268
+ const { expired, userIds } = await this.warnings.expireDue(at, limit)
269
+
270
+ for (const userId of userIds) {
271
+ const points = await this.warnings.pointsFor(userId)
272
+ const standing = await this.standingFrom(points)
273
+ await this.enforce(userId, standing, at, null, null)
274
+ }
275
+ return expired
276
+ }
277
+
278
+ async standingFor(userId: number): Promise<WarningStanding> {
279
+ const points = await this.warnings.pointsFor(userId)
280
+ const standing = await this.standingFrom(points)
281
+ return { userId, points, level: standing.level }
282
+ }
283
+
284
+ private async resolveSubject(input: {
285
+ readonly typeId: number | null
286
+ readonly title?: string | undefined
287
+ readonly points?: number | undefined
288
+ }): Promise<{ title: string; points: number; expiryDays: number | null }> {
289
+ if (input.typeId !== null) {
290
+ const type = await this.warnings.findType(input.typeId)
291
+ if (type === null) throw new ValidationError(msg('error.moderation.warning-type-exist'))
292
+ return { title: type.title, points: type.points, expiryDays: type.expiryDays }
293
+ }
294
+
295
+ const title = (input.title ?? '').trim()
296
+ if (title.length === 0) throw new ValidationError(msg('error.moderation.warning-needs-title'))
297
+ if (title.length > TITLE_MAX) {
298
+ throw new ValidationError(msg('error.moderation.warning-title-length', { max: TITLE_MAX }))
299
+ }
300
+
301
+ const points = input.points ?? 0
302
+ if (!Number.isSafeInteger(points) || points < 1 || points > POINTS_MAX) {
303
+ throw new ValidationError(msg('error.moderation.points-range', { max: POINTS_MAX }))
304
+ }
305
+ return { title, points, expiryDays: null }
306
+ }
307
+
308
+ private async standingFrom(points: number): Promise<{ level: WarningLevel | null }> {
309
+ const levels = await this.warnings.listLevels()
310
+ let reached: WarningLevel | null = null
311
+ for (const level of levels) {
312
+ if (points >= level.points && (reached === null || level.points > reached.points)) {
313
+ reached = level
314
+ }
315
+ }
316
+ return { level: reached }
317
+ }
318
+
319
+ private async enforce(
320
+ userId: number,
321
+ standing: { level: WarningLevel | null },
322
+ at: Date,
323
+ actorUserId: number | null,
324
+ triggered: WarningLevel | null,
325
+ ): Promise<void> {
326
+ const level = standing.level
327
+ const restriction: PostingRestriction =
328
+ level === null || level.action === 'ban'
329
+ ? NO_RESTRICTION
330
+ : {
331
+ suspendedUntil:
332
+ level.action === 'suspend_posting' ? until(at, level.durationDays) : null,
333
+ moderatedUntil:
334
+ level.action === 'moderate_posting' ? until(at, level.durationDays) : null,
335
+ }
336
+
337
+ await this.warnings.applyRestriction({ userId, restriction, at })
338
+
339
+ if (triggered?.action === 'ban' && this.bans !== null && actorUserId !== null) {
340
+ await this.bans
341
+ .ban({
342
+ userId,
343
+ bannedByUserId: actorUserId,
344
+ reason: `Reached ${triggered.points} warning points.`,
345
+ publicReason: 'Repeated breaches of the board rules.',
346
+ ...(triggered.durationDays === null
347
+ ? {}
348
+ : { expiresAt: addDays(at, triggered.durationDays) }),
349
+ })
350
+ .catch(() => undefined)
351
+ }
352
+ }
353
+ }
354
+
355
+ function newlyReached(
356
+ before: WarningLevel | null,
357
+ after: WarningLevel | null,
358
+ ): WarningLevel | null {
359
+ if (after === null) return null
360
+ if (before !== null && before.points >= after.points) return null
361
+ return after
362
+ }
363
+
364
+ function until(at: Date, days: number | null): Date | null {
365
+ return days === null ? FOREVER : addDays(at, days)
366
+ }
367
+
368
+ const FOREVER = new Date('9999-12-31T00:00:00Z')
369
+
370
+ function addDays(at: Date, days: number): Date {
371
+ return new Date(at.getTime() + days * 86_400_000)
372
+ }
373
+
374
+ export function restrictsPosting(
375
+ restriction: PostingRestriction,
376
+ now: Date,
377
+ ): { suspended: boolean; moderated: boolean } {
378
+ return {
379
+ suspended: restriction.suspendedUntil !== null && restriction.suspendedUntil > now,
380
+ moderated: restriction.moderatedUntil !== null && restriction.moderatedUntil > now,
381
+ }
382
+ }
383
+
384
+ export function parseWarningAction(value: string): WarningAction | null {
385
+ return WARNING_ACTIONS.includes(value as WarningAction) ? (value as WarningAction) : null
386
+ }