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 });

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'owner.premium.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'owner.premium.subtitle')}</p>
</div>
<PremiumManager
initialTiers={tiers as PremiumTierConfig[]}
initialGuilds={guilds.map((row) => ({
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
}))}
/>
</div>
);
}

View File

@@ -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) {
/>
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="logging-retention">{t('modulePages.logging.retentionLabel')}</Label>
<Input
id="logging-retention"
type="number"
min={0}
max={3650}
className="w-40"
value={value.retentionDays}
onChange={(event) =>
setValue((prev) => ({ ...prev, retentionDays: Number(event.target.value) || 0 }))
}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.logging.retentionHint')}</p>
</div>
</CardContent>
</Card>

View File

@@ -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' },

View File

@@ -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<string> {
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<PremiumTier>('PREMIUM');
const [userId, setUserId] = useState('');
const [userTier, setUserTier] = useState<PremiumTier>('PREMIUM');
function updateTierLocal(tier: PremiumTier, patch: Partial<PremiumTierConfig>) {
setTiers((prev) => prev.map((row) => (row.tier === tier ? { ...row, ...patch } : row)));
}
return (
<div className="space-y-6">
<div className="grid gap-4 lg:grid-cols-2">
{tiers.map((tier) => (
<Card key={tier.tier}>
<CardHeader>
<CardTitle>{tier.tier}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1">
<Label>{t('owner.premium.maxTags')}</Label>
<Input
type="number"
min={0}
value={tier.maxTags}
onChange={(e) => updateTierLocal(tier.tier, { maxTags: Number(e.target.value) || 0 })}
/>
</div>
<div className="space-y-1">
<Label>{t('owner.premium.maxFeeds')}</Label>
<Input
type="number"
min={0}
value={tier.maxFeeds}
onChange={(e) => updateTierLocal(tier.tier, { maxFeeds: Number(e.target.value) || 0 })}
/>
</div>
<div className="space-y-1">
<Label>{t('owner.premium.maxBackups')}</Label>
<Input
type="number"
min={0}
value={tier.maxBackups}
onChange={(e) => updateTierLocal(tier.tier, { maxBackups: Number(e.target.value) || 0 })}
/>
</div>
</div>
<div className="flex flex-wrap gap-4 text-sm">
{(['tags', 'feeds', 'guildbackup'] as const).map((feature) => (
<label key={feature} className="flex items-center gap-2">
<input
type="checkbox"
checked={tier.features[feature]}
onChange={(e) =>
updateTierLocal(tier.tier, {
features: { ...tier.features, [feature]: e.target.checked }
})
}
/>
{t(`owner.premium.feature.${feature}`)}
</label>
))}
</div>
<Button
disabled={pending}
onClick={() =>
startTransition(async () => {
const response = await fetch('/api/owner/premium', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tier: tier.tier,
maxTags: tier.maxTags,
maxFeeds: tier.maxFeeds,
maxBackups: tier.maxBackups,
features: tier.features
})
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.save')}
</Button>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>{t('owner.premium.guildsTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<Input
placeholder={t('owner.premium.guildIdPlaceholder')}
value={guildId}
onChange={(e) => setGuildId(e.target.value)}
/>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={guildTier}
onChange={(e) => setGuildTier(e.target.value as PremiumTier)}
>
<option value="FREE">FREE</option>
<option value="PREMIUM">PREMIUM</option>
</select>
<Button
disabled={pending || !guildId.trim()}
onClick={() =>
startTransition(async () => {
const response = await fetch('/api/owner/premium', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'guild', guildId: guildId.trim(), tier: guildTier, note: null })
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
setGuildId('');
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.add')}
</Button>
</div>
<div className="divide-y divide-border rounded-md border border-border">
{initialGuilds.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">{t('owner.common.empty')}</p>
) : (
initialGuilds.map((row) => (
<div key={row.id} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium">
{row.guildId} · {row.tier}
</p>
{row.expiresAt ? (
<p className="text-muted-foreground">
{t('owner.premium.expires')}: {new Date(row.expiresAt).toLocaleString()}
</p>
) : null}
</div>
<Button
size="sm"
variant="destructive"
disabled={pending}
onClick={() =>
startTransition(async () => {
const response = await fetch(`/api/owner/premium?guildId=${row.guildId}`, {
method: 'DELETE'
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.remove')}
</Button>
</div>
))
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('owner.premium.usersTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<Input
placeholder={t('owner.premium.userIdPlaceholder')}
value={userId}
onChange={(e) => setUserId(e.target.value)}
/>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={userTier}
onChange={(e) => setUserTier(e.target.value as PremiumTier)}
>
<option value="FREE">FREE</option>
<option value="PREMIUM">PREMIUM</option>
</select>
<Button
disabled={pending || !userId.trim()}
onClick={() =>
startTransition(async () => {
const response = await fetch('/api/owner/premium', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'user', userId: userId.trim(), tier: userTier, note: null })
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
setUserId('');
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.add')}
</Button>
</div>
<div className="divide-y divide-border rounded-md border border-border">
{initialUsers.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">{t('owner.common.empty')}</p>
) : (
initialUsers.map((row) => (
<div key={row.id} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium">
{row.userId} · {row.tier}
</p>
</div>
<Button
size="sm"
variant="destructive"
disabled={pending}
onClick={() =>
startTransition(async () => {
const response = await fetch(`/api/owner/premium?userId=${row.userId}`, {
method: 'DELETE'
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.remove')}
</Button>
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -87,6 +87,18 @@ export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSetting
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
/>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label htmlFor="general-snipe">{t('settings.general.snipeLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('settings.general.snipeHint')}</p>
</div>
<Switch
id="general-snipe"
checked={value.snipeEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, snipeEnabled: checked }))}
/>
</div>
</CardContent>
</Card>
)}

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;
}

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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`

View File

@@ -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.

View File

@@ -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;

View File

@@ -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<typeof LoggingConfigDashboardSchema>;
@@ -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];

View File

@@ -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<typeof LocaleSchema>;
@@ -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 (132 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 (132 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}`',

View File

@@ -87,7 +87,8 @@ export type DashboardGuild = z.infer<typeof DashboardGuildSchema>;
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<typeof GuildGeneralSettingsSchema>;

View File

@@ -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);
});
});

View File

@@ -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<typeof PremiumTierSchema>;
export const PremiumFeatureKeySchema = z.enum(['tags', 'feeds', 'guildbackup']);
export type PremiumFeatureKey = z.infer<typeof PremiumFeatureKeySchema>;
export const PremiumFeaturesSchema = z.object({
tags: z.boolean(),
feeds: z.boolean(),
guildbackup: z.boolean()
});
export type PremiumFeatures = z.infer<typeof PremiumFeaturesSchema>;
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<typeof PremiumTierConfigSchema>;
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<typeof PremiumTierConfigPatchSchema>;
export const DEFAULT_PREMIUM_TIER_CONFIGS: Record<PremiumTier, PremiumTierConfig> = {
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<typeof GuildPremiumAssignSchema>;
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<typeof UserPremiumAssignSchema>;
export const PremiumLimitResourceSchema = z.enum(['tags', 'feeds', 'backups']);
export type PremiumLimitResource = z.infer<typeof PremiumLimitResourceSchema>;
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();
}