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

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