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:
smueller
2026-07-22 16:10:30 +02:00
parent bcbfa07697
commit 8a8aefb4fb
42 changed files with 2385 additions and 50 deletions

View File

@@ -0,0 +1,51 @@
import { Prisma, type PrismaClient } from '@prisma/client';
import type { OwnerAuditEntry } from '@nexumi/shared';
import { prisma } from './prisma';
export interface WriteOwnerAuditInput {
actorUserId: string;
action: string;
targetType?: string | null;
targetId?: string | null;
before?: unknown;
after?: unknown;
}
function toJson(value: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull {
if (value === undefined || value === null) {
return Prisma.JsonNull;
}
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}
export async function writeOwnerAudit(
client: PrismaClient,
input: WriteOwnerAuditInput
): Promise<void> {
await client.ownerAuditLog.create({
data: {
actorUserId: input.actorUserId,
action: input.action,
targetType: input.targetType ?? null,
targetId: input.targetId ?? null,
before: toJson(input.before),
after: toJson(input.after)
}
});
}
export async function listOwnerAudit(limit = 50, offset = 0): Promise<OwnerAuditEntry[]> {
const rows = await prisma.ownerAuditLog.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset
});
return rows.map((row) => ({
id: row.id,
actorUserId: row.actorUserId,
action: row.action,
targetType: row.targetType,
targetId: row.targetId,
createdAt: row.createdAt.toISOString()
}));
}

View File

@@ -0,0 +1,55 @@
import { redirect } from 'next/navigation';
import {
ownerRoleAtLeast,
OwnerRoleSchema,
type OwnerRole
} from '@nexumi/shared';
import { ForbiddenError, UnauthorizedError, requireAuth, requireAuthOrRedirect } from './auth';
import { env } from './env';
import { prisma } from './prisma';
import type { SessionPayload } from './session';
export type OwnerSession = SessionPayload & { ownerRole: OwnerRole };
function bootstrapOwnerIds(): string[] {
return env.OWNER_USER_IDS ?? [];
}
export async function resolveOwnerRole(userId: string): Promise<OwnerRole | null> {
if (bootstrapOwnerIds().includes(userId)) {
return 'OWNER';
}
const member = await prisma.ownerTeamMember.findUnique({ where: { userId } });
if (!member) {
return null;
}
const parsed = OwnerRoleSchema.safeParse(member.role);
return parsed.success ? parsed.data : null;
}
export async function isOwnerUser(userId: string): Promise<boolean> {
return (await resolveOwnerRole(userId)) !== null;
}
export async function requireOwner(minimum: OwnerRole = 'VIEWER'): Promise<OwnerSession> {
const session = await requireAuth();
const role = await resolveOwnerRole(session.user.id);
if (!role) {
throw new ForbiddenError('Owner panel access required');
}
if (!ownerRoleAtLeast(role, minimum)) {
throw new ForbiddenError('Insufficient owner role');
}
return { ...session, ownerRole: role };
}
export async function requireOwnerOrRedirect(minimum: OwnerRole = 'VIEWER'): Promise<OwnerSession> {
const session = await requireAuthOrRedirect();
const role = await resolveOwnerRole(session.user.id);
if (!role || !ownerRoleAtLeast(role, minimum)) {
redirect('/dashboard');
}
return { ...session, ownerRole: role };
}
export { UnauthorizedError, ForbiddenError };

View File

@@ -0,0 +1,76 @@
import { Queue } from 'bullmq';
import { redis } from './redis';
export const OWNER_QUEUE_NAMES = [
'moderation',
'backups',
'automod',
'verification',
'reminders',
'giveaways',
'tickets',
'birthdays',
'stats',
'feeds',
'schedules',
'guild-backups',
'suggestions',
'presence'
] as const;
export type OwnerQueueName = (typeof OWNER_QUEUE_NAMES)[number];
const globalForOwnerQueues = globalThis as unknown as {
__nexumiOwnerQueues?: Map<string, Queue>;
};
function getQueue(name: string): Queue {
const cache = globalForOwnerQueues.__nexumiOwnerQueues ?? new Map<string, Queue>();
globalForOwnerQueues.__nexumiOwnerQueues = cache;
let queue = cache.get(name);
if (!queue) {
queue = new Queue(name, { connection: redis });
cache.set(name, queue);
}
return queue;
}
export interface QueueStats {
name: string;
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
}
export async function getAllQueueStats(): Promise<QueueStats[]> {
return Promise.all(
OWNER_QUEUE_NAMES.map(async (name) => {
const queue = getQueue(name);
const counts = await queue.getJobCounts('wait', 'active', 'completed', 'failed', 'delayed');
return {
name,
waiting: counts.wait,
active: counts.active,
completed: counts.completed,
failed: counts.failed,
delayed: counts.delayed
};
})
);
}
export async function retryFailedJobs(queueName: string, limit = 25): Promise<number> {
if (!(OWNER_QUEUE_NAMES as readonly string[]).includes(queueName)) {
throw new Error('Unknown queue');
}
const queue = getQueue(queueName);
const failed = await queue.getFailed(0, limit - 1);
let retried = 0;
for (const job of failed) {
await job.retry();
retried += 1;
}
return retried;
}

View File

@@ -0,0 +1,67 @@
import {
BotPresenceConfigPatchSchema,
BotPresenceConfigSchema,
PRESENCE_REDIS_KEY,
type BotPresenceConfig
} from '@nexumi/shared';
import { prisma } from './prisma';
import { redis } from './redis';
function mapRow(row: {
status: string;
activityType: string;
activityText: string;
rotatingMessages: unknown;
maintenanceMode: boolean;
maintenanceMessage: string | null;
}): BotPresenceConfig {
const rotating = Array.isArray(row.rotatingMessages)
? row.rotatingMessages.filter((item): item is string => typeof item === 'string')
: [];
return BotPresenceConfigSchema.parse({
status: row.status,
activityType: row.activityType,
activityText: row.activityText,
rotatingMessages: rotating,
maintenanceMode: row.maintenanceMode,
maintenanceMessage: row.maintenanceMessage
});
}
export async function getPresenceConfig(): Promise<BotPresenceConfig> {
const row = await prisma.botPresenceConfig.upsert({
where: { id: 'singleton' },
create: { id: 'singleton' },
update: {}
});
return mapRow(row);
}
export async function updatePresenceConfig(patch: unknown): Promise<BotPresenceConfig> {
const data = BotPresenceConfigPatchSchema.parse(patch);
const current = await getPresenceConfig();
const next = { ...current, ...data };
const row = await prisma.botPresenceConfig.upsert({
where: { id: 'singleton' },
create: {
id: 'singleton',
status: next.status,
activityType: next.activityType,
activityText: next.activityText,
rotatingMessages: next.rotatingMessages,
maintenanceMode: next.maintenanceMode,
maintenanceMessage: next.maintenanceMessage
},
update: {
status: next.status,
activityType: next.activityType,
activityText: next.activityText,
rotatingMessages: next.rotatingMessages,
maintenanceMode: next.maintenanceMode,
maintenanceMessage: next.maintenanceMessage
}
});
const mapped = mapRow(row);
await redis.set(PRESENCE_REDIS_KEY, JSON.stringify(mapped));
return mapped;
}