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 });
|
||||
|
||||
Reference in New Issue
Block a user