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