diff --git a/apps/bot/prisma/migrations/20260722200000_premium_gdpr/migration.sql b/apps/bot/prisma/migrations/20260722200000_premium_gdpr/migration.sql new file mode 100644 index 0000000..e3f74ac --- /dev/null +++ b/apps/bot/prisma/migrations/20260722200000_premium_gdpr/migration.sql @@ -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 + ); diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index 73464fc..f46f7b6 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -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]) +} + diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts index 8aee716..e221e42 100644 --- a/apps/bot/src/commands.ts +++ b/apps/bot/src/commands.ts @@ -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, diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index 5707e5a..8740b2b 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -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 { removeOnFail: 5 } ); + + await retentionQueue.add( + 'purgeExpiredLogs', + {}, + { + repeat: { pattern: '15 4 * * *' }, + removeOnComplete: 20, + removeOnFail: 20 + } + ); +} + +async function runLogRetentionJob(context: BotContext): Promise { + 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 { diff --git a/apps/bot/src/modules/core/help-catalog.ts b/apps/bot/src/modules/core/help-catalog.ts index 2f210be..85bf0cb 100644 --- a/apps/bot/src/modules/core/help-catalog.ts +++ b/apps/bot/src/modules/core/help-catalog.ts @@ -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: [ diff --git a/apps/bot/src/modules/feeds/service.ts b/apps/bot/src/modules/feeds/service.ts index 59fdbbb..f09327b 100644 --- a/apps/bot/src/modules/feeds/service.ts +++ b/apps/bot/src/modules/feeds/service.ts @@ -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, diff --git a/apps/bot/src/modules/gdpr/command-definitions.ts b/apps/bot/src/modules/gdpr/command-definitions.ts new file mode 100644 index 0000000..24007c8 --- /dev/null +++ b/apps/bot/src/modules/gdpr/command-definitions.ts @@ -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' + ) + ) +); diff --git a/apps/bot/src/modules/gdpr/commands.ts b/apps/bot/src/modules/gdpr/commands.ts new file mode 100644 index 0000000..86ce858 --- /dev/null +++ b/apps/bot/src/modules/gdpr/commands.ts @@ -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]; diff --git a/apps/bot/src/modules/gdpr/index.ts b/apps/bot/src/modules/gdpr/index.ts new file mode 100644 index 0000000..4ab291e --- /dev/null +++ b/apps/bot/src/modules/gdpr/index.ts @@ -0,0 +1,2 @@ +export { gdprCommands } from './commands.js'; +export { deleteUserPersonalData } from './service.js'; diff --git a/apps/bot/src/modules/gdpr/service.ts b/apps/bot/src/modules/gdpr/service.ts new file mode 100644 index 0000000..a2753a3 --- /dev/null +++ b/apps/bot/src/modules/gdpr/service.ts @@ -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 { + 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; +} diff --git a/apps/bot/src/modules/guildbackup/service.ts b/apps/bot/src/modules/guildbackup/service.ts index ce46f2c..60b3065 100644 --- a/apps/bot/src/modules/guildbackup/service.ts +++ b/apps/bot/src/modules/guildbackup/service.ts @@ -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, diff --git a/apps/bot/src/modules/tags/service.ts b/apps/bot/src/modules/tags/service.ts index c0a5da7..e900ab9 100644 --- a/apps/bot/src/modules/tags/service.ts +++ b/apps/bot/src/modules/tags/service.ts @@ -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, diff --git a/apps/bot/src/modules/utility/commands.ts b/apps/bot/src/modules/utility/commands.ts index c36589b..00a05b5 100644 --- a/apps/bot/src/modules/utility/commands.ts +++ b/apps/bot/src/modules/utility/commands.ts @@ -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) { diff --git a/apps/bot/src/modules/utility/snipe.ts b/apps/bot/src/modules/utility/snipe.ts index 74108e3..c3d5224 100644 --- a/apps/bot/src/modules/utility/snipe.ts +++ b/apps/bot/src/modules/utility/snipe.ts @@ -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 { + const settings = await context.prisma.guildSettings.findUnique({ + where: { guildId }, + select: { snipeEnabled: true } + }); + return settings?.snipeEnabled ?? false; +} + export async function getDeletedSnipe( context: BotContext, channelId: string, diff --git a/apps/bot/src/premium.ts b/apps/bot/src/premium.ts new file mode 100644 index 0000000..ce449e2 --- /dev/null +++ b/apps/bot/src/premium.ts @@ -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 { + 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 { + return ensurePremiumTierConfigs(prisma); +} + +export async function resolveGuildTier(prisma: PrismaClient, guildId: string): Promise { + 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 { + 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 { + 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; +} diff --git a/apps/bot/src/queues.ts b/apps/bot/src/queues.ts index 88fe4c4..7d1fb4e 100644 --- a/apps/bot/src/queues.ts +++ b/apps/bot/src/queues.ts @@ -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 }); diff --git a/apps/webui/src/app/api/guilds/[guildId]/feeds/route.ts b/apps/webui/src/app/api/guilds/[guildId]/feeds/route.ts index 3e7e6ef..5900932 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/feeds/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/feeds/route.ts @@ -2,7 +2,7 @@ import { SocialFeedDashboardCreateSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; import { writeDashboardAudit } from '@/lib/audit'; -import { createFeed, listFeeds } from '@/lib/module-configs/feeds'; +import { createFeed, FeedPremiumError, listFeeds } from '@/lib/module-configs/feeds'; import { prisma } from '@/lib/prisma'; interface RouteParams { @@ -39,6 +39,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return NextResponse.json(feed, { status: 201 }); } catch (error) { + if (error instanceof FeedPremiumError) { + return NextResponse.json({ error: error.code }, { status: 403 }); + } return toApiErrorResponse(error); } } diff --git a/apps/webui/src/app/api/guilds/[guildId]/guildbackup/route.ts b/apps/webui/src/app/api/guilds/[guildId]/guildbackup/route.ts index 1edadf1..ad9a9c0 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/guildbackup/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/guildbackup/route.ts @@ -2,7 +2,7 @@ import { GuildBackupCreateDashboardSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; import { writeDashboardAudit } from '@/lib/audit'; -import { createGuildBackupDashboard, listGuildBackupsDashboard } from '@/lib/module-configs/guildbackup'; +import { createGuildBackupDashboard, BackupPremiumError, listGuildBackupsDashboard } from '@/lib/module-configs/guildbackup'; import { prisma } from '@/lib/prisma'; interface RouteParams { @@ -39,6 +39,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return NextResponse.json(backup, { status: 201 }); } catch (error) { + if (error instanceof BackupPremiumError) { + return NextResponse.json({ error: error.code }, { status: 403 }); + } return toApiErrorResponse(error); } } diff --git a/apps/webui/src/app/api/guilds/[guildId]/tags/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tags/route.ts index d7b2dcf..5633e49 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/tags/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/tags/route.ts @@ -2,7 +2,7 @@ import { TagDashboardCreateSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; import { writeDashboardAudit } from '@/lib/audit'; -import { createTag, listTags, TagConflictError } from '@/lib/module-configs/tags'; +import { createTag, listTags, TagConflictError, TagPremiumError } from '@/lib/module-configs/tags'; import { prisma } from '@/lib/prisma'; interface RouteParams { @@ -42,6 +42,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { if (error instanceof TagConflictError) { return NextResponse.json({ error: error.message }, { status: 409 }); } + if (error instanceof TagPremiumError) { + return NextResponse.json({ error: error.code }, { status: 403 }); + } return toApiErrorResponse(error); } } diff --git a/apps/webui/src/app/api/owner/premium/route.ts b/apps/webui/src/app/api/owner/premium/route.ts new file mode 100644 index 0000000..1b9b176 --- /dev/null +++ b/apps/webui/src/app/api/owner/premium/route.ts @@ -0,0 +1,185 @@ +import { NextResponse } from 'next/server'; +import { + GuildPremiumAssignSchema, + PremiumTierConfigPatchSchema, + PremiumTierSchema, + UserPremiumAssignSchema +} from '@nexumi/shared'; +import { toApiErrorResponse } from '@/lib/auth'; +import { writeOwnerAudit } from '@/lib/owner-audit'; +import { requireOwner } from '@/lib/owner-auth'; +import { ensurePremiumTierConfigs } from '@/lib/premium'; +import { prisma } from '@/lib/prisma'; + +export async function GET() { + try { + await requireOwner('VIEWER'); + const [tiers, guilds, users] = await Promise.all([ + ensurePremiumTierConfigs(prisma), + prisma.guildPremiumAssignment.findMany({ orderBy: { updatedAt: 'desc' } }), + prisma.userPremiumAssignment.findMany({ orderBy: { updatedAt: 'desc' } }) + ]); + return NextResponse.json({ + tiers, + guilds: guilds.map((row) => ({ + ...row, + expiresAt: row.expiresAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString() + })), + users: users.map((row) => ({ + ...row, + expiresAt: row.expiresAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString() + })) + }); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function PATCH(request: Request) { + try { + const session = await requireOwner('ADMIN'); + const body = await request.json(); + const tier = PremiumTierSchema.parse(body.tier); + const patch = PremiumTierConfigPatchSchema.parse(body); + await ensurePremiumTierConfigs(prisma); + const before = await prisma.premiumTierConfig.findUniqueOrThrow({ where: { tier } }); + const after = await prisma.premiumTierConfig.update({ + where: { tier }, + data: { + ...(patch.maxTags !== undefined ? { maxTags: patch.maxTags } : {}), + ...(patch.maxFeeds !== undefined ? { maxFeeds: patch.maxFeeds } : {}), + ...(patch.maxBackups !== undefined ? { maxBackups: patch.maxBackups } : {}), + ...(patch.features !== undefined ? { features: patch.features } : {}) + } + }); + await writeOwnerAudit(prisma, { + actorUserId: session.user.id, + action: 'premium.tier.update', + targetType: 'PremiumTierConfig', + targetId: tier, + before, + after + }); + return NextResponse.json(after); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function PUT(request: Request) { + try { + const session = await requireOwner('ADMIN'); + const body = await request.json(); + const kind = body.kind === 'user' ? 'user' : 'guild'; + + if (kind === 'user') { + const input = UserPremiumAssignSchema.parse(body); + const before = await prisma.userPremiumAssignment.findUnique({ where: { userId: input.userId } }); + const after = await prisma.userPremiumAssignment.upsert({ + where: { userId: input.userId }, + create: { + userId: input.userId, + tier: input.tier, + note: input.note ?? null, + assignedById: session.user.id, + expiresAt: input.expiresAt ? new Date(input.expiresAt) : null + }, + update: { + tier: input.tier, + note: input.note ?? null, + assignedById: session.user.id, + expiresAt: input.expiresAt ? new Date(input.expiresAt) : null + } + }); + await writeOwnerAudit(prisma, { + actorUserId: session.user.id, + action: before ? 'premium.user.update' : 'premium.user.assign', + targetType: 'UserPremiumAssignment', + targetId: after.userId, + before, + after + }); + return NextResponse.json(after); + } + + const input = GuildPremiumAssignSchema.parse(body); + await prisma.guild.upsert({ where: { id: input.guildId }, update: {}, create: { id: input.guildId } }); + const before = await prisma.guildPremiumAssignment.findUnique({ where: { guildId: input.guildId } }); + const after = await prisma.guildPremiumAssignment.upsert({ + where: { guildId: input.guildId }, + create: { + guildId: input.guildId, + tier: input.tier, + note: input.note ?? null, + assignedById: session.user.id, + expiresAt: input.expiresAt ? new Date(input.expiresAt) : null + }, + update: { + tier: input.tier, + note: input.note ?? null, + assignedById: session.user.id, + expiresAt: input.expiresAt ? new Date(input.expiresAt) : null + } + }); + await writeOwnerAudit(prisma, { + actorUserId: session.user.id, + action: before ? 'premium.guild.update' : 'premium.guild.assign', + targetType: 'GuildPremiumAssignment', + targetId: after.guildId, + before, + after + }); + return NextResponse.json(after); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function DELETE(request: Request) { + try { + const session = await requireOwner('ADMIN'); + const url = new URL(request.url); + const guildId = url.searchParams.get('guildId'); + const userId = url.searchParams.get('userId'); + + if (guildId) { + const before = await prisma.guildPremiumAssignment.findUnique({ where: { guildId } }); + if (!before) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + await prisma.guildPremiumAssignment.delete({ where: { guildId } }); + await writeOwnerAudit(prisma, { + actorUserId: session.user.id, + action: 'premium.guild.remove', + targetType: 'GuildPremiumAssignment', + targetId: guildId, + before + }); + return NextResponse.json({ ok: true }); + } + + if (userId) { + const before = await prisma.userPremiumAssignment.findUnique({ where: { userId } }); + if (!before) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + await prisma.userPremiumAssignment.delete({ where: { userId } }); + await writeOwnerAudit(prisma, { + actorUserId: session.user.id, + action: 'premium.user.remove', + targetType: 'UserPremiumAssignment', + targetId: userId, + before + }); + return NextResponse.json({ ok: true }); + } + + return NextResponse.json({ error: 'guildId or userId required' }, { status: 400 }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/owner/premium/page.tsx b/apps/webui/src/app/owner/premium/page.tsx new file mode 100644 index 0000000..4575a4b --- /dev/null +++ b/apps/webui/src/app/owner/premium/page.tsx @@ -0,0 +1,40 @@ +import type { PremiumTierConfig } from '@nexumi/shared'; +import { PremiumManager } from '@/components/owner/premium-manager'; +import { getLocale, t } from '@/lib/i18n'; +import { ensurePremiumTierConfigs } from '@/lib/premium'; +import { prisma } from '@/lib/prisma'; + +export default async function OwnerPremiumPage() { + const [locale, tiers, guilds, users] = await Promise.all([ + getLocale(), + ensurePremiumTierConfigs(prisma), + prisma.guildPremiumAssignment.findMany({ orderBy: { updatedAt: 'desc' } }), + prisma.userPremiumAssignment.findMany({ orderBy: { updatedAt: 'desc' } }) + ]); + + return ( +
+
+

{t(locale, 'owner.premium.title')}

+

{t(locale, 'owner.premium.subtitle')}

+
+ ({ + id: row.id, + guildId: row.guildId, + tier: row.tier, + note: row.note, + expiresAt: row.expiresAt?.toISOString() ?? null + }))} + initialUsers={users.map((row) => ({ + id: row.id, + userId: row.userId, + tier: row.tier, + note: row.note, + expiresAt: row.expiresAt?.toISOString() ?? null + }))} + /> +
+ ); +} diff --git a/apps/webui/src/components/modules/logging-form.tsx b/apps/webui/src/components/modules/logging-form.tsx index 4d1e10d..fb2a684 100644 --- a/apps/webui/src/components/modules/logging-form.tsx +++ b/apps/webui/src/components/modules/logging-form.tsx @@ -52,6 +52,7 @@ interface LocalLoggingValue { ignoreBots: boolean; ignoredChannelIdsText: string; ignoredRoleIdsText: string; + retentionDays: number; logChannels: LocalLogChannel[]; } @@ -72,6 +73,7 @@ function toLocal(config: LoggingConfigDashboard): LocalLoggingValue { ignoreBots: config.ignoreBots, ignoredChannelIdsText: toCsv(config.ignoredChannelIds), ignoredRoleIdsText: toCsv(config.ignoredRoleIds), + retentionDays: config.retentionDays, logChannels: config.logChannels.map((mapping, index) => ({ ...mapping, clientKey: `${mapping.eventType}-${index}` })) }; } @@ -82,6 +84,7 @@ function toRemote(value: LocalLoggingValue): LoggingConfigDashboard { ignoreBots: value.ignoreBots, ignoredChannelIds: fromCsv(value.ignoredChannelIdsText), ignoredRoleIds: fromCsv(value.ignoredRoleIdsText), + retentionDays: value.retentionDays, logChannels: value.logChannels.map(({ clientKey: _clientKey, ...rest }) => rest) }; } @@ -160,6 +163,21 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) { />

{t('modulePages.logging.idsHint')}

+
+ + + setValue((prev) => ({ ...prev, retentionDays: Number(event.target.value) || 0 })) + } + /> +

