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:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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];
|
||||
|
||||
96
apps/webui/src/lib/premium.ts
Normal file
96
apps/webui/src/lib/premium.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user