@helyx/module-moderation 1.0.1
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/CHANGELOG.md +41 -0
- package/LICENSE +725 -0
- package/README.md +107 -0
- package/dist/action-support.d.ts +48 -0
- package/dist/action-support.js +201 -0
- package/dist/case-actions.d.ts +9 -0
- package/dist/case-actions.js +311 -0
- package/dist/case-naming.d.ts +4 -0
- package/dist/case-naming.js +9 -0
- package/dist/case-repository.d.ts +29 -0
- package/dist/case-repository.js +199 -0
- package/dist/channel-actions.d.ts +3 -0
- package/dist/channel-actions.js +267 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +309 -0
- package/dist/components.d.ts +15 -0
- package/dist/components.js +381 -0
- package/dist/configuration.d.ts +16 -0
- package/dist/configuration.js +94 -0
- package/dist/constants.d.ts +53 -0
- package/dist/constants.js +53 -0
- package/dist/contracts.d.ts +109 -0
- package/dist/contracts.js +84 -0
- package/dist/domain.d.ts +29 -0
- package/dist/domain.js +101 -0
- package/dist/events.d.ts +3 -0
- package/dist/events.js +60 -0
- package/dist/health.d.ts +10 -0
- package/dist/health.js +56 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +92 -0
- package/dist/moderation-cases-resource.d.ts +14 -0
- package/dist/moderation-cases-resource.js +226 -0
- package/dist/presentation.d.ts +8 -0
- package/dist/presentation.js +69 -0
- package/dist/privacy.d.ts +8 -0
- package/dist/privacy.js +38 -0
- package/dist/provider.d.ts +4 -0
- package/dist/provider.js +335 -0
- package/dist/receipt-repository.d.ts +9 -0
- package/dist/receipt-repository.js +36 -0
- package/dist/records.d.ts +399 -0
- package/dist/records.js +303 -0
- package/dist/repository-model.d.ts +131 -0
- package/dist/repository-model.js +318 -0
- package/dist/repository.d.ts +21 -0
- package/dist/repository.js +41 -0
- package/dist/service.d.ts +75 -0
- package/dist/service.js +329 -0
- package/dist/tasks.d.ts +49 -0
- package/dist/tasks.js +391 -0
- package/dist/thread-controls.d.ts +23 -0
- package/dist/thread-controls.js +376 -0
- package/dist/thread-deletion-recovery.d.ts +13 -0
- package/dist/thread-deletion-recovery.js +46 -0
- package/dist/thread-delivery.d.ts +11 -0
- package/dist/thread-delivery.js +427 -0
- package/dist/thread-reconciliation.d.ts +24 -0
- package/dist/thread-reconciliation.js +181 -0
- package/dist/thread-repository.d.ts +16 -0
- package/dist/thread-repository.js +70 -0
- package/manifest.json +999 -0
- package/migrations/0001_moderation_foundation.sql +552 -0
- package/migrations/0002_staff_attempt_parameters.sql +27 -0
- package/package.json +56 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { AUTOMOD_TIMEOUT_SECONDS, DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
+
import { appendModerationAudit, emitModerationActionLog, moderationConfiguration, moderationRetentionExpiresAt, scheduleActionReconciliation, } from "./action-support.js";
|
|
3
|
+
import { closeThreadControl, currentThreadActor } from "./components.js";
|
|
4
|
+
import { validateDemotionRoleIds, validateReason } from "./domain.js";
|
|
5
|
+
import { ModerationRepository, } from "./repository.js";
|
|
6
|
+
import { closeViolationThread, releaseViolationThreadAccess, } from "./thread-controls.js";
|
|
7
|
+
import { markAmbiguousThreadControl } from "./thread-reconciliation.js";
|
|
8
|
+
import { MODERATION_CASE_ACTIONS } from "./constants.js";
|
|
9
|
+
export class ModerationCaseActions {
|
|
10
|
+
moderation;
|
|
11
|
+
constructor(moderation) {
|
|
12
|
+
this.moderation = moderation;
|
|
13
|
+
}
|
|
14
|
+
async execute(context, moderationCase, actionId, value) {
|
|
15
|
+
if (!Object.hasOwn(MODERATION_CASE_ACTIONS, actionId))
|
|
16
|
+
fail("Unknown moderation action.");
|
|
17
|
+
const action = actionId;
|
|
18
|
+
const closing = action === "close-thread" || action === "close-and-delete-thread";
|
|
19
|
+
if (moderationCase.state !== "applied" ||
|
|
20
|
+
!moderationCase.subjectUserId ||
|
|
21
|
+
moderationCase.disassociatedAt)
|
|
22
|
+
fail("This case is not available for a moderation action.");
|
|
23
|
+
const actorContext = {
|
|
24
|
+
serverId: context.guildId,
|
|
25
|
+
userId: context.actor.userId,
|
|
26
|
+
services: context.services,
|
|
27
|
+
};
|
|
28
|
+
const repository = new ModerationRepository(context.services);
|
|
29
|
+
let bindingRevision = moderationCase.revision;
|
|
30
|
+
let bindingState = "applied";
|
|
31
|
+
let deletionConfiguration = null;
|
|
32
|
+
const revalidate = async () => {
|
|
33
|
+
if (!(await currentThreadActor(actorContext, MODERATION_CASE_ACTIONS[action])))
|
|
34
|
+
return false;
|
|
35
|
+
const current = await repository.findCase(context.guildId, moderationCase.caseId);
|
|
36
|
+
const binding = deletionConfiguration === null
|
|
37
|
+
? null
|
|
38
|
+
: await repository.findThreadDelivery(context.guildId, moderationCase.caseId);
|
|
39
|
+
return (current?.revision === bindingRevision &&
|
|
40
|
+
current.state === bindingState &&
|
|
41
|
+
current.subjectUserId === moderationCase.subjectUserId &&
|
|
42
|
+
!current.disassociatedAt &&
|
|
43
|
+
(deletionConfiguration === null ||
|
|
44
|
+
(binding?.threadId === delivery?.threadId &&
|
|
45
|
+
binding?.parentChannelId === delivery?.parentChannelId &&
|
|
46
|
+
binding?.controlToken === delivery?.controlToken &&
|
|
47
|
+
binding?.accessRoleId === delivery?.accessRoleId)) &&
|
|
48
|
+
(deletionConfiguration === null ||
|
|
49
|
+
JSON.stringify(await moderationConfiguration(context.services, context.guildId)) === deletionConfiguration));
|
|
50
|
+
};
|
|
51
|
+
if (!(await revalidate()))
|
|
52
|
+
fail("You no longer have access to this moderation action.");
|
|
53
|
+
const delivery = await repository.findThreadDelivery(context.guildId, moderationCase.caseId);
|
|
54
|
+
if (delivery &&
|
|
55
|
+
(delivery.caseRevision !== moderationCase.revision ||
|
|
56
|
+
(delivery.resolutionState === "closed" &&
|
|
57
|
+
action !== "close-and-delete-thread")))
|
|
58
|
+
fail("This case or its thread changed. Refresh the case before acting.");
|
|
59
|
+
if (delivery?.resolutionState === "resolved" && !closing)
|
|
60
|
+
fail("This case already has a staff resolution.");
|
|
61
|
+
if ((closing || action === "no-further-action") && !delivery)
|
|
62
|
+
fail("This case has no violation thread to resolve or close.");
|
|
63
|
+
if (closing && delivery?.resolutionState === "awaiting_staff")
|
|
64
|
+
fail("Resolve the case before closing its thread.");
|
|
65
|
+
const active = delivery?.parentChannelId &&
|
|
66
|
+
delivery.accessRoleId &&
|
|
67
|
+
delivery.controlToken
|
|
68
|
+
? {
|
|
69
|
+
...delivery,
|
|
70
|
+
parentChannelId: delivery.parentChannelId,
|
|
71
|
+
accessRoleId: delivery.accessRoleId,
|
|
72
|
+
controlToken: delivery.controlToken,
|
|
73
|
+
}
|
|
74
|
+
: null;
|
|
75
|
+
if (delivery && !active)
|
|
76
|
+
fail("This violation thread is no longer available.");
|
|
77
|
+
const dispatch = actionInput(action, value);
|
|
78
|
+
const reason = action === "no-further-action" || closing
|
|
79
|
+
? `Staff selected ${action.replaceAll("-", " ")} in the dashboard.`
|
|
80
|
+
: validatedReason(value.reason);
|
|
81
|
+
const operationKey = `dashboard:${context.correlationId}:${moderationCase.caseId}:${action}`;
|
|
82
|
+
const configuration = await moderationConfiguration(context.services, context.guildId);
|
|
83
|
+
if (action === "close-and-delete-thread")
|
|
84
|
+
deletionConfiguration = JSON.stringify(configuration);
|
|
85
|
+
const atomic = context.services.get(HELYX_SERVICE_NAMES.atomicModerationCases);
|
|
86
|
+
const attempt = await atomic.createAdditionalPendingAttempt({
|
|
87
|
+
guildId: context.guildId,
|
|
88
|
+
caseId: moderationCase.caseId,
|
|
89
|
+
expectedRevision: moderationCase.revision,
|
|
90
|
+
purpose: "thread_control",
|
|
91
|
+
action: dispatch.type,
|
|
92
|
+
actorUserId: context.actor.userId,
|
|
93
|
+
reason,
|
|
94
|
+
operationKey,
|
|
95
|
+
occurredAt: new Date(),
|
|
96
|
+
...(dispatch.type === "timeout"
|
|
97
|
+
? { requestedDurationSeconds: dispatch.durationSeconds }
|
|
98
|
+
: {}),
|
|
99
|
+
...(dispatch.type === "demote"
|
|
100
|
+
? { demoteRoleIds: dispatch.roleIds }
|
|
101
|
+
: {}),
|
|
102
|
+
});
|
|
103
|
+
if (attempt.outcome === "rejected")
|
|
104
|
+
fail("This moderation case changed. Refresh it before acting.");
|
|
105
|
+
if (attempt.outcome === "replayed") {
|
|
106
|
+
if (attempt.attemptOutcome === "pending")
|
|
107
|
+
fail("This moderation action is already in progress. Refresh the case shortly.");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
bindingRevision = attempt.revision;
|
|
111
|
+
bindingState = "pending";
|
|
112
|
+
let effect;
|
|
113
|
+
let auditConfirmed = false;
|
|
114
|
+
try {
|
|
115
|
+
await appendModerationAudit(context.services, {
|
|
116
|
+
guildId: context.guildId,
|
|
117
|
+
actorUserId: context.actor.userId,
|
|
118
|
+
action: "moderation.case.staff-action",
|
|
119
|
+
source: "dashboard",
|
|
120
|
+
correlationId: context.correlationId,
|
|
121
|
+
idempotencyKey: `${operationKey}:audit`,
|
|
122
|
+
targetType: "moderation_case",
|
|
123
|
+
targetId: moderationCase.caseId,
|
|
124
|
+
metadata: { action, caseNumber: moderationCase.caseNumber },
|
|
125
|
+
});
|
|
126
|
+
auditConfirmed = true;
|
|
127
|
+
if (active)
|
|
128
|
+
await markAmbiguousThreadControl(repository, active, closing
|
|
129
|
+
? action === "close-and-delete-thread"
|
|
130
|
+
? "close_delete"
|
|
131
|
+
: "close"
|
|
132
|
+
: action === "kick" || action === "ban" || action === "unmute"
|
|
133
|
+
? action
|
|
134
|
+
: "resolve", attempt.attemptId);
|
|
135
|
+
effect = !(await revalidate())
|
|
136
|
+
? { outcome: "missing_permission", safeCode: "actor_access_changed" }
|
|
137
|
+
: closing && active
|
|
138
|
+
? await closeViolationThread(context.services, active, moderationCase.subjectUserId, revalidate, action === "close-and-delete-thread")
|
|
139
|
+
: await this.moderation.dispatchAction(context.services, {
|
|
140
|
+
guildId: context.guildId,
|
|
141
|
+
subjectUserId: moderationCase.subjectUserId,
|
|
142
|
+
actorUserId: context.actor.userId,
|
|
143
|
+
action: dispatch,
|
|
144
|
+
reason: `[Helyx case ${moderationCase.caseNumber}] ${reason}`.slice(0, 512),
|
|
145
|
+
operationKey: `${operationKey}:discord`,
|
|
146
|
+
revalidate,
|
|
147
|
+
protectedRoleIds: [
|
|
148
|
+
...new Set([
|
|
149
|
+
configuration.violationAccessRoleId,
|
|
150
|
+
active?.accessRoleId,
|
|
151
|
+
].filter((id) => Boolean(id))),
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
effect = auditConfirmed
|
|
157
|
+
? { outcome: "ambiguous", safeCode: "staff_action_unconfirmed" }
|
|
158
|
+
: { outcome: "failed", safeCode: "audit_unavailable" };
|
|
159
|
+
}
|
|
160
|
+
const occurredAt = new Date();
|
|
161
|
+
if (auditConfirmed &&
|
|
162
|
+
action === "close-and-delete-thread" &&
|
|
163
|
+
!["succeeded", "already_applied"].includes(effect.outcome))
|
|
164
|
+
effect = { outcome: "ambiguous", safeCode: "thread_delete_unconfirmed" };
|
|
165
|
+
const settled = await atomic
|
|
166
|
+
.settleActionAttempt({
|
|
167
|
+
guildId: context.guildId,
|
|
168
|
+
caseId: moderationCase.caseId,
|
|
169
|
+
attemptId: attempt.attemptId,
|
|
170
|
+
expectedRevision: attempt.revision,
|
|
171
|
+
outcome: effect.outcome,
|
|
172
|
+
operationKey: `${operationKey}:settle`,
|
|
173
|
+
occurredAt,
|
|
174
|
+
retentionExpiresAt: moderationRetentionExpiresAt(occurredAt, configuration.caseRetentionDays),
|
|
175
|
+
...(effect.safeCode ? { safeCode: effect.safeCode } : {}),
|
|
176
|
+
...(effect.removedRoleIds
|
|
177
|
+
? { removedRoleIds: effect.removedRoleIds }
|
|
178
|
+
: {}),
|
|
179
|
+
})
|
|
180
|
+
.catch(() => null);
|
|
181
|
+
if (!settled ||
|
|
182
|
+
settled.outcome === "rejected" ||
|
|
183
|
+
effect.outcome === "ambiguous") {
|
|
184
|
+
await scheduleActionReconciliation(context.services, {
|
|
185
|
+
guildId: context.guildId,
|
|
186
|
+
caseId: moderationCase.caseId,
|
|
187
|
+
attemptId: attempt.attemptId,
|
|
188
|
+
operationKey,
|
|
189
|
+
}).catch(() => undefined);
|
|
190
|
+
fail("The action was not confirmed and needs dashboard review.");
|
|
191
|
+
}
|
|
192
|
+
bindingRevision = settled.revision;
|
|
193
|
+
bindingState = "applied";
|
|
194
|
+
if (!["succeeded", "already_applied"].includes(effect.outcome)) {
|
|
195
|
+
if (active) {
|
|
196
|
+
const current = await repository.findThreadDelivery(context.guildId, moderationCase.caseId);
|
|
197
|
+
if (current)
|
|
198
|
+
await repository.updateThreadDelivery({
|
|
199
|
+
deliveryId: current.deliveryId,
|
|
200
|
+
expectedFenceToken: current.fenceToken,
|
|
201
|
+
values: {
|
|
202
|
+
caseRevision: settled.revision,
|
|
203
|
+
safeCode: null,
|
|
204
|
+
retryAt: null,
|
|
205
|
+
updatedAt: new Date(),
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
fail("The moderation action was not applied. Check permissions, membership and role hierarchy.");
|
|
210
|
+
}
|
|
211
|
+
if (dispatch.type !== "case_only" && dispatch.type !== "delete_message")
|
|
212
|
+
await emitModerationActionLog(context.services, {
|
|
213
|
+
guildId: context.guildId,
|
|
214
|
+
caseId: moderationCase.caseId,
|
|
215
|
+
caseNumber: moderationCase.caseNumber,
|
|
216
|
+
subjectUserId: moderationCase.subjectUserId,
|
|
217
|
+
actorUserId: context.actor.userId,
|
|
218
|
+
action: dispatch.type,
|
|
219
|
+
operationKey,
|
|
220
|
+
...(dispatch.type === "timeout"
|
|
221
|
+
? { durationSeconds: dispatch.durationSeconds }
|
|
222
|
+
: {}),
|
|
223
|
+
});
|
|
224
|
+
if (!active)
|
|
225
|
+
return;
|
|
226
|
+
await scheduleActionReconciliation(context.services, {
|
|
227
|
+
guildId: context.guildId,
|
|
228
|
+
caseId: moderationCase.caseId,
|
|
229
|
+
attemptId: attempt.attemptId,
|
|
230
|
+
operationKey,
|
|
231
|
+
});
|
|
232
|
+
if ((action === "kick" || action === "ban") &&
|
|
233
|
+
(await releaseViolationThreadAccess(context.services, active, moderationCase.subjectUserId, action)) === "review_required")
|
|
234
|
+
fail("The action succeeded, but thread access needs dashboard review.");
|
|
235
|
+
const currentDelivery = await repository.findThreadDelivery(context.guildId, moderationCase.caseId);
|
|
236
|
+
if (!currentDelivery ||
|
|
237
|
+
!(await repository.updateThreadDelivery({
|
|
238
|
+
deliveryId: active.deliveryId,
|
|
239
|
+
expectedFenceToken: currentDelivery.fenceToken,
|
|
240
|
+
values: {
|
|
241
|
+
caseRevision: settled.revision,
|
|
242
|
+
resolutionState: closing ? "closed" : "resolved",
|
|
243
|
+
...(closing
|
|
244
|
+
? { closeState: "succeeded", safeCode: null, retryAt: null }
|
|
245
|
+
: {}),
|
|
246
|
+
updatedAt: new Date(),
|
|
247
|
+
},
|
|
248
|
+
})))
|
|
249
|
+
fail("The action succeeded, but the thread state changed. Refresh the case.");
|
|
250
|
+
if (closing)
|
|
251
|
+
return;
|
|
252
|
+
const threads = context.services.get(HELYX_SERVICE_NAMES.violationThreads);
|
|
253
|
+
if (!threads.editControls || !active.threadId || !active.controlMessageId)
|
|
254
|
+
fail("Staff resolution recorded, but the thread controls need refresh.");
|
|
255
|
+
const edited = await threads.editControls({
|
|
256
|
+
guildId: context.guildId,
|
|
257
|
+
threadId: active.threadId,
|
|
258
|
+
messageId: active.controlMessageId,
|
|
259
|
+
buttons: closeThreadControl(active.controlToken).buttons,
|
|
260
|
+
operationKey: `${operationKey}:controls`,
|
|
261
|
+
revalidate,
|
|
262
|
+
});
|
|
263
|
+
if (edited.outcome !== "updated")
|
|
264
|
+
fail("Staff resolution recorded, but the thread controls need refresh.");
|
|
265
|
+
const refreshed = await repository.findThreadDelivery(context.guildId, moderationCase.caseId);
|
|
266
|
+
if (refreshed)
|
|
267
|
+
await repository.updateThreadDelivery({
|
|
268
|
+
deliveryId: refreshed.deliveryId,
|
|
269
|
+
expectedFenceToken: refreshed.fenceToken,
|
|
270
|
+
values: { safeCode: null, retryAt: null, updatedAt: new Date() },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function actionInput(action, value) {
|
|
275
|
+
if (action === "mute") {
|
|
276
|
+
const seconds = Number(value.durationSeconds);
|
|
277
|
+
if (!AUTOMOD_TIMEOUT_SECONDS.some((duration) => duration === seconds))
|
|
278
|
+
fail("Choose a supported mute duration.");
|
|
279
|
+
return { type: "timeout", durationSeconds: seconds };
|
|
280
|
+
}
|
|
281
|
+
if (action === "unmute")
|
|
282
|
+
return { type: "timeout_remove" };
|
|
283
|
+
if (action === "kick")
|
|
284
|
+
return { type: "kick" };
|
|
285
|
+
if (action === "ban")
|
|
286
|
+
return { type: "ban", deleteMessageSeconds: 0 };
|
|
287
|
+
if (action === "demote") {
|
|
288
|
+
try {
|
|
289
|
+
return {
|
|
290
|
+
type: "demote",
|
|
291
|
+
roleIds: validateDemotionRoleIds(value.roleIds),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
fail("Choose between 1 and 10 distinct roles to remove.");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return { type: "case_only" };
|
|
299
|
+
}
|
|
300
|
+
function validatedReason(value) {
|
|
301
|
+
try {
|
|
302
|
+
return validateReason(value);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return fail("The reason must be between 10 and 500 characters.");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function fail(message) {
|
|
309
|
+
throw new DashboardActionValidationError(message);
|
|
310
|
+
}
|
|
311
|
+
//# sourceMappingURL=case-actions.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ModerationConfiguration } from "./configuration.js";
|
|
2
|
+
export declare function formatModerationCaseNumber(caseNumber: number): string;
|
|
3
|
+
export declare function moderationThreadName(configuration: ModerationConfiguration, caseNumber: number): string;
|
|
4
|
+
//# sourceMappingURL=case-naming.d.ts.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function formatModerationCaseNumber(caseNumber) {
|
|
2
|
+
if (!Number.isSafeInteger(caseNumber) || caseNumber < 1)
|
|
3
|
+
throw new Error("Invalid moderation case number.");
|
|
4
|
+
return String(caseNumber).padStart(3, "0");
|
|
5
|
+
}
|
|
6
|
+
export function moderationThreadName(configuration, caseNumber) {
|
|
7
|
+
return `${configuration.violationCaseNamePrefix}-${formatModerationCaseNumber(caseNumber)}`;
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=case-naming.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ModuleRecordService } from "@helyx/sdk";
|
|
2
|
+
import type { ModerationCaseAction, ModerationCaseState } from "./contracts.js";
|
|
3
|
+
import { type StoredModerationAttempt, type StoredModerationCase } from "./repository-model.js";
|
|
4
|
+
export interface ModerationCaseQuery {
|
|
5
|
+
guildId: string;
|
|
6
|
+
search?: string;
|
|
7
|
+
actorUserId?: string;
|
|
8
|
+
ruleId?: string;
|
|
9
|
+
repeatOrdinal?: 1 | 2 | 3;
|
|
10
|
+
action?: ModerationCaseAction;
|
|
11
|
+
state?: ModerationCaseState;
|
|
12
|
+
from?: Date;
|
|
13
|
+
to?: Date;
|
|
14
|
+
cursor?: string;
|
|
15
|
+
limit: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ModerationCasePage {
|
|
18
|
+
items: readonly StoredModerationCase[];
|
|
19
|
+
nextCursor?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare class ModerationCaseRepository {
|
|
22
|
+
private readonly records;
|
|
23
|
+
constructor(records: ModuleRecordService);
|
|
24
|
+
findCase(guildId: string, caseId: string): Promise<StoredModerationCase | null>;
|
|
25
|
+
findCaseByNumber(guildId: string, caseNumber: number): Promise<StoredModerationCase | null>;
|
|
26
|
+
listCases(query: ModerationCaseQuery): Promise<ModerationCasePage>;
|
|
27
|
+
listAttempts(guildId: string, caseId: string): Promise<readonly StoredModerationAttempt[]>;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=case-repository.d.ts.map
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { MODERATION_LIMITS, MODERATION_MODULE_ID } from "./constants.js";
|
|
2
|
+
import { ATTEMPT_FIELDS, CASE_FIELDS, mapAttempt, mapCase, } from "./repository-model.js";
|
|
3
|
+
export class ModerationCaseRepository {
|
|
4
|
+
records;
|
|
5
|
+
constructor(records) {
|
|
6
|
+
this.records = records;
|
|
7
|
+
}
|
|
8
|
+
async findCase(guildId, caseId) {
|
|
9
|
+
const row = await this.records.findOne({
|
|
10
|
+
moduleId: MODERATION_MODULE_ID,
|
|
11
|
+
collection: "cases",
|
|
12
|
+
select: CASE_FIELDS,
|
|
13
|
+
where: { guild_id: guildId, case_id: caseId },
|
|
14
|
+
});
|
|
15
|
+
return row ? mapCase(row) : null;
|
|
16
|
+
}
|
|
17
|
+
async findCaseByNumber(guildId, caseNumber) {
|
|
18
|
+
const row = await this.records.findOne({
|
|
19
|
+
moduleId: MODERATION_MODULE_ID,
|
|
20
|
+
collection: "cases",
|
|
21
|
+
select: CASE_FIELDS,
|
|
22
|
+
where: { guild_id: guildId, case_number: caseNumber },
|
|
23
|
+
});
|
|
24
|
+
return row ? mapCase(row) : null;
|
|
25
|
+
}
|
|
26
|
+
async listCases(query) {
|
|
27
|
+
validateCaseQuery(query);
|
|
28
|
+
const search = query.search?.trim();
|
|
29
|
+
if (search?.startsWith("#")) {
|
|
30
|
+
const caseNumber = Number(search.slice(1));
|
|
31
|
+
const found = Number.isSafeInteger(caseNumber)
|
|
32
|
+
? await this.findCaseByNumber(query.guildId, caseNumber)
|
|
33
|
+
: null;
|
|
34
|
+
return { items: found && withinDates(found, query) ? [found] : [] };
|
|
35
|
+
}
|
|
36
|
+
const binding = queryBinding(query);
|
|
37
|
+
const decoded = decodeCursor(query.cursor, binding);
|
|
38
|
+
const snapshotAt = decoded?.snapshotAt ?? new Date().toISOString();
|
|
39
|
+
const effectiveQuery = withDefaultDateWindow(query, snapshotAt);
|
|
40
|
+
const where = {
|
|
41
|
+
guild_id: effectiveQuery.guildId,
|
|
42
|
+
};
|
|
43
|
+
if (effectiveQuery.actorUserId)
|
|
44
|
+
where.actor_user_id = effectiveQuery.actorUserId;
|
|
45
|
+
if (effectiveQuery.ruleId)
|
|
46
|
+
where.repeat_rule_id = effectiveQuery.ruleId;
|
|
47
|
+
if (effectiveQuery.repeatOrdinal)
|
|
48
|
+
where.repeat_offence_ordinal = effectiveQuery.repeatOrdinal;
|
|
49
|
+
if (effectiveQuery.action)
|
|
50
|
+
where.action = effectiveQuery.action;
|
|
51
|
+
if (effectiveQuery.state)
|
|
52
|
+
where.state = effectiveQuery.state;
|
|
53
|
+
if (search)
|
|
54
|
+
where.subject_user_id = search;
|
|
55
|
+
const page = await this.records.findMany({
|
|
56
|
+
moduleId: MODERATION_MODULE_ID,
|
|
57
|
+
collection: "cases",
|
|
58
|
+
select: CASE_FIELDS,
|
|
59
|
+
where,
|
|
60
|
+
orderBy: [
|
|
61
|
+
{ field: "created_at", direction: "desc" },
|
|
62
|
+
{ field: "case_id", direction: "desc" },
|
|
63
|
+
],
|
|
64
|
+
...(effectiveQuery.from && effectiveQuery.to
|
|
65
|
+
? {
|
|
66
|
+
dateRange: {
|
|
67
|
+
field: "created_at",
|
|
68
|
+
from: effectiveQuery.from,
|
|
69
|
+
to: effectiveQuery.to,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
: {}),
|
|
73
|
+
...(decoded ? { cursor: decoded.values } : {}),
|
|
74
|
+
limit: query.limit,
|
|
75
|
+
});
|
|
76
|
+
const items = page.records.map(mapCase);
|
|
77
|
+
return {
|
|
78
|
+
items,
|
|
79
|
+
...(page.nextCursor
|
|
80
|
+
? {
|
|
81
|
+
nextCursor: encodeCursor(binding, page.nextCursor, snapshotAt),
|
|
82
|
+
}
|
|
83
|
+
: {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async listAttempts(guildId, caseId) {
|
|
87
|
+
const result = await this.records.findMany({
|
|
88
|
+
moduleId: MODERATION_MODULE_ID,
|
|
89
|
+
collection: "action_attempts",
|
|
90
|
+
select: ATTEMPT_FIELDS,
|
|
91
|
+
where: { guild_id: guildId, case_id: caseId },
|
|
92
|
+
orderBy: [
|
|
93
|
+
{ field: "created_at", direction: "asc" },
|
|
94
|
+
{ field: "attempt_id", direction: "asc" },
|
|
95
|
+
],
|
|
96
|
+
limit: 100,
|
|
97
|
+
});
|
|
98
|
+
return result.records.map(mapAttempt);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function withDefaultDateWindow(query, snapshotAt) {
|
|
102
|
+
if (query.search || (query.from && query.to))
|
|
103
|
+
return query;
|
|
104
|
+
const to = new Date(snapshotAt);
|
|
105
|
+
if (!Number.isFinite(to.getTime()))
|
|
106
|
+
throw new Error("The moderation case cursor is invalid.");
|
|
107
|
+
return {
|
|
108
|
+
...query,
|
|
109
|
+
from: new Date(to.getTime() - MODERATION_LIMITS.caseDateWindowDays * 86_400_000),
|
|
110
|
+
to,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function validateCaseQuery(query) {
|
|
114
|
+
if (!Number.isInteger(query.limit) ||
|
|
115
|
+
query.limit < 1 ||
|
|
116
|
+
query.limit > MODERATION_LIMITS.casePageSize)
|
|
117
|
+
throw new Error("Moderation case page size is invalid.");
|
|
118
|
+
if ((query.from && !query.to) || (!query.from && query.to))
|
|
119
|
+
throw new Error("Moderation case date filters require both dates.");
|
|
120
|
+
if (query.from && query.to) {
|
|
121
|
+
const window = query.to.getTime() - query.from.getTime();
|
|
122
|
+
if (window < 0 ||
|
|
123
|
+
window > MODERATION_LIMITS.caseDateWindowDays * 86_400_000)
|
|
124
|
+
throw new Error("Moderation case date filters cannot exceed 31 days.");
|
|
125
|
+
}
|
|
126
|
+
for (const value of [
|
|
127
|
+
query.actorUserId,
|
|
128
|
+
query.search?.startsWith("#") ? undefined : query.search,
|
|
129
|
+
])
|
|
130
|
+
if (value && !/^\d{17,20}$/u.test(value.trim()))
|
|
131
|
+
throw new Error("Moderation case member filters require an exact Discord user ID.");
|
|
132
|
+
}
|
|
133
|
+
function withinDates(item, query) {
|
|
134
|
+
return ((!query.from || item.createdAt >= query.from) &&
|
|
135
|
+
(!query.to || item.createdAt <= query.to));
|
|
136
|
+
}
|
|
137
|
+
function queryBinding(query) {
|
|
138
|
+
return JSON.stringify({
|
|
139
|
+
v: 1,
|
|
140
|
+
guildId: query.guildId,
|
|
141
|
+
search: query.search?.trim() ?? null,
|
|
142
|
+
actorUserId: query.actorUserId ?? null,
|
|
143
|
+
ruleId: query.ruleId ?? null,
|
|
144
|
+
repeatOrdinal: query.repeatOrdinal ?? null,
|
|
145
|
+
action: query.action ?? null,
|
|
146
|
+
state: query.state ?? null,
|
|
147
|
+
from: query.from?.toISOString() ?? null,
|
|
148
|
+
to: query.to?.toISOString() ?? null,
|
|
149
|
+
order: "created_at_desc_case_id_desc",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function encodeCursor(binding, values, snapshotAt) {
|
|
153
|
+
return Buffer.from(JSON.stringify({
|
|
154
|
+
v: 1,
|
|
155
|
+
binding,
|
|
156
|
+
values: values.map((value) => value instanceof Date ? { date: value.toISOString() } : value),
|
|
157
|
+
snapshotAt,
|
|
158
|
+
}), "utf8").toString("base64url");
|
|
159
|
+
}
|
|
160
|
+
function decodeCursor(cursor, binding) {
|
|
161
|
+
if (!cursor)
|
|
162
|
+
return null;
|
|
163
|
+
try {
|
|
164
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
165
|
+
if (!isObject(parsed) ||
|
|
166
|
+
parsed.v !== 1 ||
|
|
167
|
+
parsed.binding !== binding ||
|
|
168
|
+
!Array.isArray(parsed.values) ||
|
|
169
|
+
typeof parsed.snapshotAt !== "string")
|
|
170
|
+
throw new Error("invalid");
|
|
171
|
+
const values = parsed.values.map((value) => isObject(value) && typeof value.date === "string"
|
|
172
|
+
? validDate(value.date)
|
|
173
|
+
: validScalar(value));
|
|
174
|
+
if (values.length !== 2)
|
|
175
|
+
throw new Error("invalid");
|
|
176
|
+
return { values, snapshotAt: parsed.snapshotAt };
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
throw new Error("The moderation case cursor is invalid or belongs to another query.");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function isObject(value) {
|
|
183
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
184
|
+
}
|
|
185
|
+
function validDate(value) {
|
|
186
|
+
const date = new Date(value);
|
|
187
|
+
if (!Number.isFinite(date.getTime()))
|
|
188
|
+
throw new Error("invalid");
|
|
189
|
+
return date;
|
|
190
|
+
}
|
|
191
|
+
function validScalar(value) {
|
|
192
|
+
if (typeof value === "string" ||
|
|
193
|
+
typeof value === "number" ||
|
|
194
|
+
typeof value === "boolean" ||
|
|
195
|
+
value instanceof Date)
|
|
196
|
+
return value;
|
|
197
|
+
throw new Error("invalid");
|
|
198
|
+
}
|
|
199
|
+
//# sourceMappingURL=case-repository.js.map
|