{t('modulePages.logging.retentionHint')}

+
diff --git a/apps/webui/src/components/owner/owner-sidebar.tsx b/apps/webui/src/components/owner/owner-sidebar.tsx index d9d54f3..e2ee9f8 100644 --- a/apps/webui/src/components/owner/owner-sidebar.tsx +++ b/apps/webui/src/components/owner/owner-sidebar.tsx @@ -3,6 +3,7 @@ import { Activity, BookOpen, + Crown, Flag, LayoutDashboard, ListTodo, @@ -22,6 +23,7 @@ const LINKS = [ { href: '/owner/guilds', icon: Server, key: 'guilds' }, { href: '/owner/users', icon: Users, key: 'users' }, { href: '/owner/flags', icon: Flag, key: 'flags' }, + { href: '/owner/premium', icon: Crown, key: 'premium' }, { href: '/owner/presence', icon: Radio, key: 'presence' }, { href: '/owner/team', icon: Users, key: 'team' }, { href: '/owner/jobs', icon: ListTodo, key: 'jobs' }, diff --git a/apps/webui/src/components/owner/premium-manager.tsx b/apps/webui/src/components/owner/premium-manager.tsx new file mode 100644 index 0000000..0c34b7c --- /dev/null +++ b/apps/webui/src/components/owner/premium-manager.tsx @@ -0,0 +1,305 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { toast } from 'sonner'; +import type { PremiumTier, PremiumTierConfig } from '@nexumi/shared'; +import { useTranslations } from '@/components/locale-provider'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +async function readError(response: Response): Promise { + const data = (await response.json().catch(() => null)) as { error?: string } | null; + return data?.error ?? 'Request failed'; +} + +type GuildAssignment = { + id: string; + guildId: string; + tier: string; + note: string | null; + expiresAt: string | null; +}; + +type UserAssignment = { + id: string; + userId: string; + tier: string; + note: string | null; + expiresAt: string | null; +}; + +export function PremiumManager({ + initialTiers, + initialGuilds, + initialUsers +}: { + initialTiers: PremiumTierConfig[]; + initialGuilds: GuildAssignment[]; + initialUsers: UserAssignment[]; +}) { + const t = useTranslations(); + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [tiers, setTiers] = useState(initialTiers); + const [guildId, setGuildId] = useState(''); + const [guildTier, setGuildTier] = useState('PREMIUM'); + const [userId, setUserId] = useState(''); + const [userTier, setUserTier] = useState('PREMIUM'); + + function updateTierLocal(tier: PremiumTier, patch: Partial) { + setTiers((prev) => prev.map((row) => (row.tier === tier ? { ...row, ...patch } : row))); + } + + return ( +
+
+ {tiers.map((tier) => ( + + + {tier.tier} + + +
+
+ + updateTierLocal(tier.tier, { maxTags: Number(e.target.value) || 0 })} + /> +
+
+ + updateTierLocal(tier.tier, { maxFeeds: Number(e.target.value) || 0 })} + /> +
+
+ + updateTierLocal(tier.tier, { maxBackups: Number(e.target.value) || 0 })} + /> +
+
+
+ {(['tags', 'feeds', 'guildbackup'] as const).map((feature) => ( + + ))} +
+ +
+
+ ))} +
+ + + + {t('owner.premium.guildsTitle')} + + +
+ setGuildId(e.target.value)} + /> + + +
+
+ {initialGuilds.length === 0 ? ( +

{t('owner.common.empty')}

+ ) : ( + initialGuilds.map((row) => ( +
+
+

+ {row.guildId} · {row.tier} +

+ {row.expiresAt ? ( +

+ {t('owner.premium.expires')}: {new Date(row.expiresAt).toLocaleString()} +

+ ) : null} +
+ +
+ )) + )} +
+
+
+ + + + {t('owner.premium.usersTitle')} + + +
+ setUserId(e.target.value)} + /> + + +
+
+ {initialUsers.length === 0 ? ( +

{t('owner.common.empty')}

+ ) : ( + initialUsers.map((row) => ( +
+
+

+ {row.userId} · {row.tier} +

+
+ +
+ )) + )} +
+
+
+
+ ); +} diff --git a/apps/webui/src/components/settings/general-settings-form.tsx b/apps/webui/src/components/settings/general-settings-form.tsx index 9300fb3..7c9925d 100644 --- a/apps/webui/src/components/settings/general-settings-form.tsx +++ b/apps/webui/src/components/settings/general-settings-form.tsx @@ -87,6 +87,18 @@ export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSetting onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))} /> + +
+
+ +

