Implement premium features and GDPR compliance in bot and WebUI

- Added new models for premium tier configurations and assignments, enabling feature limits for free and premium users.
- Introduced GDPR deletion logging and commands for user data management.
- Enhanced logging configuration with retention days for audit logs.
- Updated bot commands and job processing to include new premium and GDPR functionalities.
- Improved localization with new keys for premium features and GDPR commands in both English and German.
- Added UI components for managing premium settings and logging retention in the WebUI.
This commit is contained in:
smueller
2026-07-22 16:59:23 +02:00
parent 4852f16f79
commit 08af99ed52
42 changed files with 1546 additions and 30 deletions

View File

@@ -0,0 +1,91 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "snipeEnabled" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "LoggingConfig" ADD COLUMN "retentionDays" INTEGER NOT NULL DEFAULT 90;
-- CreateTable
CREATE TABLE "PremiumTierConfig" (
"tier" TEXT NOT NULL,
"maxTags" INTEGER NOT NULL,
"maxFeeds" INTEGER NOT NULL,
"maxBackups" INTEGER NOT NULL,
"features" JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PremiumTierConfig_pkey" PRIMARY KEY ("tier")
);
-- CreateTable
CREATE TABLE "GuildPremiumAssignment" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"tier" TEXT NOT NULL,
"note" TEXT,
"assignedById" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GuildPremiumAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserPremiumAssignment" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tier" TEXT NOT NULL,
"note" TEXT,
"assignedById" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserPremiumAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GdprDeletionLog" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"guildId" TEXT,
"summary" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GdprDeletionLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "GuildPremiumAssignment_guildId_key" ON "GuildPremiumAssignment"("guildId");
-- CreateIndex
CREATE INDEX "GuildPremiumAssignment_tier_idx" ON "GuildPremiumAssignment"("tier");
-- CreateIndex
CREATE UNIQUE INDEX "UserPremiumAssignment_userId_key" ON "UserPremiumAssignment"("userId");
-- CreateIndex
CREATE INDEX "UserPremiumAssignment_tier_idx" ON "UserPremiumAssignment"("tier");
-- CreateIndex
CREATE INDEX "GdprDeletionLog_userId_createdAt_idx" ON "GdprDeletionLog"("userId", "createdAt");
-- Seed default tier configs
INSERT INTO "PremiumTierConfig" ("tier", "maxTags", "maxFeeds", "maxBackups", "features", "updatedAt")
VALUES
(
'FREE',
15,
3,
2,
'{"tags":true,"feeds":true,"guildbackup":true}'::jsonb,
CURRENT_TIMESTAMP
),
(
'PREMIUM',
100,
25,
25,
'{"tags":true,"feeds":true,"guildbackup":true}'::jsonb,
CURRENT_TIMESTAMP
);

View File

@@ -62,6 +62,8 @@ model GuildSettings {
locale String @default("de")
timezone String @default("Europe/Berlin")
moderationEnabled Boolean @default(true)
/// Snipe/editsnipe storage and commands (SPEC: default off for privacy).
snipeEnabled Boolean @default(false)
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
@@ -209,6 +211,8 @@ model LoggingConfig {
ignoreBots Boolean @default(true)
ignoredChannelIds String[] @default([])
ignoredRoleIds String[] @default([])
/// Days to keep Case / dashboard audit rows; 0 = keep forever.
retentionDays Int @default(90)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@ -881,3 +885,50 @@ model OwnerAuditLog {
@@index([createdAt])
}
/// Per-tier limits and feature gates (FREE / PREMIUM). Seeded on first use.
model PremiumTierConfig {
tier String @id
maxTags Int
maxFeeds Int
maxBackups Int
/// JSON object of feature key → boolean (tags, feeds, guildbackup, …).
features Json
updatedAt DateTime @updatedAt
}
model GuildPremiumAssignment {
id String @id @default(cuid())
guildId String @unique
tier String
note String?
assignedById String
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tier])
}
model UserPremiumAssignment {
id String @id @default(cuid())
userId String @unique
tier String
note String?
assignedById String
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tier])
}
model GdprDeletionLog {
id String @id @default(cuid())
userId String
guildId String?
summary Json?
createdAt DateTime @default(now())
@@index([userId, createdAt])
}

