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