{t('settings.general.snipeHint')}

+
+ setValue((prev) => ({ ...prev, snipeEnabled: checked }))} + /> +
)} diff --git a/apps/webui/src/lib/guild-settings.ts b/apps/webui/src/lib/guild-settings.ts index 5d9c8e4..4d9a799 100644 --- a/apps/webui/src/lib/guild-settings.ts +++ b/apps/webui/src/lib/guild-settings.ts @@ -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 }; } diff --git a/apps/webui/src/lib/module-configs/feeds.ts b/apps/webui/src/lib/module-configs/feeds.ts index 7f1f300..aa1cc4e 100644 --- a/apps/webui/src/lib/module-configs/feeds.ts +++ b/apps/webui/src/lib/module-configs/feeds.ts @@ -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 { 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, diff --git a/apps/webui/src/lib/module-configs/guildbackup.ts b/apps/webui/src/lib/module-configs/guildbackup.ts index d0d2f16..cb4666d 100644 --- a/apps/webui/src/lib/module-configs/guildbackup.ts +++ b/apps/webui/src/lib/module-configs/guildbackup.ts @@ -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 { await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); } @@ -39,6 +46,15 @@ export async function createGuildBackupDashboard( createdById: string ): Promise { 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 } diff --git a/apps/webui/src/lib/module-configs/logging.ts b/apps/webui/src/lib/module-configs/logging.ts index c0b1702..f92f783 100644 --- a/apps/webui/src/lib/module-configs/logging.ts +++ b/apps/webui/src/lib/module-configs/logging.ts @@ -21,6 +21,7 @@ export async function getLoggingDashboard(guildId: string): Promise ({ eventType: channel.eventType as LogEventTypeDashboard, channelId: channel.channelId diff --git a/apps/webui/src/lib/module-configs/tags.ts b/apps/webui/src/lib/module-configs/tags.ts index adb9d5d..7fce98c 100644 --- a/apps/webui/src/lib/module-configs/tags.ts +++ b/apps/webui/src/lib/module-configs/tags.ts @@ -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 { 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: { diff --git a/apps/webui/src/lib/owner-jobs.ts b/apps/webui/src/lib/owner-jobs.ts index 2d1f28a..adefce3 100644 --- a/apps/webui/src/lib/owner-jobs.ts +++ b/apps/webui/src/lib/owner-jobs.ts @@ -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]; diff --git a/apps/webui/src/lib/premium.ts b/apps/webui/src/lib/premium.ts new file mode 100644 index 0000000..7a62b20 --- /dev/null +++ b/apps/webui/src/lib/premium.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index 8174ffc..7b808c1 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -162,7 +162,9 @@ "timezoneLabel": "Zeitzone", "timezoneHint": "IANA-Zeitzonen-Bezeichner, z. B. Europe/Berlin oder America/New_York.", "moderationLabel": "Moderations-Modul", - "moderationHint": "Aktiviert Moderations-Commands (Ban, Kick, Warn, Purge, …) auf diesem Server." + "moderationHint": "Aktiviert Moderations-Commands (Ban, Kick, Warn, Purge, …) auf diesem Server.", + "snipeLabel": "Snipe / Editsnipe", + "snipeHint": "Speichert gelöschte/bearbeitete Nachrichten kurzzeitig (standardmäßig aus, Datenschutz)." } }, "modulesPage": { @@ -248,6 +250,8 @@ "ignoredRolesLabel": "Ignorierte Rollen", "idsPlaceholder": "Kommagetrennte IDs, z. B. 123456789012345678, 234567890123456789", "idsHint": "Discord-IDs kommagetrennt eintragen. Entwicklermodus aktivieren, um IDs zu kopieren.", + "retentionLabel": "Aufbewahrung (Tage)", + "retentionHint": "Cases und Dashboard-Audit älter als X Tage löschen. 0 = unbegrenzt.", "channelsTitle": "Event-Kanal-Zuordnung", "channelsDescription": "Lege für jeden Event-Typ einen eigenen Log-Kanal fest.", "channelsEmpty": "Keine Event-Kanal-Zuordnungen konfiguriert.", @@ -604,6 +608,7 @@ "guilds": "Server", "users": "User-Blacklist", "flags": "Feature-Flags", + "premium": "Premium", "presence": "Präsenz", "team": "Team", "jobs": "Jobs", @@ -650,6 +655,23 @@ "keyPlaceholder": "Flag-Key (z. B. new.module)", "rollout": "Rollout %" }, + "premium": { + "title": "Premium", + "subtitle": "Stufen Free/Premium, Limits und manuelle Zuweisung (keine Zahlung).", + "maxTags": "Max. Tags", + "maxFeeds": "Max. Feeds", + "maxBackups": "Max. Backups", + "feature": { + "tags": "Tags", + "feeds": "Feeds", + "guildbackup": "Backups" + }, + "guildsTitle": "Server-Zuweisungen", + "usersTitle": "User-Zuweisungen", + "guildIdPlaceholder": "Discord Server-ID", + "userIdPlaceholder": "Discord User-ID", + "expires": "Läuft ab" + }, "presence": { "title": "Bot-Präsenz", "subtitle": "Status, Aktivität und Wartungsmodus.", diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index b0b46c6..ce282a9 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -162,7 +162,9 @@ "timezoneLabel": "Timezone", "timezoneHint": "IANA timezone identifier, e.g. Europe/Berlin or America/New_York.", "moderationLabel": "Moderation module", - "moderationHint": "Enable moderation commands (ban, kick, warn, purge, …) on this server." + "moderationHint": "Enable moderation commands (ban, kick, warn, purge, …) on this server.", + "snipeLabel": "Snipe / Editsnipe", + "snipeHint": "Temporarily stores deleted/edited messages (off by default for privacy)." } }, "modulesPage": { @@ -248,6 +250,8 @@ "ignoredRolesLabel": "Ignored roles", "idsPlaceholder": "Comma-separated IDs, e.g. 123456789012345678, 234567890123456789", "idsHint": "Enter Discord IDs, comma-separated. Enable Developer Mode to copy IDs.", + "retentionLabel": "Retention (days)", + "retentionHint": "Delete cases and dashboard audit older than X days. 0 = keep forever.", "channelsTitle": "Event-channel mapping", "channelsDescription": "Assign a dedicated log channel for each event type.", "channelsEmpty": "No event-channel mappings configured.", @@ -604,6 +608,7 @@ "guilds": "Guilds", "users": "User blacklist", "flags": "Feature flags", + "premium": "Premium", "presence": "Presence", "team": "Team", "jobs": "Jobs", @@ -650,6 +655,23 @@ "keyPlaceholder": "Flag key (e.g. new.module)", "rollout": "Rollout %" }, + "premium": { + "title": "Premium", + "subtitle": "Free/Premium tiers, limits, and manual assignment (no payments).", + "maxTags": "Max tags", + "maxFeeds": "Max feeds", + "maxBackups": "Max backups", + "feature": { + "tags": "Tags", + "feeds": "Feeds", + "guildbackup": "Backups" + }, + "guildsTitle": "Guild assignments", + "usersTitle": "User assignments", + "guildIdPlaceholder": "Discord guild ID", + "userIdPlaceholder": "Discord user ID", + "expires": "Expires" + }, "presence": { "title": "Bot presence", "subtitle": "Status, activity, and maintenance mode.", diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 03e6d2f..64965ce 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -42,6 +42,16 @@ docker compose run --rm bot pnpm prisma:migrate pnpm prisma:generate ``` +## Premium & DSGVO + +- `PremiumTierConfig` (FREE/PREMIUM Limits + Feature-Flags) +- `GuildPremiumAssignment` / `UserPremiumAssignment` +- `GdprDeletionLog` (Protokoll der `/gdpr delete`-Läufe) +- `GuildSettings.snipeEnabled` (Default `false`) +- `LoggingConfig.retentionDays` (Default `90`, `0` = unbegrenzt) + +Migration: `20260722200000_premium_gdpr` + ## Backup (automatisch) - Job: BullMQ `dailyPgDump` diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 6784d7c..a81050a 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -13,32 +13,37 @@ - BullMQ WebUI↔Bot, Presence-/Wartungsmodus-Anbindung - Manuell bestätigt (User-Freigabe) -## Phase 8 – Landing, Status, Rechtsseiten (Status: implementiert, manuelle Tests offen) +## Phase 8 – Landing, Status, Rechtsseiten (Status: abgeschlossen, manuell bestätigt) + +### Abgeschlossen + +- Landing `/`, Status `/status`, Rechtsseiten, Public APIs, Core-Commands, `docs/verification.md` +- Manuell bestätigt (User-Freigabe) + +## Post-Phase – Premium + DSGVO (Status: implementiert, manuelle Tests offen) ### Abgeschlossen (Code) -- Landing `/` (Features, Invite, Support, Live-Stats, Login/Dashboard) -- Status `/status` (Shards/Latenz/Uptime aus Redis `bot:status`, Changelog/Incidents) -- Rechtsseiten-Gerüst `/impressum`, `/privacy`, `/terms` (DE+EN i18n; Impressum aus Env) -- Public APIs `GET /api/public/stats`, `GET /api/public/status` -- Bot schreibt `bot:status` (Presence-Job + Ready); Core-Commands `/help`, `/info`, `/invite`, `/support` -- `docs/verification.md` (Privileged Intents) -- Env: `SUPPORT_SERVER_URL`, `LEGAL_OPERATOR_*` (WebUI), `WEBUI_URL`/`SUPPORT_SERVER_URL` (Bot) +- Premium: `PremiumTierConfig`, Guild-/User-Zuweisung, Owner-UI `/owner/premium` +- Limits enforced (Tags/Feeds/Backups) in Bot + WebUI +- DSGVO: `/privacy`, `/gdpr delete confirm:true` (Löschung/Anonymisierung + `GdprDeletionLog`) +- Log-Retention: `LoggingConfig.retentionDays` + täglicher Job `retention`/`purgeExpiredLogs` +- Snipe standardmäßig aus (`GuildSettings.snipeEnabled`, Toggle in Allgemeine Einstellungen) +- Migration `20260722200000_premium_gdpr` ### Manuell testen -- [ ] Landing unter `WEBUI_URL` (nicht localhost): Stats, Invite, Login -- [ ] `/status` zeigt Shards nach Bot-Start (sonst „degraded“ bis Redis-Status da ist) -- [ ] Impressum: mit/ohne `LEGAL_OPERATOR_*` in `.env` -- [ ] Privacy/Terms in DE und EN (Locale-Cookie) -- [ ] Discord: `/help`, `/info`, `/invite`, `/support` -- [ ] Support-Link nur sichtbar, wenn `SUPPORT_SERVER_URL` gesetzt +- [ ] Owner `/owner/premium`: Limits speichern, Guild PREMIUM zuweisen +- [ ] Free-Limit: mehr Tags/Feeds/Backups als Free-Cap → Fehler +- [ ] `/privacy` und `/gdpr delete confirm:true` +- [ ] Logging-Retention im Dashboard setzen +- [ ] Snipe: default aus; nach Aktivierung in Einstellungen nutzbar -### Bewusst offen (SPEC, nicht Phase 8) +### Bewusst offen - Musik- und KI-Modul (nur auf Zuruf) -- Premium-Zahlungsanbindung (manuelle Zuweisung bleibt Owner-Panel) +- Premium-Zahlungsanbindung ## Nächster geplanter Schritt -- Nach manueller Freigabe Phase 8: auf Zuruf Musik/KI oder andere SPEC-Restpunkte. +- Nach manueller Freigabe: Musik/KI auf Zuruf. diff --git a/packages/shared/src/command-locales.ts b/packages/shared/src/command-locales.ts index 3bb52e7..34eb3a7 100644 --- a/packages/shared/src/command-locales.ts +++ b/packages/shared/src/command-locales.ts @@ -1418,6 +1418,22 @@ export const commandLocales = { 'core.support.description': { de: 'Link zum Support-Server', en: 'Link to the support server' + }, + 'gdpr.privacy.description': { + de: 'Datenschutzhinweis und Link zur Datenschutzerklärung', + en: 'Privacy notice and link to the privacy policy' + }, + 'gdpr.command.description': { + de: 'DSGVO-Datenanfragen', + en: 'GDPR data requests' + }, + 'gdpr.delete.description': { + de: 'Persönliche Bot-Daten löschen / anonymisieren', + en: 'Delete / anonymize your personal bot data' + }, + 'gdpr.delete.options.confirm': { + de: 'Auf true setzen zur Bestätigung', + en: 'Set to true to confirm' } } as const; diff --git a/packages/shared/src/dashboard.ts b/packages/shared/src/dashboard.ts index 7a7bff2..29efc1c 100644 --- a/packages/shared/src/dashboard.ts +++ b/packages/shared/src/dashboard.ts @@ -162,6 +162,7 @@ export const LoggingConfigDashboardSchema = z.object({ ignoreBots: z.boolean(), ignoredChannelIds: z.array(z.string()), ignoredRoleIds: z.array(z.string()), + retentionDays: z.number().int().min(0).max(3650), logChannels: z.array(LogChannelMappingSchema) }); export type LoggingConfigDashboard = z.infer; @@ -172,6 +173,7 @@ export const LoggingConfigDashboardPatchSchema = z ignoreBots: z.boolean().optional(), ignoredChannelIds: z.array(z.string()).optional(), ignoredRoleIds: z.array(z.string()).optional(), + retentionDays: z.number().int().min(0).max(3650).optional(), logChannels: z.array(LogChannelMappingSchema).optional() }) .refine((value) => Object.keys(value).length > 0, { message: 'At least one field is required' }); @@ -632,6 +634,7 @@ export const KNOWN_COMMAND_NAMES = [ 'reminders', 'afk', 'emoji', 'sticker', 'timestamp', 'snipe', 'editsnipe', 'embed', '8ball', 'dice', 'flip', 'rps', 'choose', 'trivia', 'tictactoe', 'connect4', 'hangman', 'meme', 'cat', 'dog', 'giveaway', 'ticket', 'selfroles', 'tag', 'starboard', 'suggest', 'suggestion', 'birthday', 'voice', - 'stats', 'invites', 'feeds', 'schedule', 'announce', 'backup' + 'stats', 'invites', 'feeds', 'schedule', 'announce', 'backup', 'help', 'info', 'invite', 'support', + 'privacy', 'gdpr' ] as const; export type KnownCommandName = (typeof KNOWN_COMMAND_NAMES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dac357e..f415974 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -11,6 +11,7 @@ export * from './phase6.js'; export * from './dashboard.js'; export * from './owner.js'; export * from './public.js'; +export * from './premium.js'; export const LocaleSchema = z.enum(['de', 'en']); export type Locale = z.infer; @@ -95,6 +96,15 @@ const de: Dictionary = { 'core.module.feeds': 'Feeds', 'core.module.scheduler': 'Scheduler', 'core.module.guildbackup': 'Backup', + 'gdpr.privacy.title': 'Datenschutz', + 'gdpr.privacy.body': + 'Nexumi speichert Discord-IDs und modulbezogene Daten (z. B. Verwarnungen, XP, Economy), soweit für den Betrieb nötig.', + 'gdpr.privacy.link': 'Vollständige Datenschutzerklärung: {url}', + 'gdpr.privacy.deleteHint': 'Persönliche Daten löschen: `/gdpr delete confirm:true`', + 'gdpr.delete.needConfirm': 'Setze `confirm` auf `true`, um die Löschung zu bestätigen.', + 'gdpr.delete.doneTitle': 'Daten gelöscht', + 'gdpr.delete.doneBody': + 'Gelöscht/anonymisiert: {warnings} Verwarnungen, {levels} Level-Einträge, {economy} Economy-Einträge, {cases} Cases anonymisiert.', 'moderation.success': 'Aktion erfolgreich ausgeführt.', 'moderation.reason.none': 'Kein Grund angegeben', 'moderation.warning.created': 'Verwarnung erstellt: {warningId}', @@ -239,6 +249,7 @@ const de: Dictionary = { 'utility.embed.sent': 'Embed gesendet.', 'utility.embed.invalidColor': 'Ungültige Farbe. Nutze z. B. 6366F1.', 'utility.snipe.none': 'Nichts zum Anzeigen.', + 'utility.snipe.disabled': 'Snipe ist auf diesem Server deaktiviert (Datenschutz).', 'utility.snipe.privacy': 'Datenschutz: Snipe speichert gelöschte/bearbeitete Nachrichten kurzzeitig (1 h) in Redis.', 'utility.snipe.deletedAt': 'Gelöschte Nachricht von {user}', @@ -498,6 +509,8 @@ const de: Dictionary = { 'tag.error.invalid_name': 'Ungültiger Tag-Name (1–32 Zeichen, a-z, 0-9, _, -).', 'tag.error.content_required': 'Text-Tags benötigen Inhalt.', 'tag.error.embed_required': 'Embed-Tags benötigen Titel oder Beschreibung.', + 'tag.error.premium_limit': 'Tag-Limit für diese Premium-Stufe erreicht.', + 'tag.error.premium_feature': 'Tags sind für diese Premium-Stufe deaktiviert.', 'guildbackup.description': 'Server-Backups erstellen und wiederherstellen', 'guildbackup.created': 'Backup **{name}** erstellt (ID: `{id}`).', 'guildbackup.deleted': 'Backup `{id}` gelöscht.', @@ -532,6 +545,8 @@ const de: Dictionary = { 'guildbackup.error.owner_only': 'Nur der Server-Owner kann Backups wiederherstellen.', 'guildbackup.error.bot_missing_permissions': 'Dem Bot fehlen Manage Roles oder Manage Channels für einen Restore.', + 'guildbackup.error.premium_limit': 'Backup-Limit für diese Premium-Stufe erreicht.', + 'guildbackup.error.premium_feature': 'Backups sind für diese Premium-Stufe deaktiviert.', 'feeds.added': 'Feed `{id}` ({type}) hinzugefügt.', 'feeds.removed': 'Feed `{id}` entfernt.', 'feeds.list.header': '**Social Feeds**', @@ -544,6 +559,8 @@ const de: Dictionary = { 'feeds.test.twitchSkipped': 'Twitch-Feed übersprungen: TWITCH_CLIENT_ID und TWITCH_CLIENT_SECRET fehlen in der Bot-Konfiguration.', 'feeds.error.not_found': 'Feed nicht gefunden.', + 'feeds.error.premium_limit': 'Feed-Limit für diese Premium-Stufe erreicht.', + 'feeds.error.premium_feature': 'Feeds sind für diese Premium-Stufe deaktiviert.', 'scheduler.created': 'Geplante Nachricht erstellt (ID: `{id}`). {timing}', 'scheduler.created.at': 'Sendung: {time}', 'scheduler.created.cron': 'Wiederholung: `{cron}`', @@ -612,6 +629,15 @@ const en: Dictionary = { 'core.module.feeds': 'Feeds', 'core.module.scheduler': 'Scheduler', 'core.module.guildbackup': 'Backup', + 'gdpr.privacy.title': 'Privacy', + 'gdpr.privacy.body': + 'Nexumi stores Discord IDs and module-related data (e.g. warnings, XP, economy) as needed to operate.', + 'gdpr.privacy.link': 'Full privacy policy: {url}', + 'gdpr.privacy.deleteHint': 'Delete personal data: `/gdpr delete confirm:true`', + 'gdpr.delete.needConfirm': 'Set `confirm` to `true` to confirm deletion.', + 'gdpr.delete.doneTitle': 'Data deleted', + 'gdpr.delete.doneBody': + 'Deleted/anonymized: {warnings} warnings, {levels} level rows, {economy} economy rows, {cases} cases anonymized.', 'moderation.success': 'Action executed successfully.', 'moderation.reason.none': 'No reason provided', 'moderation.warning.created': 'Warning created: {warningId}', @@ -755,6 +781,7 @@ const en: Dictionary = { 'utility.embed.sent': 'Embed sent.', 'utility.embed.invalidColor': 'Invalid color. Use e.g. 6366F1.', 'utility.snipe.none': 'Nothing to show.', + 'utility.snipe.disabled': 'Snipe is disabled on this server (privacy).', 'utility.snipe.privacy': 'Privacy: Snipe stores deleted/edited messages briefly (1 h) in Redis.', 'utility.snipe.deletedAt': 'Deleted message by {user}', @@ -1014,6 +1041,8 @@ const en: Dictionary = { 'tag.error.invalid_name': 'Invalid tag name (1–32 chars, a-z, 0-9, _, -).', 'tag.error.content_required': 'Text tags require content.', 'tag.error.embed_required': 'Embed tags require a title or description.', + 'tag.error.premium_limit': 'Tag limit reached for this premium tier.', + 'tag.error.premium_feature': 'Tags are disabled for this premium tier.', 'guildbackup.description': 'Create and restore server backups', 'guildbackup.created': 'Created backup **{name}** (ID: `{id}`).', 'guildbackup.deleted': 'Deleted backup `{id}`.', @@ -1048,6 +1077,8 @@ const en: Dictionary = { 'guildbackup.error.owner_only': 'Only the server owner can restore backups.', 'guildbackup.error.bot_missing_permissions': 'The bot is missing Manage Roles or Manage Channels for a restore.', + 'guildbackup.error.premium_limit': 'Backup limit reached for this premium tier.', + 'guildbackup.error.premium_feature': 'Backups are disabled for this premium tier.', 'feeds.added': 'Added feed `{id}` ({type}).', 'feeds.removed': 'Removed feed `{id}`.', 'feeds.list.header': '**Social Feeds**', @@ -1060,6 +1091,8 @@ const en: Dictionary = { 'feeds.test.twitchSkipped': 'Twitch feed skipped: TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET are missing in the bot configuration.', 'feeds.error.not_found': 'Feed not found.', + 'feeds.error.premium_limit': 'Feed limit reached for this premium tier.', + 'feeds.error.premium_feature': 'Feeds are disabled for this premium tier.', 'scheduler.created': 'Scheduled message created (ID: `{id}`). {timing}', 'scheduler.created.at': 'Sends at: {time}', 'scheduler.created.cron': 'Repeats: `{cron}`', diff --git a/packages/shared/src/phase6.ts b/packages/shared/src/phase6.ts index 09785cc..62b16b8 100644 --- a/packages/shared/src/phase6.ts +++ b/packages/shared/src/phase6.ts @@ -87,7 +87,8 @@ export type DashboardGuild = z.infer; export const GuildGeneralSettingsSchema = z.object({ locale: LocaleSchema, timezone: z.string().min(1).max(64), - moderationEnabled: z.boolean() + moderationEnabled: z.boolean(), + snipeEnabled: z.boolean() }); export type GuildGeneralSettings = z.infer; diff --git a/packages/shared/src/premium.test.ts b/packages/shared/src/premium.test.ts new file mode 100644 index 0000000..744cffc --- /dev/null +++ b/packages/shared/src/premium.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_PREMIUM_TIER_CONFIGS, + GuildPremiumAssignSchema, + isAssignmentActive, + limitKeyForResource, + PremiumTierConfigSchema +} from './premium.js'; + +describe('premium schemas', () => { + it('parses default free config', () => { + const parsed = PremiumTierConfigSchema.parse(DEFAULT_PREMIUM_TIER_CONFIGS.FREE); + expect(parsed.maxTags).toBe(15); + expect(parsed.features.feeds).toBe(true); + }); + + it('maps resources to limit keys', () => { + expect(limitKeyForResource('tags')).toBe('maxTags'); + expect(limitKeyForResource('backups')).toBe('maxBackups'); + }); + + it('validates guild assignment and expiry', () => { + expect( + GuildPremiumAssignSchema.parse({ + guildId: '123456789012345678', + tier: 'PREMIUM', + note: null, + expiresAt: null + }).tier + ).toBe('PREMIUM'); + expect(isAssignmentActive(null)).toBe(true); + expect(isAssignmentActive(new Date(Date.now() - 1000))).toBe(false); + expect(isAssignmentActive(new Date(Date.now() + 60_000))).toBe(true); + }); +}); diff --git a/packages/shared/src/premium.ts b/packages/shared/src/premium.ts new file mode 100644 index 0000000..0b4f4c8 --- /dev/null +++ b/packages/shared/src/premium.ts @@ -0,0 +1,94 @@ +import { z } from 'zod'; + +const snowflake = z.string().regex(/^\d{17,20}$/); + +export const PremiumTierSchema = z.enum(['FREE', 'PREMIUM']); +export type PremiumTier = z.infer; + +export const PremiumFeatureKeySchema = z.enum(['tags', 'feeds', 'guildbackup']); +export type PremiumFeatureKey = z.infer; + +export const PremiumFeaturesSchema = z.object({ + tags: z.boolean(), + feeds: z.boolean(), + guildbackup: z.boolean() +}); +export type PremiumFeatures = z.infer; + +export const PremiumTierConfigSchema = z.object({ + tier: PremiumTierSchema, + maxTags: z.number().int().min(0).max(10_000), + maxFeeds: z.number().int().min(0).max(10_000), + maxBackups: z.number().int().min(0).max(10_000), + features: PremiumFeaturesSchema +}); +export type PremiumTierConfig = z.infer; + +export const PremiumTierConfigPatchSchema = PremiumTierConfigSchema.omit({ tier: true }).partial().refine( + (value) => Object.keys(value).length > 0, + { message: 'At least one field is required' } +); +export type PremiumTierConfigPatch = z.infer; + +export const DEFAULT_PREMIUM_TIER_CONFIGS: Record = { + FREE: { + tier: 'FREE', + maxTags: 15, + maxFeeds: 3, + maxBackups: 2, + features: { tags: true, feeds: true, guildbackup: true } + }, + PREMIUM: { + tier: 'PREMIUM', + maxTags: 100, + maxFeeds: 25, + maxBackups: 25, + features: { tags: true, feeds: true, guildbackup: true } + } +}; + +export const GuildPremiumAssignSchema = z.object({ + guildId: snowflake, + tier: PremiumTierSchema, + note: z.string().max(500).nullable().optional(), + expiresAt: z.string().datetime().nullable().optional() +}); +export type GuildPremiumAssign = z.infer; + +export const UserPremiumAssignSchema = z.object({ + userId: snowflake, + tier: PremiumTierSchema, + note: z.string().max(500).nullable().optional(), + expiresAt: z.string().datetime().nullable().optional() +}); +export type UserPremiumAssign = z.infer; + +export const PremiumLimitResourceSchema = z.enum(['tags', 'feeds', 'backups']); +export type PremiumLimitResource = z.infer; + +export function limitKeyForResource(resource: PremiumLimitResource): 'maxTags' | 'maxFeeds' | 'maxBackups' { + if (resource === 'tags') return 'maxTags'; + if (resource === 'feeds') return 'maxFeeds'; + return 'maxBackups'; +} + +export function featureKeyForResource(resource: PremiumLimitResource): PremiumFeatureKey { + if (resource === 'backups') return 'guildbackup'; + return resource; +} + +export function parsePremiumFeatures(raw: unknown): PremiumFeatures { + const parsed = PremiumFeaturesSchema.safeParse(raw); + if (parsed.success) { + return parsed.data; + } + return { tags: true, feeds: true, guildbackup: true }; +} + +export function isAssignmentActive(expiresAt: Date | string | null | undefined, now = new Date()): boolean { + if (!expiresAt) { + return true; + } + const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt; + return date.getTime() > now.getTime(); +}