View File

@@ -30,10 +30,12 @@ import { feedsCommands } from './modules/feeds/index.js';
import { scheduleCommands } from './modules/scheduler/index.js';
import { guildBackupCommands } from './modules/guildbackup/index.js';
import { coreCommands } from './modules/core/index.js';
import { gdprCommands } from './modules/gdpr/index.js';
import type { BotContext, SlashCommand } from './types.js';
const commands: SlashCommand[] = [
...coreCommands,
...gdprCommands,
...moderationCommands,
...automodCommands,
...welcomeCommands,

View File

@@ -36,6 +36,8 @@ import {
presenceQueue,
presenceQueueName,
reminderQueueName,
retentionQueue,
retentionQueueName,
scheduleQueueName,
suggestionsQueueName,
ticketQueue,
@@ -321,6 +323,20 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'Presence refresh job failed');
});
const retentionWorker = new Worker(
retentionQueueName,
async (job) => {
if (job.name !== 'purgeExpiredLogs') {
return;
}
await runLogRetentionJob(context);
},
{ connection: redis }
);
retentionWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Log retention job failed');
});
return [
moderationWorker,
backupWorker,
@@ -335,7 +351,8 @@ export function startWorkers(context: BotContext): Worker[] {
scheduleWorker,
guildBackupWorker,
suggestionsWorker,
presenceWorker
presenceWorker,
retentionWorker
];
}
@@ -403,6 +420,41 @@ export async function ensureRecurringJobs(): Promise<void> {
removeOnFail: 5
}
);
await retentionQueue.add(
'purgeExpiredLogs',
{},
{
repeat: { pattern: '15 4 * * *' },
removeOnComplete: 20,
removeOnFail: 20
}
);
}
async function runLogRetentionJob(context: BotContext): Promise<void> {
const configs = await context.prisma.loggingConfig.findMany({
where: { retentionDays: { gt: 0 } },
select: { guildId: true, retentionDays: true }
});
for (const config of configs) {
const cutoff = new Date(Date.now() - config.retentionDays * 24 * 60 * 60 * 1000);
const [cases, audits] = await Promise.all([
context.prisma.case.deleteMany({
where: { guildId: config.guildId, createdAt: { lt: cutoff } }
}),
context.prisma.dashboardAuditLog.deleteMany({
where: { guildId: config.guildId, createdAt: { lt: cutoff } }
})
]);
if (cases.count > 0 || audits.count > 0) {
logger.info(
{ guildId: config.guildId, cases: cases.count, audits: audits.count, retentionDays: config.retentionDays },
'Log retention purge completed'
);
}
}
}
async function runPgDumpBackup(): Promise<void> {

View File

@@ -1,6 +1,6 @@
/** Module id → top-level slash command names for `/help`. */
export const HELP_MODULES: ReadonlyArray<{ id: string; commands: readonly string[] }> = [
{ id: 'core', commands: ['help', 'info', 'invite', 'support'] },
{ id: 'core', commands: ['help', 'info', 'invite', 'support', 'privacy', 'gdpr'] },
{
id: 'moderation',
commands: [

View File

@@ -4,6 +4,7 @@ import { applyFeedTemplate, SocialFeedTypeSchema } from '@nexumi/shared';
import { z } from 'zod';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
import type { BotContext } from '../../types.js';
import { fetchFeedItems, type FeedItem, type FetchResult } from './fetchers.js';
@@ -49,6 +50,16 @@ export async function addFeed(
await ensureGuild(prisma, guildId);
const sourceId = normalizeFeedSource(input.type, input.source);
const currentCount = await prisma.socialFeed.count({ where: { guildId } });
try {
await assertPremiumLimit(prisma, guildId, 'feeds', currentCount);
} catch (error) {
if (error instanceof PremiumLimitError) {
throw new FeedError(error.code === 'feature_disabled' ? 'premium_feature' : 'premium_limit');
}
throw error;
}
return prisma.socialFeed.create({
data: {
guildId,

View File

@@ -0,0 +1,22 @@
import {
applyCommandDescription,
applyOptionDescription
} from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const privacyCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('privacy'),
'gdpr.privacy.description'
);
export const gdprCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('gdpr'),
'gdpr.command.description'
).addSubcommand((sub) =>
applyCommandDescription(sub.setName('delete'), 'gdpr.delete.description').addBooleanOption((option) =>
applyOptionDescription(
option.setName('confirm').setRequired(true),
'gdpr.delete.options.confirm'
)
)
);

View File

@@ -0,0 +1,77 @@
import { EmbedBuilder } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { env } from '../../env.js';
import { privacyCommandData, gdprCommandData } from './command-definitions.js';
import { deleteUserPersonalData } from './service.js';
const NEXUMI_COLOR = 0x6366f1;
function privacyUrl(): string {
const base = (env.WEBUI_URL ?? env.PUBLIC_BASE_URL).replace(/\/$/, '');
return `${base}/privacy`;
}
const privacyCommand: SlashCommand = {
data: privacyCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const embed = new EmbedBuilder()
.setTitle(t(locale, 'gdpr.privacy.title'))
.setColor(NEXUMI_COLOR)
.setDescription(
[
t(locale, 'gdpr.privacy.body'),
'',
tf(locale, 'gdpr.privacy.link', { url: privacyUrl() }),
t(locale, 'gdpr.privacy.deleteHint')
].join('\n')
);
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
const gdprCommand: SlashCommand = {
data: gdprCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const sub = interaction.options.getSubcommand(true);
if (sub !== 'delete') {
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
return;
}
const confirmed = interaction.options.getBoolean('confirm', true);
if (!confirmed) {
await interaction.reply({
content: t(locale, 'gdpr.delete.needConfirm'),
ephemeral: true
});
return;
}
await interaction.deferReply({ ephemeral: true });
const summary = await deleteUserPersonalData(
context.prisma,
interaction.user.id,
interaction.guildId
);
const embed = new EmbedBuilder()
.setTitle(t(locale, 'gdpr.delete.doneTitle'))
.setColor(NEXUMI_COLOR)
.setDescription(
tf(locale, 'gdpr.delete.doneBody', {
warnings: summary.warnings,
levels: summary.memberLevels,
economy: summary.memberEconomies,
cases: summary.casesAnonymized
})
);
await interaction.editReply({ embeds: [embed] });
}
};
export const gdprCommands: SlashCommand[] = [privacyCommand, gdprCommand];

View File

@@ -0,0 +1,2 @@
export { gdprCommands } from './commands.js';
export { deleteUserPersonalData } from './service.js';

View File

@@ -0,0 +1,96 @@
import type { PrismaClient } from '@prisma/client';
export type GdprDeletionSummary = {
warnings: number;
modNotes: number;
memberLevels: number;
memberEconomies: number;
inventoryItems: number;
birthdays: number;
afkStatuses: number;
reminders: number;
inviteStats: number;
casesAnonymized: number;
userPremium: number;
userRecord: number;
};
/**
* Deletes or anonymizes personal data for a Discord user across guilds.
* Moderation case history is anonymized (targetUserId cleared to a placeholder)
* so server audit integrity is preserved.
*/
export async function deleteUserPersonalData(
prisma: PrismaClient,
userId: string,
guildId: string | null
): Promise<GdprDeletionSummary> {
const scope = guildId ? { guildId, userId } : { userId };
const caseScope = guildId
? { guildId, targetUserId: userId }
: { targetUserId: userId };
const [
warnings,
modNotes,
memberLevels,
memberEconomies,
inventoryItems,
birthdays,
afkStatuses,
reminders,
inviteStats,
inviteJoins,
casesAnonymized,
userPremium
] = await prisma.$transaction([
prisma.warning.deleteMany({ where: scope }),
prisma.modNote.deleteMany({ where: scope }),
prisma.memberLevel.deleteMany({ where: scope }),
prisma.memberEconomy.deleteMany({ where: scope }),
prisma.inventoryItem.deleteMany({ where: scope }),
prisma.birthday.deleteMany({ where: scope }),
prisma.afkStatus.deleteMany({ where: scope }),
prisma.reminder.deleteMany({ where: { userId, ...(guildId ? { guildId } : {}) } }),
prisma.inviteStat.deleteMany({
where: guildId ? { guildId, inviterId: userId } : { inviterId: userId }
}),
prisma.inviteJoin.deleteMany({ where: scope }),
prisma.case.updateMany({
where: caseScope,
data: { targetUserId: '0', reason: '[redacted GDPR deletion]' }
}),
prisma.userPremiumAssignment.deleteMany({ where: { userId } })
]);
let userRecord = 0;
if (!guildId) {
const deleted = await prisma.user.deleteMany({ where: { id: userId } });
userRecord = deleted.count;
}
const summary: GdprDeletionSummary = {
warnings: warnings.count,
modNotes: modNotes.count,
memberLevels: memberLevels.count,
memberEconomies: memberEconomies.count,
inventoryItems: inventoryItems.count,
birthdays: birthdays.count,
afkStatuses: afkStatuses.count,
reminders: reminders.count,
inviteStats: inviteStats.count + inviteJoins.count,
casesAnonymized: casesAnonymized.count,
userPremium: userPremium.count,
userRecord
};
await prisma.gdprDeletionLog.create({
data: {
userId,
guildId,
summary
}
});
return summary;
}

View File

@@ -11,6 +11,7 @@ import { GuildBackupPayloadSchema, type GuildBackupPayload } from '@nexumi/share
import type { GuildBackup } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
import { guildBackupQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
@@ -46,6 +47,18 @@ export async function createGuildBackup(
await ensureGuild(context.prisma, guild.id);
const currentCount = await context.prisma.guildBackup.count({ where: { guildId: guild.id } });
try {
await assertPremiumLimit(context.prisma, guild.id, 'backups', currentCount);
} catch (error) {
if (error instanceof PremiumLimitError) {
throw new GuildBackupError(
error.code === 'feature_disabled' ? 'premium_feature' : 'premium_limit'
);
}
throw error;
}
return context.prisma.guildBackup.create({
data: {
guildId: guild.id,

View File

@@ -8,6 +8,7 @@ import {
import type { Tag } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import type { BotContext } from '../../types.js';
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
export class TagError extends Error {
constructor(public readonly code: string) {
@@ -153,6 +154,16 @@ export async function createTag(
throw new TagError('embed_required');
}
const currentCount = await context.prisma.tag.count({ where: { guildId: params.guildId } });
try {
await assertPremiumLimit(context.prisma, params.guildId, 'tags', currentCount);
} catch (error) {
if (error instanceof PremiumLimitError) {
throw new TagError(error.code === 'feature_disabled' ? 'premium_feature' : 'premium_limit');
}
throw error;
}
return context.prisma.tag.create({
data: {
guildId: params.guildId,

View File

@@ -30,7 +30,7 @@ import {
import { createPoll } from './poll.js';
import { createReminder, deleteReminder, listReminders } from './reminders.js';
import { setAfk } from './afk.js';
import { getDeletedSnipe, getEditedSnipe } from './snipe.js';
import { getDeletedSnipe, getEditedSnipe, isSnipeEnabled } from './snipe.js';
import {
UtilityError,
buildChannelInfoEmbed,
@@ -373,6 +373,10 @@ const snipeCommand: SlashCommand = {
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
if (!(await isSnipeEnabled(context, interaction.guildId!))) {
await interaction.reply({ content: t(locale, 'utility.snipe.disabled'), ephemeral: true });
return;
}
const result = await getDeletedSnipe(context, interaction.channelId!, locale);
if (!result) {
@@ -392,6 +396,10 @@ const editsnipeCommand: SlashCommand = {
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
if (!(await isSnipeEnabled(context, interaction.guildId!))) {
await interaction.reply({ content: t(locale, 'utility.snipe.disabled'), ephemeral: true });
return;
}
const result = await getEditedSnipe(context, interaction.channelId!, locale);
if (!result) {

View File

@@ -58,6 +58,9 @@ export function registerSnipeEvents(context: BotContext): void {
if (message.author?.bot || !message.guildId || !message.channelId) {
return;
}
if (!(await isSnipeEnabled(context, message.guildId))) {
return;
}
if (!message.content && message.attachments.size === 0) {
return;
}
@@ -68,6 +71,9 @@ export function registerSnipeEvents(context: BotContext): void {
if (oldMessage.author?.bot || !oldMessage.guildId || !oldMessage.channelId) {
return;
}
if (!(await isSnipeEnabled(context, oldMessage.guildId))) {
return;
}
const oldContent = oldMessage.content ?? '';
const newContent = newMessage.content ?? '';
if (!oldContent || oldContent === newContent) {
@@ -77,6 +83,14 @@ export function registerSnipeEvents(context: BotContext): void {
});
}
export async function isSnipeEnabled(context: BotContext, guildId: string): Promise<boolean> {
const settings = await context.prisma.guildSettings.findUnique({
where: { guildId },
select: { snipeEnabled: true }
});
return settings?.snipeEnabled ?? false;
}
export async function getDeletedSnipe(
context: BotContext,
channelId: string,

100
apps/bot/src/premium.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { PrismaClient } from '@prisma/client';
import {
DEFAULT_PREMIUM_TIER_CONFIGS,
featureKeyForResource,
isAssignmentActive,
limitKeyForResource,
parsePremiumFeatures,
PremiumTierSchema,
type PremiumLimitResource,
type PremiumTier,
type PremiumTierConfig
} from '@nexumi/shared';
export class PremiumLimitError extends Error {
constructor(
public readonly code: 'feature_disabled' | 'limit_reached',
public readonly resource: PremiumLimitResource,
public readonly limit: number,
public readonly current: number,
public readonly tier: PremiumTier
) {
super(code);
}
}
function toConfig(row: {
tier: string;
maxTags: number;
maxFeeds: number;
maxBackups: number;
features: unknown;
}): PremiumTierConfig {
return {
tier: PremiumTierSchema.parse(row.tier),
maxTags: row.maxTags,
maxFeeds: row.maxFeeds,
maxBackups: row.maxBackups,
features: parsePremiumFeatures(row.features)
};
}
export async function ensurePremiumTierConfigs(prisma: PrismaClient): Promise<PremiumTierConfig[]> {
const configs: PremiumTierConfig[] = [];
for (const tier of PremiumTierSchema.options) {
const defaults = DEFAULT_PREMIUM_TIER_CONFIGS[tier];
const row = await prisma.premiumTierConfig.upsert({
where: { tier },
create: {
tier,
maxTags: defaults.maxTags,
maxFeeds: defaults.maxFeeds,
maxBackups: defaults.maxBackups,
features: defaults.features
},
update: {}
});
configs.push(toConfig(row));
}
return configs;
}
export async function getPremiumTierConfigs(prisma: PrismaClient): Promise<PremiumTierConfig[]> {
return ensurePremiumTierConfigs(prisma);
}
export async function resolveGuildTier(prisma: PrismaClient, guildId: string): Promise<PremiumTier> {
const assignment = await prisma.guildPremiumAssignment.findUnique({ where: { guildId } });
if (!assignment || !isAssignmentActive(assignment.expiresAt)) {
return 'FREE';
}
const parsed = PremiumTierSchema.safeParse(assignment.tier);
return parsed.success ? parsed.data : 'FREE';
}
export async function resolveGuildPremiumConfig(
prisma: PrismaClient,
guildId: string
): Promise<PremiumTierConfig> {
const tier = await resolveGuildTier(prisma, guildId);
const configs = await ensurePremiumTierConfigs(prisma);
return configs.find((config) => config.tier === tier) ?? DEFAULT_PREMIUM_TIER_CONFIGS[tier];
}
export async function assertPremiumLimit(
prisma: PrismaClient,
guildId: string,
resource: PremiumLimitResource,
currentCount: number
): Promise<PremiumTierConfig> {
const config = await resolveGuildPremiumConfig(prisma, guildId);
const featureKey = featureKeyForResource(resource);
if (!config.features[featureKey]) {
throw new PremiumLimitError('feature_disabled', resource, 0, currentCount, config.tier);
}
const limit = config[limitKeyForResource(resource)];
if (currentCount >= limit) {
throw new PremiumLimitError('limit_reached', resource, limit, currentCount, config.tier);
}
return config;
}

View File

@@ -29,3 +29,5 @@ export const suggestionsQueueName = 'suggestions';
export const suggestionsQueue = new Queue(suggestionsQueueName, { connection: redis });
export const presenceQueueName = 'presence';
export const presenceQueue = new Queue(presenceQueueName, { connection: redis });
export const retentionQueueName = 'retention';
export const retentionQueue = new Queue(retentionQueueName, { connection: redis });