deploy #2

Merged
smueller merged 24 commits from deploy into main 2026-07-29 07:44:03 +00:00
14 changed files with 218 additions and 52 deletions
Showing only changes of commit 2058713e03 - Show all commits

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

@@ -130,6 +130,7 @@ model Case {
model Warning { model Warning {
id String @id @default(cuid()) id String @id @default(cuid())
warningNumber Int
guildId String guildId String
userId String userId String
moderatorId String moderatorId String
@@ -140,6 +141,7 @@ model Warning {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull) case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull)
@@unique([guildId, warningNumber])
@@index([guildId, userId]) @@index([guildId, userId])
} }

View File

@@ -1,6 +1,7 @@
import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js'; import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js';
import type { BotContext } from '../../types.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 { import {
getAutoModConfig, getAutoModConfig,
getEnabledAutoModRules, getEnabledAutoModRules,
@@ -41,15 +42,8 @@ async function applyAutoModAction(
const baseReason = `AutoMod (${ruleType}): ${reason}`; const baseReason = `AutoMod (${ruleType}): ${reason}`;
if (action === 'WARN') { if (action === 'WARN') {
await context.prisma.warning.create({ const locale = await getGuildLocale(context.prisma, guild.id);
data: { const modCase = await createCase(context, {
guildId: guild.id,
userId: message.author.id,
moderatorId,
reason: baseReason
}
});
await createCase(context, {
guildId: guild.id, guildId: guild.id,
targetUserId: message.author.id, targetUserId: message.author.id,
moderatorId, moderatorId,
@@ -59,6 +53,16 @@ async function applyAutoModAction(
source: 'automod', source: 'automod',
metadata: { ruleType, automod: true } 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; return;
} }

View File

@@ -96,9 +96,12 @@ export const warnCommandData = applyCommandDescription(
) )
) )
.addSubcommand((s) => .addSubcommand((s) =>
applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addStringOption( applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addIntegerOption(
(o) => (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) => .addSubcommand((s) =>

View File

@@ -8,7 +8,7 @@ import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js'; import type { SlashCommand } from '../../types.js';
import { requirePermission } from '../../permissions.js'; import { requirePermission } from '../../permissions.js';
import { assertModerableMember } from '../../hierarchy.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 { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js';
import { getGuildLocale } from '../../i18n.js'; import { getGuildLocale } from '../../i18n.js';
import { promptDestructiveConfirmation } from './confirmations.js'; import { promptDestructiveConfirmation } from './confirmations.js';
@@ -307,19 +307,15 @@ const warnCommand: SlashCommand = {
action: 'WARN_ADD', action: 'WARN_ADD',
reason reason
}); });
await context.prisma.user.upsert({ const { warning } = await createWarning(context, {
where: { id: user.id },
update: {},
create: { id: user.id }
});
const warning = await context.prisma.warning.create({
data: {
guildId: interaction.guildId!, guildId: interaction.guildId!,
userId: user.id, userId: user.id,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
reason, reason,
caseId: modCase.id caseId: modCase.id,
} guildName: interaction.guild!.name,
locale,
notifyUser: user
}); });
const escalationResult = await applyWarnEscalation( const escalationResult = await applyWarnEscalation(
interaction, interaction,
@@ -330,8 +326,8 @@ const warnCommand: SlashCommand = {
); );
await interaction.reply({ await interaction.reply({
content: escalationResult content: escalationResult
? `${tf(locale, 'moderation.warning.created', { warningId: warning.id })}\n${escalationResult}` ? `${tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber })}\n${escalationResult}`
: tf(locale, 'moderation.warning.created', { warningId: warning.id }), : tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber }),
ephemeral: true ephemeral: true
}); });
return; return;
@@ -349,8 +345,8 @@ const warnCommand: SlashCommand = {
? t(locale, 'moderation.warning.none') ? t(locale, 'moderation.warning.none')
: warnings : warnings
.map( .map(
(w: { id: string; reason: string | null }) => (w: { warningNumber: number; reason: string | null }) =>
`- ${w.id}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}` `- #${w.warningNumber}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}`
) )
.join('\n'); .join('\n');
await interaction.reply({ content, ephemeral: true }); await interaction.reply({ content, ephemeral: true });
@@ -358,9 +354,9 @@ const warnCommand: SlashCommand = {
} }
if (sub === 'remove') { 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({ const warning = await context.prisma.warning.findFirst({
where: { id: warningId, guildId: interaction.guildId! } where: { warningNumber, guildId: interaction.guildId! }
}); });
if (!warning) { if (!warning) {
await interaction.reply({ await interaction.reply({
@@ -376,7 +372,7 @@ const warnCommand: SlashCommand = {
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
...auditFrom(interaction), ...auditFrom(interaction),
action: 'WARN_REMOVE', action: 'WARN_REMOVE',
reason: `Warning ${warningId} removed` reason: `Warning #${warningNumber} removed`
}); });
await replySuccessCase(interaction, locale, modCase.caseNumber); await replySuccessCase(interaction, locale, modCase.caseNumber);
return; return;

View File

@@ -1,6 +1,11 @@
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js'; import {
import type { Prisma } from '@prisma/client'; PermissionFlagsBits,
import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared'; 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 { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
import { moderationQueue } from '../../queues.js'; import { moderationQueue } from '../../queues.js';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
@@ -17,6 +22,121 @@ type CreateCaseInput = {
metadata?: Record<string, unknown>; 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) { export async function createCase(context: BotContext, input: CreateCaseInput) {
await context.prisma.guild.upsert({ await context.prisma.guild.upsert({
where: { id: input.guildId }, where: { id: input.guildId },

View File

@@ -105,6 +105,7 @@ export function WarningsTable({ guildId, initialWarnings }: WarningsTableProps)
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border text-left text-xs uppercase text-muted-foreground"> <tr className="border-b border-border text-left text-xs uppercase text-muted-foreground">
<th className="py-2 pr-4">#</th>
<th className="py-2 pr-4">{t('modulePages.moderation.target')}</th> <th className="py-2 pr-4">{t('modulePages.moderation.target')}</th>
<th className="py-2 pr-4">{t('modulePages.moderation.moderator')}</th> <th className="py-2 pr-4">{t('modulePages.moderation.moderator')}</th>
<th className="py-2 pr-4">{t('modulePages.moderation.reason')}</th> <th className="py-2 pr-4">{t('modulePages.moderation.reason')}</th>
@@ -115,6 +116,7 @@ export function WarningsTable({ guildId, initialWarnings }: WarningsTableProps)
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{warnings.map((entry) => ( {warnings.map((entry) => (
<tr key={entry.id}> <tr key={entry.id}>
<td className="py-2 pr-4 font-mono text-xs">#{entry.warningNumber}</td>
<td className="py-2 pr-4 font-mono text-xs">{entry.userId}</td> <td className="py-2 pr-4 font-mono text-xs">{entry.userId}</td>
<td className="py-2 pr-4 font-mono text-xs">{entry.moderatorId}</td> <td className="py-2 pr-4 font-mono text-xs">{entry.moderatorId}</td>
<td className="max-w-64 truncate py-2 pr-4"> <td className="max-w-64 truncate py-2 pr-4">

View File

@@ -13,6 +13,7 @@ export async function listWarnings(guildId: string, query: WarningListQuery): Pr
return warnings.map((warning) => ({ return warnings.map((warning) => ({
id: warning.id, id: warning.id,
warningNumber: warning.warningNumber,
userId: warning.userId, userId: warning.userId,
moderatorId: warning.moderatorId, moderatorId: warning.moderatorId,
reason: warning.reason, reason: warning.reason,

View File

@@ -366,7 +366,7 @@
"deleteCase": "Fall löschen", "deleteCase": "Fall löschen",
"confirmDeleteCase": "Diesen Fall wirklich löschen?", "confirmDeleteCase": "Diesen Fall wirklich löschen?",
"warningsTitle": "Verwarnungen", "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.", "warningsEmpty": "Keine Verwarnungen gefunden.",
"warningsSearchUser": "User-ID filtern", "warningsSearchUser": "User-ID filtern",
"removeWarning": "Verwarnung entfernen", "removeWarning": "Verwarnung entfernen",

View File

@@ -366,7 +366,7 @@
"deleteCase": "Delete case", "deleteCase": "Delete case",
"confirmDeleteCase": "Really delete this case?", "confirmDeleteCase": "Really delete this case?",
"warningsTitle": "Warnings", "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.", "warningsEmpty": "No warnings found.",
"warningsSearchUser": "Filter by user ID", "warningsSearchUser": "Filter by user ID",
"removeWarning": "Remove warning", "removeWarning": "Remove warning",

View File

@@ -617,3 +617,20 @@
- [ ] Alten Cron-Eintrag (jobId `repeat:…`) im Dashboard löschen → kein API-Error -8 - [ ] Alten Cron-Eintrag (jobId `repeat:…`) im Dashboard löschen → kein API-Error -8
- [ ] Dark Mode: Cron-Feld und datetime-local lesbar/bedienbar - [ ] 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

View File

@@ -88,8 +88,8 @@ export const commandLocales = {
en: 'Optional escalation reason' en: 'Optional escalation reason'
}, },
'moderation.warn.options.warning_id': { 'moderation.warn.options.warning_id': {
de: 'Verwarnungs-ID', de: 'Kurze Verwarnungsnummer (z. B. aus /warn list)',
en: 'Warning ID' en: 'Short warning number (e.g. from /warn list)'
}, },
'moderation.choice.timeout': { 'moderation.choice.timeout': {
de: 'Timeout', de: 'Timeout',

View File

@@ -101,6 +101,7 @@ export type CaseListQuery = z.infer<typeof CaseListQuerySchema>;
export const WarningDashboardSchema = z.object({ export const WarningDashboardSchema = z.object({
id: z.string(), id: z.string(),
warningNumber: z.number().int().positive(),
userId: z.string(), userId: z.string(),
moderatorId: z.string(), moderatorId: z.string(),
reason: z.string().nullable(), reason: z.string().nullable(),

View File

@@ -133,10 +133,12 @@ const de: Dictionary = {
'Der Bot kann dieses Mitglied wegen der Rollenhierarchie oder fehlender Rechte nicht moderieren.', 'Der Bot kann dieses Mitglied wegen der Rollenhierarchie oder fehlender Rechte nicht moderieren.',
'moderation.hierarchy.notMember': 'Dieses Mitglied ist nicht auf dem Server.', 'moderation.hierarchy.notMember': 'Dieses Mitglied ist nicht auf dem Server.',
'moderation.reason.none': 'Kein Grund angegeben', '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.none': 'Keine Verwarnungen vorhanden.',
'moderation.warning.defaultReason': 'Kein Grund', 'moderation.warning.defaultReason': 'Kein Grund',
'moderation.warning.notFound': 'Verwarnung nicht gefunden.', '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.purge.done': '{deletedCount} Nachrichten gelöscht (Fall #{caseNumber}).',
'moderation.note.none': 'Keine Notizen vorhanden.', 'moderation.note.none': 'Keine Notizen vorhanden.',
'moderation.case.notFound': 'Fall nicht gefunden.', '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.', 'The bot cannot moderate this member due to role hierarchy or missing permissions.',
'moderation.hierarchy.notMember': 'That member is not on this server.', 'moderation.hierarchy.notMember': 'That member is not on this server.',
'moderation.reason.none': 'No reason provided', '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.none': 'No warnings.',
'moderation.warning.defaultReason': 'No reason', 'moderation.warning.defaultReason': 'No reason',
'moderation.warning.notFound': 'Warning not found.', '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.purge.done': 'Deleted {deletedCount} messages (case #{caseNumber}).',
'moderation.note.none': 'No notes.', 'moderation.note.none': 'No notes.',
'moderation.case.notFound': 'Case not found.', 'moderation.case.notFound': 'Case not found.',