Implement Owner Panel features and maintenance mode handling
- Introduced the Owner Panel with new routes and UI components for managing guilds, user blacklists, feature flags, and bot presence. - Added maintenance mode functionality to prevent non-owner users from executing commands during maintenance periods. - Enhanced localization with new keys for Owner Panel features in both English and German. - Updated job processing to include a presence refresh job, ensuring the bot's status is regularly updated. - Improved environment configuration to support owner user IDs for access control.
This commit is contained in:
17
apps/webui/src/app/api/owner/audit/route.ts
Normal file
17
apps/webui/src/app/api/owner/audit/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { listOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(Number(searchParams.get('limit') ?? 50), 100);
|
||||
const offset = Math.max(Number(searchParams.get('offset') ?? 0), 0);
|
||||
const entries = await listOwnerAudit(limit, offset);
|
||||
return NextResponse.json({ entries });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
73
apps/webui/src/app/api/owner/changelog/route.ts
Normal file
73
apps/webui/src/app/api/owner/changelog/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ChangelogEntrySchema } from '@nexumi/shared';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const entries = await prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' } });
|
||||
return NextResponse.json({
|
||||
entries: entries.map((entry) => ({
|
||||
...entry,
|
||||
publishedAt: entry.publishedAt.toISOString(),
|
||||
createdAt: entry.createdAt.toISOString(),
|
||||
updatedAt: entry.updatedAt.toISOString()
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const body = ChangelogEntrySchema.parse(await request.json());
|
||||
const entry = await prisma.changelogEntry.create({
|
||||
data: {
|
||||
version: body.version,
|
||||
title: body.title,
|
||||
body: body.body,
|
||||
publishedAt: body.publishedAt ? new Date(body.publishedAt) : undefined
|
||||
}
|
||||
});
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'changelog.create',
|
||||
targetType: 'ChangelogEntry',
|
||||
targetId: entry.id,
|
||||
after: entry
|
||||
});
|
||||
return NextResponse.json(entry);
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const id = new URL(request.url).searchParams.get('id');
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id required' }, { status: 400 });
|
||||
}
|
||||
const before = await prisma.changelogEntry.findUnique({ where: { id } });
|
||||
if (!before) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
await prisma.changelogEntry.delete({ where: { id } });
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'changelog.delete',
|
||||
targetType: 'ChangelogEntry',
|
||||
targetId: id,
|
||||
before
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
74
apps/webui/src/app/api/owner/flags/route.ts
Normal file
74
apps/webui/src/app/api/owner/flags/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { FeatureFlagUpsertSchema } from '@nexumi/shared';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const flags = await prisma.featureFlag.findMany({ orderBy: { key: 'asc' } });
|
||||
return NextResponse.json({ flags });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const body = FeatureFlagUpsertSchema.parse(await request.json());
|
||||
const before = await prisma.featureFlag.findUnique({ where: { key: body.key } });
|
||||
const flag = await prisma.featureFlag.upsert({
|
||||
where: { key: body.key },
|
||||
create: {
|
||||
key: body.key,
|
||||
enabled: body.enabled,
|
||||
rolloutPercent: body.rolloutPercent,
|
||||
whitelistGuildIds: body.whitelistGuildIds
|
||||
},
|
||||
update: {
|
||||
enabled: body.enabled,
|
||||
rolloutPercent: body.rolloutPercent,
|
||||
whitelistGuildIds: body.whitelistGuildIds
|
||||
}
|
||||
});
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: before ? 'flags.update' : 'flags.create',
|
||||
targetType: 'FeatureFlag',
|
||||
targetId: flag.id,
|
||||
before,
|
||||
after: flag
|
||||
});
|
||||
return NextResponse.json(flag);
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const key = new URL(request.url).searchParams.get('key');
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: 'key required' }, { status: 400 });
|
||||
}
|
||||
const before = await prisma.featureFlag.findUnique({ where: { key } });
|
||||
if (!before) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
await prisma.featureFlag.delete({ where: { key } });
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'flags.delete',
|
||||
targetType: 'FeatureFlag',
|
||||
targetId: before.id,
|
||||
before
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
104
apps/webui/src/app/api/owner/guilds/route.ts
Normal file
104
apps/webui/src/app/api/owner/guilds/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { GuildBlacklistSchema } from '@nexumi/shared';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { env } from '@/lib/env';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const q = new URL(request.url).searchParams.get('q')?.trim() ?? '';
|
||||
const guilds = await prisma.guild.findMany({
|
||||
where: q ? { id: { contains: q } } : undefined,
|
||||
include: { settings: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100
|
||||
});
|
||||
const blacklist = await prisma.guildBlacklist.findMany();
|
||||
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
|
||||
return NextResponse.json({
|
||||
guilds: guilds.map((guild) => ({
|
||||
id: guild.id,
|
||||
locale: guild.settings?.locale ?? 'de',
|
||||
createdAt: guild.createdAt.toISOString(),
|
||||
blacklisted: blacklisted.has(guild.id)
|
||||
})),
|
||||
blacklist
|
||||
});
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const body = (await request.json()) as { action?: string; guildId?: string; reason?: string };
|
||||
if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) {
|
||||
return NextResponse.json({ error: 'Invalid guildId' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.action === 'leave') {
|
||||
const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bot ${env.BOT_TOKEN}` }
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
return NextResponse.json({ error: `Discord leave failed (${response.status})` }, { status: 502 });
|
||||
}
|
||||
await prisma.guild.delete({ where: { id: body.guildId } }).catch(() => undefined);
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'guilds.leave',
|
||||
targetType: 'Guild',
|
||||
targetId: body.guildId
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (body.action === 'blacklist') {
|
||||
const parsed = GuildBlacklistSchema.parse({
|
||||
guildId: body.guildId,
|
||||
reason: body.reason ?? null
|
||||
});
|
||||
const row = await prisma.guildBlacklist.upsert({
|
||||
where: { guildId: parsed.guildId },
|
||||
create: {
|
||||
guildId: parsed.guildId,
|
||||
reason: parsed.reason ?? null,
|
||||
createdById: session.user.id
|
||||
},
|
||||
update: { reason: parsed.reason ?? null }
|
||||
});
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'guilds.blacklist',
|
||||
targetType: 'GuildBlacklist',
|
||||
targetId: row.id,
|
||||
after: row
|
||||
});
|
||||
return NextResponse.json(row);
|
||||
}
|
||||
|
||||
if (body.action === 'unblacklist') {
|
||||
const before = await prisma.guildBlacklist.findUnique({ where: { guildId: body.guildId } });
|
||||
if (before) {
|
||||
await prisma.guildBlacklist.delete({ where: { guildId: body.guildId } });
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'guilds.unblacklist',
|
||||
targetType: 'GuildBlacklist',
|
||||
targetId: before.id,
|
||||
before
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
48
apps/webui/src/app/api/owner/jobs/route.ts
Normal file
48
apps/webui/src/app/api/owner/jobs/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { getAllQueueStats, retryFailedJobs } from '@/lib/owner-jobs';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('SUPPORT');
|
||||
const [queues, migrations] = await Promise.all([
|
||||
getAllQueueStats(),
|
||||
prisma.$queryRaw<Array<{ migration_name: string; finished_at: Date | null }>>`
|
||||
SELECT migration_name, finished_at FROM "_prisma_migrations" ORDER BY finished_at DESC NULLS LAST
|
||||
`
|
||||
]);
|
||||
return NextResponse.json({
|
||||
queues,
|
||||
migrations: migrations.map((row) => ({
|
||||
name: row.migration_name,
|
||||
finishedAt: row.finished_at?.toISOString() ?? null
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const body = (await request.json()) as { queueName?: string };
|
||||
if (!body.queueName) {
|
||||
return NextResponse.json({ error: 'queueName required' }, { status: 400 });
|
||||
}
|
||||
const retried = await retryFailedJobs(body.queueName);
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'jobs.retryFailed',
|
||||
targetType: 'Queue',
|
||||
targetId: body.queueName,
|
||||
after: { retried }
|
||||
});
|
||||
return NextResponse.json({ retried });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
38
apps/webui/src/app/api/owner/overview/route.ts
Normal file
38
apps/webui/src/app/api/owner/overview/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { listOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getPresenceConfig } from '@/lib/owner-presence';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const [guildCount, blacklistUsers, blacklistGuilds, presence, audit, changelog] =
|
||||
await Promise.all([
|
||||
prisma.guild.count(),
|
||||
prisma.globalUserBlacklist.count(),
|
||||
prisma.guildBlacklist.count(),
|
||||
getPresenceConfig(),
|
||||
listOwnerAudit(10),
|
||||
prisma.changelogEntry.findMany({ orderBy: { publishedAt: 'desc' }, take: 5 })
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
guildCount,
|
||||
blacklistUsers,
|
||||
blacklistGuilds,
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
maintenanceMode: presence.maintenanceMode,
|
||||
recentAudit: audit,
|
||||
changelog: changelog.map((entry) => ({
|
||||
id: entry.id,
|
||||
version: entry.version,
|
||||
title: entry.title,
|
||||
publishedAt: entry.publishedAt.toISOString()
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
36
apps/webui/src/app/api/owner/presence/route.ts
Normal file
36
apps/webui/src/app/api/owner/presence/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { BotPresenceConfigPatchSchema } from '@nexumi/shared';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { getPresenceConfig, updatePresenceConfig } from '@/lib/owner-presence';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
return NextResponse.json(await getPresenceConfig());
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const body = BotPresenceConfigPatchSchema.parse(await request.json());
|
||||
const before = await getPresenceConfig();
|
||||
const after = await updatePresenceConfig(body);
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'presence.update',
|
||||
targetType: 'BotPresenceConfig',
|
||||
targetId: 'singleton',
|
||||
before,
|
||||
after
|
||||
});
|
||||
return NextResponse.json(after);
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
77
apps/webui/src/app/api/owner/team/route.ts
Normal file
77
apps/webui/src/app/api/owner/team/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { OwnerTeamUpsertSchema } from '@nexumi/shared';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { env } from '@/lib/env';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const members = await prisma.ownerTeamMember.findMany({ orderBy: { createdAt: 'asc' } });
|
||||
return NextResponse.json({
|
||||
bootstrapOwnerIds: env.OWNER_USER_IDS ?? [],
|
||||
members
|
||||
});
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('OWNER');
|
||||
const body = OwnerTeamUpsertSchema.parse(await request.json());
|
||||
const before = await prisma.ownerTeamMember.findUnique({ where: { userId: body.userId } });
|
||||
const member = await prisma.ownerTeamMember.upsert({
|
||||
where: { userId: body.userId },
|
||||
create: {
|
||||
userId: body.userId,
|
||||
role: body.role,
|
||||
note: body.note ?? null
|
||||
},
|
||||
update: {
|
||||
role: body.role,
|
||||
note: body.note ?? null
|
||||
}
|
||||
});
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: before ? 'team.update' : 'team.create',
|
||||
targetType: 'OwnerTeamMember',
|
||||
targetId: member.id,
|
||||
before,
|
||||
after: member
|
||||
});
|
||||
return NextResponse.json(member);
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('OWNER');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const userId = searchParams.get('userId');
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'userId required' }, { status: 400 });
|
||||
}
|
||||
const before = await prisma.ownerTeamMember.findUnique({ where: { userId } });
|
||||
if (!before) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
await prisma.ownerTeamMember.delete({ where: { userId } });
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'team.delete',
|
||||
targetType: 'OwnerTeamMember',
|
||||
targetId: before.id,
|
||||
before
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
73
apps/webui/src/app/api/owner/users/route.ts
Normal file
73
apps/webui/src/app/api/owner/users/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { GlobalUserBlacklistSchema } from '@nexumi/shared';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const users = await prisma.globalUserBlacklist.findMany({ orderBy: { createdAt: 'desc' } });
|
||||
return NextResponse.json({ users });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('SUPPORT');
|
||||
const body = GlobalUserBlacklistSchema.parse(await request.json());
|
||||
const before = await prisma.globalUserBlacklist.findUnique({ where: { userId: body.userId } });
|
||||
const row = await prisma.globalUserBlacklist.upsert({
|
||||
where: { userId: body.userId },
|
||||
create: {
|
||||
userId: body.userId,
|
||||
reason: body.reason ?? null,
|
||||
note: body.note ?? null,
|
||||
createdById: session.user.id
|
||||
},
|
||||
update: {
|
||||
reason: body.reason ?? null,
|
||||
note: body.note ?? null
|
||||
}
|
||||
});
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: before ? 'users.blacklist.update' : 'users.blacklist.create',
|
||||
targetType: 'GlobalUserBlacklist',
|
||||
targetId: row.id,
|
||||
before,
|
||||
after: row
|
||||
});
|
||||
return NextResponse.json(row);
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('SUPPORT');
|
||||
const userId = new URL(request.url).searchParams.get('userId');
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'userId required' }, { status: 400 });
|
||||
}
|
||||
const before = await prisma.globalUserBlacklist.findUnique({ where: { userId } });
|
||||
if (!before) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
await prisma.globalUserBlacklist.delete({ where: { userId } });
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'users.blacklist.delete',
|
||||
targetType: 'GlobalUserBlacklist',
|
||||
targetId: before.id,
|
||||
before
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user