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:
@@ -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
|
||||
);
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
22
apps/bot/src/modules/gdpr/command-definitions.ts
Normal file
22
apps/bot/src/modules/gdpr/command-definitions.ts
Normal 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'
|
||||
)
|
||||
)
|
||||
);
|
||||
77
apps/bot/src/modules/gdpr/commands.ts
Normal file
77
apps/bot/src/modules/gdpr/commands.ts
Normal 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];
|
||||
2
apps/bot/src/modules/gdpr/index.ts
Normal file
2
apps/bot/src/modules/gdpr/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { gdprCommands } from './commands.js';
|
||||
export { deleteUserPersonalData } from './service.js';
|
||||
96
apps/bot/src/modules/gdpr/service.ts
Normal file
96
apps/bot/src/modules/gdpr/service.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
100
apps/bot/src/premium.ts
Normal 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;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
185
apps/webui/src/app/api/owner/premium/route.ts
Normal file
185
apps/webui/src/app/api/owner/premium/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
40
apps/webui/src/app/owner/premium/page.tsx
Normal file
40
apps/webui/src/app/owner/premium/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
305
apps/webui/src/components/owner/premium-manager.tsx
Normal file
305
apps/webui/src/components/owner/premium-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -15,11 +15,17 @@ async function ensureGuildSettings(guildId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function toGeneralSettings(settings: { locale: string; timezone: string; moderationEnabled: boolean }): GuildGeneralSettings {
|
||||
function toGeneralSettings(settings: {
|
||||
locale: string;
|
||||
timezone: string;
|
||||
moderationEnabled: boolean;
|
||||
snipeEnabled: boolean;
|
||||
}): GuildGeneralSettings {
|
||||
return {
|
||||
locale: settings.locale === 'en' ? 'en' : 'de',
|
||||
timezone: settings.timezone,
|
||||
moderationEnabled: settings.moderationEnabled
|
||||
moderationEnabled: settings.moderationEnabled,
|
||||
snipeEnabled: settings.snipeEnabled
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ import type {
|
||||
} from '@nexumi/shared';
|
||||
import type { SocialFeed } from '@prisma/client';
|
||||
import { prisma } from '../prisma';
|
||||
import { assertPremiumLimit, PremiumLimitError } from '../premium';
|
||||
|
||||
export class FeedPremiumError extends Error {
|
||||
constructor(public readonly code: 'premium_limit' | 'premium_feature') {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
function toDashboard(feed: SocialFeed): SocialFeedDashboard {
|
||||
return {
|
||||
@@ -31,6 +38,15 @@ export async function createFeed(
|
||||
input: SocialFeedDashboardCreate
|
||||
): Promise<SocialFeedDashboard> {
|
||||
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
||||
const currentCount = await prisma.socialFeed.count({ where: { guildId } });
|
||||
try {
|
||||
await assertPremiumLimit(prisma, guildId, 'feeds', currentCount);
|
||||
} catch (error) {
|
||||
if (error instanceof PremiumLimitError) {
|
||||
throw new FeedPremiumError(error.code === 'feature_disabled' ? 'premium_feature' : 'premium_limit');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const feed = await prisma.socialFeed.create({
|
||||
data: {
|
||||
guildId,
|
||||
|
||||
@@ -2,8 +2,15 @@ import { GuildBackupPayloadSchema, type GuildBackupDashboard } from '@nexumi/sha
|
||||
import type { GuildBackup } from '@prisma/client';
|
||||
import { buildGuildBackupPayloadViaRest } from '../guild-backup';
|
||||
import { prisma } from '../prisma';
|
||||
import { assertPremiumLimit, PremiumLimitError } from '../premium';
|
||||
import { guildBackupQueue, guildBackupQueueName } from '../queues';
|
||||
|
||||
export class BackupPremiumError extends Error {
|
||||
constructor(public readonly code: 'premium_limit' | 'premium_feature') {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureGuild(guildId: string): Promise<void> {
|
||||
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
||||
}
|
||||
@@ -39,6 +46,15 @@ export async function createGuildBackupDashboard(
|
||||
createdById: string
|
||||
): Promise<GuildBackupDashboard> {
|
||||
await ensureGuild(guildId);
|
||||
const currentCount = await prisma.guildBackup.count({ where: { guildId } });
|
||||
try {
|
||||
await assertPremiumLimit(prisma, guildId, 'backups', currentCount);
|
||||
} catch (error) {
|
||||
if (error instanceof PremiumLimitError) {
|
||||
throw new BackupPremiumError(error.code === 'feature_disabled' ? 'premium_feature' : 'premium_limit');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const payload = await buildGuildBackupPayloadViaRest(guildId);
|
||||
const backup = await prisma.guildBackup.create({
|
||||
data: { guildId, name, createdById, payload }
|
||||
|
||||
@@ -21,6 +21,7 @@ export async function getLoggingDashboard(guildId: string): Promise<LoggingConfi
|
||||
ignoreBots: config.ignoreBots,
|
||||
ignoredChannelIds: config.ignoredChannelIds,
|
||||
ignoredRoleIds: config.ignoredRoleIds,
|
||||
retentionDays: config.retentionDays,
|
||||
logChannels: channels.map((channel) => ({
|
||||
eventType: channel.eventType as LogEventTypeDashboard,
|
||||
channelId: channel.channelId
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TagDashboard, TagDashboardCreate, TagDashboardUpdate } from '@nexumi/shared';
|
||||
import type { Tag } from '@prisma/client';
|
||||
import { prisma } from '../prisma';
|
||||
import { assertPremiumLimit, PremiumLimitError } from '../premium';
|
||||
|
||||
export class TagConflictError extends Error {
|
||||
constructor() {
|
||||
@@ -8,6 +9,12 @@ export class TagConflictError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class TagPremiumError extends Error {
|
||||
constructor(public readonly code: 'premium_limit' | 'premium_feature') {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
function toDashboard(tag: Tag): TagDashboard {
|
||||
return {
|
||||
id: tag.id,
|
||||
@@ -31,6 +38,15 @@ export async function createTag(
|
||||
input: TagDashboardCreate
|
||||
): Promise<TagDashboard> {
|
||||
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
||||
const currentCount = await prisma.tag.count({ where: { guildId } });
|
||||
try {
|
||||
await assertPremiumLimit(prisma, guildId, 'tags', currentCount);
|
||||
} catch (error) {
|
||||
if (error instanceof PremiumLimitError) {
|
||||
throw new TagPremiumError(error.code === 'feature_disabled' ? 'premium_feature' : 'premium_limit');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const tag = await prisma.tag.create({
|
||||
data: {
|
||||
|
||||
@@ -15,7 +15,8 @@ export const OWNER_QUEUE_NAMES = [
|
||||
'schedules',
|
||||
'guild-backups',
|
||||
'suggestions',
|
||||
'presence'
|
||||
'presence',
|
||||
'retention'
|
||||
] as const;
|
||||
|
||||
export type OwnerQueueName = (typeof OWNER_QUEUE_NAMES)[number];
|
||||
|
||||
96
apps/webui/src/lib/premium.ts
Normal file
96
apps/webui/src/lib/premium.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import {
|
||||
DEFAULT_PREMIUM_TIER_CONFIGS,
|
||||
featureKeyForResource,
|
||||
isAssignmentActive,
|
||||
limitKeyForResource,
|
||||
parsePremiumFeatures,
|
||||
PremiumTierSchema,
|
||||
type PremiumLimitResource,
|
||||
type PremiumTier,
|
||||
type PremiumTierConfig
|
||||
} from '@nexumi/shared';
|
||||
|
||||
export class PremiumLimitError extends Error {
|
||||
constructor(
|
||||
public readonly code: 'feature_disabled' | 'limit_reached',
|
||||
public readonly resource: PremiumLimitResource,
|
||||
public readonly limit: number,
|
||||
public readonly current: number,
|
||||
public readonly tier: PremiumTier
|
||||
) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
function toConfig(row: {
|
||||
tier: string;
|
||||
maxTags: number;
|
||||
maxFeeds: number;
|
||||
maxBackups: number;
|
||||
features: unknown;
|
||||
}): PremiumTierConfig {
|
||||
return {
|
||||
tier: PremiumTierSchema.parse(row.tier),
|
||||
maxTags: row.maxTags,
|
||||
maxFeeds: row.maxFeeds,
|
||||
maxBackups: row.maxBackups,
|
||||
features: parsePremiumFeatures(row.features)
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensurePremiumTierConfigs(prisma: PrismaClient): Promise<PremiumTierConfig[]> {
|
||||
const configs: PremiumTierConfig[] = [];
|
||||
for (const tier of PremiumTierSchema.options) {
|
||||
const defaults = DEFAULT_PREMIUM_TIER_CONFIGS[tier];
|
||||
const row = await prisma.premiumTierConfig.upsert({
|
||||
where: { tier },
|
||||
create: {
|
||||
tier,
|
||||
maxTags: defaults.maxTags,
|
||||
maxFeeds: defaults.maxFeeds,
|
||||
maxBackups: defaults.maxBackups,
|
||||
features: defaults.features
|
||||
},
|
||||
update: {}
|
||||
});
|
||||
configs.push(toConfig(row));
|
||||
}
|
||||
return configs;
|
||||
}
|
||||
|
||||
export async function resolveGuildTier(prisma: PrismaClient, guildId: string): Promise<PremiumTier> {
|
||||
const assignment = await prisma.guildPremiumAssignment.findUnique({ where: { guildId } });
|
||||
if (!assignment || !isAssignmentActive(assignment.expiresAt)) {
|
||||
return 'FREE';
|
||||
}
|
||||
const parsed = PremiumTierSchema.safeParse(assignment.tier);
|
||||
return parsed.success ? parsed.data : 'FREE';
|
||||
}
|
||||
|
||||
export async function resolveGuildPremiumConfig(
|
||||
prisma: PrismaClient,
|
||||
guildId: string
|
||||
): Promise<PremiumTierConfig> {
|
||||
const tier = await resolveGuildTier(prisma, guildId);
|
||||
const configs = await ensurePremiumTierConfigs(prisma);
|
||||
return configs.find((config) => config.tier === tier) ?? DEFAULT_PREMIUM_TIER_CONFIGS[tier];
|
||||
}
|
||||
|
||||
export async function assertPremiumLimit(
|
||||
prisma: PrismaClient,
|
||||
guildId: string,
|
||||
resource: PremiumLimitResource,
|
||||
currentCount: number
|
||||
): Promise<PremiumTierConfig> {
|
||||
const config = await resolveGuildPremiumConfig(prisma, guildId);
|
||||
const featureKey = featureKeyForResource(resource);
|
||||
if (!config.features[featureKey]) {
|
||||
throw new PremiumLimitError('feature_disabled', resource, 0, currentCount, config.tier);
|
||||
}
|
||||
const limit = config[limitKeyForResource(resource)];
|
||||
if (currentCount >= limit) {
|
||||
throw new PremiumLimitError('limit_reached', resource, limit, currentCount, config.tier);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user