Implement warning system enhancements with unique identifiers
- Added a `warningNumber` field to the `Warning` model for sequential identification of warnings. - Updated the `createWarning` function to handle the new `warningNumber` logic and notify users via DM upon receiving a warning. - Refactored moderation commands to utilize the new warning system, including changes to the warning creation and removal processes. - Enhanced the WebUI to display warning numbers in the warnings table and updated localization for related messages. - Documented changes in phase tracking to reflect the new warning system features.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Warning" ADD COLUMN "warningNumber" INTEGER;
|
||||
|
||||
-- Backfill per-guild sequential numbers (oldest first)
|
||||
WITH numbered AS (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY "guildId" ORDER BY "createdAt" ASC, id ASC) AS rn
|
||||
FROM "Warning"
|
||||
)
|
||||
UPDATE "Warning" AS w
|
||||
SET "warningNumber" = numbered.rn
|
||||
FROM numbered
|
||||
WHERE w.id = numbered.id;
|
||||
|
||||
ALTER TABLE "Warning" ALTER COLUMN "warningNumber" SET NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX "Warning_guildId_warningNumber_key" ON "Warning"("guildId", "warningNumber");
|
||||
@@ -129,17 +129,19 @@ model Case {
|
||||
}
|
||||
|
||||
model Warning {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
userId String
|
||||
moderatorId String
|
||||
reason String?
|
||||
caseId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull)
|
||||
id String @id @default(cuid())
|
||||
warningNumber Int
|
||||
guildId String
|
||||
userId String
|
||||
moderatorId String
|
||||
reason String?
|
||||
caseId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([guildId, warningNumber])
|
||||
@@index([guildId, userId])
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { createCase } from '../moderation/service.js';
|
||||
import { createCase, createWarning } from '../moderation/service.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import {
|
||||
getAutoModConfig,
|
||||
getEnabledAutoModRules,
|
||||
@@ -41,15 +42,8 @@ async function applyAutoModAction(
|
||||
const baseReason = `AutoMod (${ruleType}): ${reason}`;
|
||||
|
||||
if (action === 'WARN') {
|
||||
await context.prisma.warning.create({
|
||||
data: {
|
||||
guildId: guild.id,
|
||||
userId: message.author.id,
|
||||
moderatorId,
|
||||
reason: baseReason
|
||||
}
|
||||
});
|
||||
await createCase(context, {
|
||||
const locale = await getGuildLocale(context.prisma, guild.id);
|
||||
const modCase = await createCase(context, {
|
||||
guildId: guild.id,
|
||||
targetUserId: message.author.id,
|
||||
moderatorId,
|
||||
@@ -59,6 +53,16 @@ async function applyAutoModAction(
|
||||
source: 'automod',
|
||||
metadata: { ruleType, automod: true }
|
||||
});
|
||||
await createWarning(context, {
|
||||
guildId: guild.id,
|
||||
userId: message.author.id,
|
||||
moderatorId,
|
||||
reason: baseReason,
|
||||
caseId: modCase.id,
|
||||
guildName: guild.name,
|
||||
locale,
|
||||
notifyUser: message.author
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -96,9 +96,12 @@ export const warnCommandData = applyCommandDescription(
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addStringOption(
|
||||
applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addIntegerOption(
|
||||
(o) =>
|
||||
applyOptionDescription(o.setName('warning_id').setRequired(true), 'moderation.warn.options.warning_id')
|
||||
applyOptionDescription(
|
||||
o.setName('id').setRequired(true).setMinValue(1),
|
||||
'moderation.warn.options.warning_id'
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { t, tf } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { assertModerableMember } from '../../hierarchy.js';
|
||||
import { applyWarnEscalation, createCase } from './service.js';
|
||||
import { applyWarnEscalation, createCase, createWarning } from './service.js';
|
||||
import { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { promptDestructiveConfirmation } from './confirmations.js';
|
||||
@@ -307,19 +307,15 @@ const warnCommand: SlashCommand = {
|
||||
action: 'WARN_ADD',
|
||||
reason
|
||||
});
|
||||
await context.prisma.user.upsert({
|
||||
where: { id: user.id },
|
||||
update: {},
|
||||
create: { id: user.id }
|
||||
});
|
||||
const warning = await context.prisma.warning.create({
|
||||
data: {
|
||||
guildId: interaction.guildId!,
|
||||
userId: user.id,
|
||||
moderatorId: interaction.user.id,
|
||||
reason,
|
||||
caseId: modCase.id
|
||||
}
|
||||
const { warning } = await createWarning(context, {
|
||||
guildId: interaction.guildId!,
|
||||
userId: user.id,
|
||||
moderatorId: interaction.user.id,
|
||||
reason,
|
||||
caseId: modCase.id,
|
||||
guildName: interaction.guild!.name,
|
||||
locale,
|
||||
notifyUser: user
|
||||
});
|
||||
const escalationResult = await applyWarnEscalation(
|
||||
interaction,
|
||||
@@ -330,8 +326,8 @@ const warnCommand: SlashCommand = {
|
||||
);
|
||||
await interaction.reply({
|
||||
content: escalationResult
|
||||
? `${tf(locale, 'moderation.warning.created', { warningId: warning.id })}\n${escalationResult}`
|
||||
: tf(locale, 'moderation.warning.created', { warningId: warning.id }),
|
||||
? `${tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber })}\n${escalationResult}`
|
||||
: tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
@@ -349,8 +345,8 @@ const warnCommand: SlashCommand = {
|
||||
? t(locale, 'moderation.warning.none')
|
||||
: warnings
|
||||
.map(
|
||||
(w: { id: string; reason: string | null }) =>
|
||||
`- ${w.id}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}`
|
||||
(w: { warningNumber: number; reason: string | null }) =>
|
||||
`- #${w.warningNumber}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}`
|
||||
)
|
||||
.join('\n');
|
||||
await interaction.reply({ content, ephemeral: true });
|
||||
@@ -358,9 +354,9 @@ const warnCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
const warningId = interaction.options.getString('warning_id', true);
|
||||
const warningNumber = interaction.options.getInteger('id', true);
|
||||
const warning = await context.prisma.warning.findFirst({
|
||||
where: { id: warningId, guildId: interaction.guildId! }
|
||||
where: { warningNumber, guildId: interaction.guildId! }
|
||||
});
|
||||
if (!warning) {
|
||||
await interaction.reply({
|
||||
@@ -376,7 +372,7 @@ const warnCommand: SlashCommand = {
|
||||
moderatorId: interaction.user.id,
|
||||
...auditFrom(interaction),
|
||||
action: 'WARN_REMOVE',
|
||||
reason: `Warning ${warningId} removed`
|
||||
reason: `Warning #${warningNumber} removed`
|
||||
});
|
||||
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared';
|
||||
import {
|
||||
PermissionFlagsBits,
|
||||
type ChatInputCommandInteraction,
|
||||
type User
|
||||
} from 'discord.js';
|
||||
import type { Prisma, Warning } from '@prisma/client';
|
||||
import { buildCaseMetadata, type CaseAuditSource, t, tf, type Locale } from '@nexumi/shared';
|
||||
import { logger } from '../../logger.js';
|
||||
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||
import { moderationQueue } from '../../queues.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
@@ -17,6 +22,121 @@ type CreateCaseInput = {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type CreateWarningInput = {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
moderatorId: string;
|
||||
reason?: string;
|
||||
caseId?: string;
|
||||
guildName: string;
|
||||
locale: Locale;
|
||||
/** Discord user to DM; when omitted the user is fetched by id. */
|
||||
notifyUser?: User;
|
||||
};
|
||||
|
||||
export type CreatedWarning = {
|
||||
warning: Warning;
|
||||
warningCount: number;
|
||||
};
|
||||
|
||||
async function notifyWarnedUser(params: {
|
||||
user: User;
|
||||
guildName: string;
|
||||
reason: string;
|
||||
warningCount: number;
|
||||
warningNumber: number;
|
||||
locale: Locale;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await params.user.send(
|
||||
tf(params.locale, 'moderation.warning.dm', {
|
||||
guild: params.guildName,
|
||||
reason: params.reason,
|
||||
count: params.warningCount,
|
||||
warningNumber: params.warningNumber
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, userId: params.user.id, warningNumber: params.warningNumber },
|
||||
'Failed to DM warned user'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWarning(
|
||||
context: BotContext,
|
||||
input: CreateWarningInput
|
||||
): Promise<CreatedWarning> {
|
||||
await context.prisma.user.upsert({
|
||||
where: { id: input.userId },
|
||||
update: {},
|
||||
create: { id: input.userId }
|
||||
});
|
||||
|
||||
let warning: Warning | null = null;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
warning = await context.prisma.$transaction(
|
||||
async (tx) => {
|
||||
const last = await tx.warning.findFirst({
|
||||
where: { guildId: input.guildId },
|
||||
orderBy: { warningNumber: 'desc' },
|
||||
select: { warningNumber: true }
|
||||
});
|
||||
return tx.warning.create({
|
||||
data: {
|
||||
guildId: input.guildId,
|
||||
warningNumber: (last?.warningNumber ?? 0) + 1,
|
||||
userId: input.userId,
|
||||
moderatorId: input.moderatorId,
|
||||
reason: input.reason,
|
||||
caseId: input.caseId
|
||||
}
|
||||
});
|
||||
},
|
||||
{ isolationLevel: 'Serializable' }
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
const code =
|
||||
typeof error === 'object' && error && 'code' in error
|
||||
? String((error as { code: unknown }).code)
|
||||
: '';
|
||||
if (code !== 'P2002' && attempt === 4) {
|
||||
throw error;
|
||||
}
|
||||
if (attempt === 4) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!warning) {
|
||||
throw new Error('Failed to create warning');
|
||||
}
|
||||
|
||||
const warningCount = await context.prisma.warning.count({
|
||||
where: { guildId: input.guildId, userId: input.userId }
|
||||
});
|
||||
|
||||
const reason = input.reason ?? t(input.locale, 'moderation.warning.defaultReason');
|
||||
const notifyUser =
|
||||
input.notifyUser ?? (await context.client.users.fetch(input.userId).catch(() => null));
|
||||
if (notifyUser) {
|
||||
await notifyWarnedUser({
|
||||
user: notifyUser,
|
||||
guildName: input.guildName,
|
||||
reason,
|
||||
warningCount,
|
||||
warningNumber: warning.warningNumber,
|
||||
locale: input.locale
|
||||
});
|
||||
}
|
||||
|
||||
return { warning, warningCount };
|
||||
}
|
||||
|
||||
export async function createCase(context: BotContext, input: CreateCaseInput) {
|
||||
await context.prisma.guild.upsert({
|
||||
where: { id: input.guildId },
|
||||
|
||||
Reference in New Issue
Block a user