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

@@ -15,11 +15,17 @@ async function ensureGuildSettings(guildId: string) {
});
}
function toGeneralSettings(settings: { locale: string; timezone: string; moderationEnabled: boolean }): GuildGeneralSettings {
function toGeneralSettings(settings: {
locale: string;
timezone: string;
moderationEnabled: boolean;
snipeEnabled: boolean;
}): GuildGeneralSettings {
return {
locale: settings.locale === 'en' ? 'en' : 'de',
timezone: settings.timezone,
moderationEnabled: settings.moderationEnabled
moderationEnabled: settings.moderationEnabled,
snipeEnabled: settings.snipeEnabled
};
}

View File

@@ -5,6 +5,13 @@ import type {
} from '@nexumi/shared';
import type { SocialFeed } from '@prisma/client';
import { prisma } from '../prisma';
import { assertPremiumLimit, PremiumLimitError } from '../premium';
export class FeedPremiumError extends Error {
constructor(public readonly code: 'premium_limit' | 'premium_feature') {
super(code);
}
}
function toDashboard(feed: SocialFeed): SocialFeedDashboard {
return {
@@ -31,6 +38,15 @@ export async function createFeed(
input: SocialFeedDashboardCreate
): Promise<SocialFeedDashboard> {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
const currentCount = await prisma.socialFeed.count({ where: { guildId } });
try {
await assertPremiumLimit(prisma, guildId, 'feeds', currentCount);
} catch (error) {
if (error instanceof PremiumLimitError) {
throw new FeedPremiumError(error.code === 'feature_disabled' ? 'premium_feature' : 'premium_limit');
}
throw error;
}
const feed = await prisma.socialFeed.create({
data: {
guildId,

View File

@@ -2,8 +2,15 @@ import { GuildBackupPayloadSchema, type GuildBackupDashboard } from '@nexumi/sha
import type { GuildBackup } from '@prisma/client';
import { buildGuildBackupPayloadViaRest } from '../guild-backup';
import { prisma } from '../prisma';
import { assertPremiumLimit, PremiumLimitError } from '../premium';
import { guildBackupQueue, guildBackupQueueName } from '../queues';
export class BackupPremiumError extends Error {
constructor(public readonly code: 'premium_limit' | 'premium_feature') {
super(code);
}
}
async function ensureGuild(guildId: string): Promise<void> {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
}
@@ -39,6 +46,15 @@ export async function createGuildBackupDashboard(
createdById: string
): Promise<GuildBackupDashboard> {
await ensureGuild(guildId);
const currentCount = await prisma.guildBackup.count({ where: { guildId } });
try {
await assertPremiumLimit(prisma, guildId, 'backups', currentCount);
} catch (error) {
if (error instanceof PremiumLimitError) {
throw new BackupPremiumError(error.code === 'feature_disabled' ? 'premium_feature' : 'premium_limit');
}
throw error;
}
const payload = await buildGuildBackupPayloadViaRest(guildId);
const backup = await prisma.guildBackup.create({
data: { guildId, name, createdById, payload }

View File

@@ -21,6 +21,7 @@ export async function getLoggingDashboard(guildId: string): Promise<LoggingConfi
ignoreBots: config.ignoreBots,
ignoredChannelIds: config.ignoredChannelIds,
ignoredRoleIds: config.ignoredRoleIds,
retentionDays: config.retentionDays,
logChannels: channels.map((channel) => ({
eventType: channel.eventType as LogEventTypeDashboard,
channelId: channel.channelId

View File

@@ -1,6 +1,7 @@
import type { TagDashboard, TagDashboardCreate, TagDashboardUpdate } from '@nexumi/shared';
import type { Tag } from '@prisma/client';
import { prisma } from '../prisma';
import { assertPremiumLimit, PremiumLimitError } from '../premium';
export class TagConflictError extends Error {
constructor() {
@@ -8,6 +9,12 @@ export class TagConflictError extends Error {
}
}
export class TagPremiumError extends Error {
constructor(public readonly code: 'premium_limit' | 'premium_feature') {
super(code);
}
}
function toDashboard(tag: Tag): TagDashboard {
return {
id: tag.id,
@@ -31,6 +38,15 @@ export async function createTag(
input: TagDashboardCreate
): Promise<TagDashboard> {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
const currentCount = await prisma.tag.count({ where: { guildId } });
try {
await assertPremiumLimit(prisma, guildId, 'tags', currentCount);
} catch (error) {
if (error instanceof PremiumLimitError) {
throw new TagPremiumError(error.code === 'feature_disabled' ? 'premium_feature' : 'premium_limit');
}
throw error;
}
try {
const tag = await prisma.tag.create({
data: {

View File

@@ -15,7 +15,8 @@ export const OWNER_QUEUE_NAMES = [
'schedules',
'guild-backups',
'suggestions',
'presence'
'presence',
'retention'
] as const;
export type OwnerQueueName = (typeof OWNER_QUEUE_NAMES)[number];

View File

@@ -0,0 +1,96 @@
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 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;
}