diff --git a/apps/bot/prisma/migrations/20260725180000_warning_number/migration.sql b/apps/bot/prisma/migrations/20260725180000_warning_number/migration.sql new file mode 100644 index 0000000..413be75 --- /dev/null +++ b/apps/bot/prisma/migrations/20260725180000_warning_number/migration.sql @@ -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"); diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index e821793..f4a32e8 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -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]) } diff --git a/apps/bot/src/modules/automod/actions.ts b/apps/bot/src/modules/automod/actions.ts index df2188d..0b75de1 100644 --- a/apps/bot/src/modules/automod/actions.ts +++ b/apps/bot/src/modules/automod/actions.ts @@ -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; } diff --git a/apps/bot/src/modules/moderation/command-definitions.ts b/apps/bot/src/modules/moderation/command-definitions.ts index 1122075..6b5efd0 100644 --- a/apps/bot/src/modules/moderation/command-definitions.ts +++ b/apps/bot/src/modules/moderation/command-definitions.ts @@ -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) => diff --git a/apps/bot/src/modules/moderation/commands.ts b/apps/bot/src/modules/moderation/commands.ts index bf614d8..9bb8851 100644 --- a/apps/bot/src/modules/moderation/commands.ts +++ b/apps/bot/src/modules/moderation/commands.ts @@ -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; diff --git a/apps/bot/src/modules/moderation/service.ts b/apps/bot/src/modules/moderation/service.ts index 7c3ce9b..0299cc4 100644 --- a/apps/bot/src/modules/moderation/service.ts +++ b/apps/bot/src/modules/moderation/service.ts @@ -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; }; +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 { + 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 { + 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 }, diff --git a/apps/webui/src/components/modules/warnings-table.tsx b/apps/webui/src/components/modules/warnings-table.tsx index 4d7df11..c61ec68 100644 --- a/apps/webui/src/components/modules/warnings-table.tsx +++ b/apps/webui/src/components/modules/warnings-table.tsx @@ -105,6 +105,7 @@ export function WarningsTable({ guildId, initialWarnings }: WarningsTableProps) + @@ -115,6 +116,7 @@ export function WarningsTable({ guildId, initialWarnings }: WarningsTableProps) {warnings.map((entry) => ( +
# {t('modulePages.moderation.target')} {t('modulePages.moderation.moderator')} {t('modulePages.moderation.reason')}
#{entry.warningNumber} {entry.userId} {entry.moderatorId} diff --git a/apps/webui/src/lib/module-configs/warnings.ts b/apps/webui/src/lib/module-configs/warnings.ts index cb5d2e2..b5f115d 100644 --- a/apps/webui/src/lib/module-configs/warnings.ts +++ b/apps/webui/src/lib/module-configs/warnings.ts @@ -13,6 +13,7 @@ export async function listWarnings(guildId: string, query: WarningListQuery): Pr return warnings.map((warning) => ({ id: warning.id, + warningNumber: warning.warningNumber, userId: warning.userId, moderatorId: warning.moderatorId, reason: warning.reason, diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index 040a12e..088b2d4 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -366,7 +366,7 @@ "deleteCase": "Fall löschen", "confirmDeleteCase": "Diesen Fall wirklich löschen?", "warningsTitle": "Verwarnungen", - "warningsDescription": "Letzte Verwarnungen. Entfernen löscht den Warn-Eintrag.", + "warningsDescription": "Letzte Verwarnungen mit kurzer Nummer (#). Entfernen löscht den Warn-Eintrag.", "warningsEmpty": "Keine Verwarnungen gefunden.", "warningsSearchUser": "User-ID filtern", "removeWarning": "Verwarnung entfernen", diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index 4d189c6..c401744 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -366,7 +366,7 @@ "deleteCase": "Delete case", "confirmDeleteCase": "Really delete this case?", "warningsTitle": "Warnings", - "warningsDescription": "Recent warnings. Remove deletes the warning entry.", + "warningsDescription": "Recent warnings with short numbers (#). Remove deletes the warning entry.", "warningsEmpty": "No warnings found.", "warningsSearchUser": "Filter by user ID", "removeWarning": "Remove warning", diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index c5829d4..df10b1d 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -617,3 +617,20 @@ - [ ] Alten Cron-Eintrag (jobId `repeat:…`) im Dashboard löschen → kein API-Error -8 - [ ] Dark Mode: Cron-Feld und datetime-local lesbar/bedienbar +## Post-Phase – Warn-DM + kurze Warnungsnummern (Status: implementiert) + +### Abgeschlossen (Code) + +- Verwarnte Nutzer erhalten eine DM mit Servername, Grund, aktueller Warnungsanzahl und Nr. +- Pro Guild sequenzielle kurze `warningNumber` (wie Case-Nummern); `/warn list` und Dashboard zeigen `#N` +- `/warn remove` akzeptiert Integer-Option `id` statt langer CUID +- Gemeinsame `createWarning`-Hilfe (Slash + AutoMod), Migration `20260725180000_warning_number` + +### Manuell testen + +- [ ] `/warn add` → Moderator sieht `#N`; Ziel-User erhält DM (Server, Grund, Count) +- [ ] User mit geschlossenen DMs → Warn trotzdem gespeichert, Bot-Log warnt +- [ ] `/warn list` zeigt `#1`, `#2`, …; `/warn remove id:1` entfernt korrekt +- [ ] AutoMod-WARN → ebenfalls DM + Case verknüpft +- [ ] Dashboard: Warn-Tabelle zeigt `#`-Spalte + diff --git a/packages/shared/src/command-locales.ts b/packages/shared/src/command-locales.ts index 01da1e7..6c93bbd 100644 --- a/packages/shared/src/command-locales.ts +++ b/packages/shared/src/command-locales.ts @@ -88,8 +88,8 @@ export const commandLocales = { en: 'Optional escalation reason' }, 'moderation.warn.options.warning_id': { - de: 'Verwarnungs-ID', - en: 'Warning ID' + de: 'Kurze Verwarnungsnummer (z. B. aus /warn list)', + en: 'Short warning number (e.g. from /warn list)' }, 'moderation.choice.timeout': { de: 'Timeout', diff --git a/packages/shared/src/dashboard.ts b/packages/shared/src/dashboard.ts index 036e37e..d4d49ff 100644 --- a/packages/shared/src/dashboard.ts +++ b/packages/shared/src/dashboard.ts @@ -101,6 +101,7 @@ export type CaseListQuery = z.infer; export const WarningDashboardSchema = z.object({ id: z.string(), + warningNumber: z.number().int().positive(), userId: z.string(), moderatorId: z.string(), reason: z.string().nullable(), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f523ace..71fc0e9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -133,10 +133,12 @@ const de: Dictionary = { 'Der Bot kann dieses Mitglied wegen der Rollenhierarchie oder fehlender Rechte nicht moderieren.', 'moderation.hierarchy.notMember': 'Dieses Mitglied ist nicht auf dem Server.', 'moderation.reason.none': 'Kein Grund angegeben', - 'moderation.warning.created': 'Verwarnung erstellt: {warningId}', + 'moderation.warning.created': 'Verwarnung #{warningNumber} erstellt.', 'moderation.warning.none': 'Keine Verwarnungen vorhanden.', 'moderation.warning.defaultReason': 'Kein Grund', 'moderation.warning.notFound': 'Verwarnung nicht gefunden.', + 'moderation.warning.dm': + 'Du hast eine Verwarnung auf **{guild}** erhalten.\nGrund: {reason}\nVerwarnungen: {count} (Nr. #{warningNumber})', 'moderation.purge.done': '{deletedCount} Nachrichten gelöscht (Fall #{caseNumber}).', 'moderation.note.none': 'Keine Notizen vorhanden.', 'moderation.case.notFound': 'Fall nicht gefunden.', @@ -750,10 +752,12 @@ const en: Dictionary = { 'The bot cannot moderate this member due to role hierarchy or missing permissions.', 'moderation.hierarchy.notMember': 'That member is not on this server.', 'moderation.reason.none': 'No reason provided', - 'moderation.warning.created': 'Warning created: {warningId}', + 'moderation.warning.created': 'Warning #{warningNumber} created.', 'moderation.warning.none': 'No warnings.', 'moderation.warning.defaultReason': 'No reason', 'moderation.warning.notFound': 'Warning not found.', + 'moderation.warning.dm': + 'You received a warning on **{guild}**.\nReason: {reason}\nWarnings: {count} (No. #{warningNumber})', 'moderation.purge.done': 'Deleted {deletedCount} messages (case #{caseNumber}).', 'moderation.note.none': 'No notes.', 'moderation.case.notFound': 'Case not found.',