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:
TheOnlyMace
2026-07-25 17:31:28 +02:00
parent 061e287390
commit 2058713e03
14 changed files with 218 additions and 52 deletions

View File

@@ -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");

View File

@@ -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])
}

View File

@@ -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;
}

View File

@@ -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) =>

View File

@@ -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;

View File

@@ -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 },