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