Schema migrations: - users.is_admin boolean - users.password_hash text (scrypt N=16384, 16-byte salt) - users.last_login_at timestamp - organizations.suspended + suspended_reason - admin_settings table (DB-stored prompt override + future settings) Auth (@bmm/auth): - hashPassword + verifyPassword via node:crypto scrypt (no extra dep) - loginWithPassword: scrypt-verifies, issues 30-day session, updates last_login_at - seedAdmin: idempotent upsert keyed on email; creates org + membership on first run - AuthedUser now carries isAdmin flag API: - POST /v1/auth/admin/login (email + password) — 300ms throttle on failure - requireAdmin preHandler — 401 if no session, 403 if non-admin - Bootstrap: api on boot calls seedAdmin(ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME) if env present. Idempotent. Admin API routes (all gated by requireAdmin): - GET /v1/admin/overview (totals, trends 7d, server-status breakdown, builds 24h, recent activity) - GET /v1/admin/users (search, per-row org + plan + serverCount) - PATCH /v1/admin/users/:id (isAdmin, name) - DELETE /v1/admin/users/:id (self-delete blocked) - GET /v1/admin/orgs (member + server counts) - PATCH /v1/admin/orgs/:id (plan, quota, suspended; cascades to mcp_servers.status=paused on suspend) - GET /v1/admin/servers (cross-org with status filter) - POST /v1/admin/servers/:id/rebuild (re-queues build using last prompt) - DELETE /v1/admin/servers/:id - GET /v1/admin/builds (status filter, error messages, prompt previews) - GET /v1/admin/builds/:id/logs - GET /v1/admin/audit (system-wide with user email join) - GET /v1/admin/system (DB ping, Redis ping, BullMQ queue depth, docker ps count) - GET /v1/admin/prompt (builtin + override + updatedAt) - PATCH /v1/admin/prompt (value: string | null) — saves DB override or drops it UI (apps/web/app/admin/*): - /admin/login — password form, separate from /login magic-link - AdminLayout — Linear-style sidebar (8 nav items), bottom panel with user email + 'user view' shortcut + logout, client-side requireAdmin guard with redirect - /admin — overview dashboard with 4 metric cards, 2 panels (status + 24h builds), recent activity table linking to full audit - /admin/users — search + admin toggle + delete (self-delete blocked) - /admin/orgs — plan/quota/suspend actions via prompts - /admin/servers — cross-org table with rebuild + delete actions, status filter - /admin/builds — every build cross-fleet with error vs prompt preview - /admin/audit — system-wide log + CSV export + filter dropdowns - /admin/system — auto-refreshing 5s health probes for Postgres, Redis, queue, Docker - /admin/prompt — live editor for the LLM system prompt with built-in baseline, override-state badge, drop-override action, diff preview, save-as-override End-to-end verified: login as marco.frangiskatos@gmail.com + Melusa112233.*, every admin page returns 200, admin login + overview tested via screenshot, docker probe returns true count of running MCP containers.
570 lines
18 KiB
TypeScript
570 lines
18 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { spawn } from 'node:child_process';
|
|
import { z } from 'zod';
|
|
import {
|
|
adminSettings,
|
|
auditLog,
|
|
buildLogs,
|
|
builds,
|
|
count,
|
|
createDb,
|
|
desc,
|
|
eq,
|
|
gte,
|
|
inArray,
|
|
mcpServers,
|
|
memberships,
|
|
organizations,
|
|
sql,
|
|
toolCallMetrics,
|
|
users,
|
|
} from '@bmm/db';
|
|
import { SYSTEM_PROMPT } from '@bmm/llm';
|
|
import { requireAdmin } from '../plugins/session.js';
|
|
import { getRedis } from '../lib/redis.js';
|
|
import { getBuildQueue } from '../lib/queue.js';
|
|
import { audit } from '../lib/audit.js';
|
|
|
|
const db = createDb();
|
|
|
|
const PROMPT_KEY = 'system_prompt_override';
|
|
|
|
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|
// ---- Overview metrics ----
|
|
app.get('/v1/admin/overview', { preHandler: requireAdmin }, async (_req, reply) => {
|
|
const dayAgo = new Date(Date.now() - 24 * 3600_000);
|
|
const sevenDaysAgo = new Date(Date.now() - 7 * 86400_000);
|
|
|
|
const [
|
|
userCount,
|
|
orgCount,
|
|
serverCount,
|
|
buildCount,
|
|
callCount,
|
|
liveCount,
|
|
failedBuildCount,
|
|
newUsersLast7d,
|
|
newServersLast7d,
|
|
] = await Promise.all([
|
|
db.select({ c: count() }).from(users).then((r) => Number(r[0]?.c ?? 0)),
|
|
db.select({ c: count() }).from(organizations).then((r) => Number(r[0]?.c ?? 0)),
|
|
db.select({ c: count() }).from(mcpServers).then((r) => Number(r[0]?.c ?? 0)),
|
|
db.select({ c: count() }).from(builds).then((r) => Number(r[0]?.c ?? 0)),
|
|
db.select({ c: count() }).from(toolCallMetrics).then((r) => Number(r[0]?.c ?? 0)),
|
|
db
|
|
.select({ c: count() })
|
|
.from(mcpServers)
|
|
.where(eq(mcpServers.status, 'live'))
|
|
.then((r) => Number(r[0]?.c ?? 0)),
|
|
db
|
|
.select({ c: count() })
|
|
.from(builds)
|
|
.where(eq(builds.status, 'failed'))
|
|
.then((r) => Number(r[0]?.c ?? 0)),
|
|
db
|
|
.select({ c: count() })
|
|
.from(users)
|
|
.where(gte(users.createdAt, sevenDaysAgo))
|
|
.then((r) => Number(r[0]?.c ?? 0)),
|
|
db
|
|
.select({ c: count() })
|
|
.from(mcpServers)
|
|
.where(gte(mcpServers.createdAt, sevenDaysAgo))
|
|
.then((r) => Number(r[0]?.c ?? 0)),
|
|
]);
|
|
|
|
// Server status breakdown
|
|
const statusBreakdown = await db
|
|
.select({ status: mcpServers.status, c: count() })
|
|
.from(mcpServers)
|
|
.groupBy(mcpServers.status);
|
|
|
|
// Recent activity from audit log
|
|
const recent = await db
|
|
.select()
|
|
.from(auditLog)
|
|
.orderBy(desc(auditLog.createdAt))
|
|
.limit(15);
|
|
|
|
// Builds in last 24h with status
|
|
const recentBuilds = await db
|
|
.select({ status: builds.status, c: count() })
|
|
.from(builds)
|
|
.where(gte(builds.createdAt, dayAgo))
|
|
.groupBy(builds.status);
|
|
|
|
return reply.send({
|
|
totals: {
|
|
users: userCount,
|
|
orgs: orgCount,
|
|
servers: serverCount,
|
|
liveServers: liveCount,
|
|
builds: buildCount,
|
|
failedBuilds: failedBuildCount,
|
|
toolCalls: callCount,
|
|
},
|
|
trends: {
|
|
newUsersLast7d,
|
|
newServersLast7d,
|
|
},
|
|
statusBreakdown,
|
|
recentBuilds24h: recentBuilds,
|
|
recentActivity: recent,
|
|
});
|
|
});
|
|
|
|
// ---- Users ----
|
|
app.get('/v1/admin/users', { preHandler: requireAdmin }, async (req, reply) => {
|
|
const Query = z.object({
|
|
search: z.string().optional(),
|
|
limit: z.coerce.number().min(1).max(500).default(100),
|
|
});
|
|
const parsed = Query.safeParse(req.query);
|
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
|
|
|
|
const rows = await db.select().from(users).orderBy(desc(users.createdAt)).limit(parsed.data.limit);
|
|
const filtered = parsed.data.search
|
|
? rows.filter((u) =>
|
|
u.email.toLowerCase().includes(parsed.data.search!.toLowerCase()) ||
|
|
(u.name?.toLowerCase().includes(parsed.data.search!.toLowerCase()) ?? false),
|
|
)
|
|
: rows;
|
|
|
|
// attach org + server count
|
|
const enriched = await Promise.all(
|
|
filtered.map(async (u) => {
|
|
const [m] = await db
|
|
.select({
|
|
orgId: memberships.orgId,
|
|
orgName: organizations.name,
|
|
orgSlug: organizations.slug,
|
|
plan: organizations.plan,
|
|
role: memberships.role,
|
|
})
|
|
.from(memberships)
|
|
.innerJoin(organizations, eq(organizations.id, memberships.orgId))
|
|
.where(eq(memberships.userId, u.id))
|
|
.limit(1);
|
|
|
|
const [sc] = await db
|
|
.select({ c: count() })
|
|
.from(mcpServers)
|
|
.where(m ? eq(mcpServers.orgId, m.orgId) : sql`false`);
|
|
|
|
return {
|
|
id: u.id,
|
|
email: u.email,
|
|
name: u.name,
|
|
isAdmin: u.isAdmin,
|
|
emailVerified: u.emailVerified,
|
|
lastLoginAt: u.lastLoginAt,
|
|
createdAt: u.createdAt,
|
|
org: m ?? null,
|
|
serverCount: Number(sc?.c ?? 0),
|
|
};
|
|
}),
|
|
);
|
|
|
|
return reply.send({ users: enriched });
|
|
});
|
|
|
|
app.patch('/v1/admin/users/:id', { preHandler: requireAdmin }, async (req, reply) => {
|
|
const Params = z.object({ id: z.string().uuid() });
|
|
const Body = z.object({
|
|
isAdmin: z.boolean().optional(),
|
|
name: z.string().optional(),
|
|
});
|
|
const p = Params.safeParse(req.params);
|
|
const b = Body.safeParse(req.body);
|
|
if (!p.success || !b.success) return reply.code(400).send({ error: 'invalid_input' });
|
|
|
|
await db.update(users).set(b.data).where(eq(users.id, p.data.id));
|
|
await audit({
|
|
orgId: req.user!.orgId,
|
|
userId: req.user!.userId,
|
|
action: 'admin.user.update',
|
|
resourceType: 'user',
|
|
resourceId: p.data.id,
|
|
metadata: b.data,
|
|
ipAddress: req.ip,
|
|
});
|
|
return reply.send({ ok: true });
|
|
});
|
|
|
|
app.delete('/v1/admin/users/:id', { preHandler: requireAdmin }, async (req, reply) => {
|
|
const Params = z.object({ id: z.string().uuid() });
|
|
const p = Params.safeParse(req.params);
|
|
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
|
|
if (p.data.id === req.user!.userId) {
|
|
return reply.code(400).send({ error: 'cannot_delete_self' });
|
|
}
|
|
await db.delete(users).where(eq(users.id, p.data.id));
|
|
await audit({
|
|
orgId: req.user!.orgId,
|
|
userId: req.user!.userId,
|
|
action: 'admin.user.delete',
|
|
resourceType: 'user',
|
|
resourceId: p.data.id,
|
|
ipAddress: req.ip,
|
|
});
|
|
return reply.send({ ok: true });
|
|
});
|
|
|
|
// ---- Organizations ----
|
|
app.get('/v1/admin/orgs', { preHandler: requireAdmin }, async (_req, reply) => {
|
|
const rows = await db.select().from(organizations).orderBy(desc(organizations.createdAt));
|
|
|
|
const enriched = await Promise.all(
|
|
rows.map(async (o) => {
|
|
const [mc] = await db
|
|
.select({ c: count() })
|
|
.from(memberships)
|
|
.where(eq(memberships.orgId, o.id));
|
|
const [sc] = await db
|
|
.select({ c: count() })
|
|
.from(mcpServers)
|
|
.where(eq(mcpServers.orgId, o.id));
|
|
return {
|
|
...o,
|
|
memberCount: Number(mc?.c ?? 0),
|
|
serverCount: Number(sc?.c ?? 0),
|
|
};
|
|
}),
|
|
);
|
|
return reply.send({ orgs: enriched });
|
|
});
|
|
|
|
app.patch('/v1/admin/orgs/:id', { preHandler: requireAdmin }, async (req, reply) => {
|
|
const Params = z.object({ id: z.string().uuid() });
|
|
const Body = z.object({
|
|
plan: z.enum(['hobby', 'pro', 'team', 'enterprise']).optional(),
|
|
monthlyCallQuota: z.coerce.number().int().min(0).optional(),
|
|
suspended: z.boolean().optional(),
|
|
suspendedReason: z.string().max(500).nullable().optional(),
|
|
});
|
|
const p = Params.safeParse(req.params);
|
|
const b = Body.safeParse(req.body);
|
|
if (!p.success || !b.success) return reply.code(400).send({ error: 'invalid_input' });
|
|
|
|
await db.update(organizations).set(b.data).where(eq(organizations.id, p.data.id));
|
|
|
|
// If suspended, pause all the org's servers
|
|
if (b.data.suspended === true) {
|
|
await db
|
|
.update(mcpServers)
|
|
.set({ status: 'paused', updatedAt: new Date() })
|
|
.where(eq(mcpServers.orgId, p.data.id));
|
|
}
|
|
await audit({
|
|
orgId: req.user!.orgId,
|
|
userId: req.user!.userId,
|
|
action: 'admin.org.update',
|
|
resourceType: 'org',
|
|
resourceId: p.data.id,
|
|
metadata: b.data,
|
|
ipAddress: req.ip,
|
|
});
|
|
return reply.send({ ok: true });
|
|
});
|
|
|
|
// ---- Servers ----
|
|
app.get('/v1/admin/servers', { preHandler: requireAdmin }, async (req, reply) => {
|
|
const Query = z.object({
|
|
status: z.string().optional(),
|
|
limit: z.coerce.number().min(1).max(500).default(200),
|
|
});
|
|
const parsed = Query.safeParse(req.query);
|
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
|
|
|
|
const rows = await db
|
|
.select({
|
|
server: mcpServers,
|
|
org: { id: organizations.id, name: organizations.name, slug: organizations.slug, plan: organizations.plan },
|
|
})
|
|
.from(mcpServers)
|
|
.innerJoin(organizations, eq(organizations.id, mcpServers.orgId))
|
|
.orderBy(desc(mcpServers.updatedAt))
|
|
.limit(parsed.data.limit);
|
|
|
|
const filtered = parsed.data.status
|
|
? rows.filter((r) => r.server.status === parsed.data.status)
|
|
: rows;
|
|
|
|
return reply.send({ servers: filtered });
|
|
});
|
|
|
|
app.post('/v1/admin/servers/:id/rebuild', { preHandler: requireAdmin }, async (req, reply) => {
|
|
const Params = z.object({ id: z.string().uuid() });
|
|
const p = Params.safeParse(req.params);
|
|
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
|
|
|
|
const [server] = await db.select().from(mcpServers).where(eq(mcpServers.id, p.data.id)).limit(1);
|
|
if (!server) return reply.code(404).send({ error: 'not_found' });
|
|
|
|
// Get last build's prompt
|
|
const [lastBuild] = await db
|
|
.select()
|
|
.from(builds)
|
|
.where(eq(builds.serverId, server.id))
|
|
.orderBy(desc(builds.version))
|
|
.limit(1);
|
|
if (!lastBuild) return reply.code(400).send({ error: 'no_previous_build' });
|
|
|
|
const nextVersion = (server.currentVersion ?? 0) + 1;
|
|
const [build] = await db
|
|
.insert(builds)
|
|
.values({
|
|
serverId: server.id,
|
|
version: nextVersion,
|
|
prompt: lastBuild.prompt,
|
|
status: 'queued',
|
|
})
|
|
.returning();
|
|
if (!build) return reply.code(500).send({ error: 'build_create_failed' });
|
|
|
|
await db
|
|
.update(mcpServers)
|
|
.set({ status: 'queued', updatedAt: new Date() })
|
|
.where(eq(mcpServers.id, server.id));
|
|
|
|
await getBuildQueue().add('generate', {
|
|
buildId: build.id,
|
|
serverId: server.id,
|
|
orgId: server.orgId,
|
|
prompt: lastBuild.prompt,
|
|
version: nextVersion,
|
|
slug: server.slug,
|
|
serverName: server.name,
|
|
secrets: {},
|
|
});
|
|
|
|
await audit({
|
|
orgId: req.user!.orgId,
|
|
userId: req.user!.userId,
|
|
action: 'admin.server.rebuild',
|
|
resourceType: 'server',
|
|
resourceId: server.id,
|
|
metadata: { newVersion: nextVersion },
|
|
ipAddress: req.ip,
|
|
});
|
|
|
|
return reply.send({ build });
|
|
});
|
|
|
|
app.delete('/v1/admin/servers/:id', { preHandler: requireAdmin }, async (req, reply) => {
|
|
const Params = z.object({ id: z.string().uuid() });
|
|
const p = Params.safeParse(req.params);
|
|
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
|
|
|
|
const [server] = await db.select().from(mcpServers).where(eq(mcpServers.id, p.data.id)).limit(1);
|
|
if (!server) return reply.code(404).send({ error: 'not_found' });
|
|
|
|
await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
|
|
await audit({
|
|
orgId: req.user!.orgId,
|
|
userId: req.user!.userId,
|
|
action: 'admin.server.delete',
|
|
resourceType: 'server',
|
|
resourceId: server.id,
|
|
metadata: { slug: server.slug, name: server.name },
|
|
ipAddress: req.ip,
|
|
});
|
|
return reply.send({ ok: true });
|
|
});
|
|
|
|
// ---- Builds ----
|
|
app.get('/v1/admin/builds', { preHandler: requireAdmin }, async (req, reply) => {
|
|
const Query = z.object({
|
|
status: z.string().optional(),
|
|
limit: z.coerce.number().min(1).max(500).default(100),
|
|
});
|
|
const parsed = Query.safeParse(req.query);
|
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
|
|
|
|
const rows = await db
|
|
.select({
|
|
build: builds,
|
|
server: { id: mcpServers.id, name: mcpServers.name, slug: mcpServers.slug },
|
|
org: { id: organizations.id, name: organizations.name },
|
|
})
|
|
.from(builds)
|
|
.innerJoin(mcpServers, eq(mcpServers.id, builds.serverId))
|
|
.innerJoin(organizations, eq(organizations.id, mcpServers.orgId))
|
|
.orderBy(desc(builds.createdAt))
|
|
.limit(parsed.data.limit);
|
|
|
|
const filtered = parsed.data.status
|
|
? rows.filter((r) => r.build.status === parsed.data.status)
|
|
: rows;
|
|
|
|
return reply.send({ builds: filtered });
|
|
});
|
|
|
|
app.get('/v1/admin/builds/:id/logs', { preHandler: requireAdmin }, async (req, reply) => {
|
|
const Params = z.object({ id: z.string().uuid() });
|
|
const p = Params.safeParse(req.params);
|
|
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
|
|
const logs = await db
|
|
.select()
|
|
.from(buildLogs)
|
|
.where(eq(buildLogs.buildId, p.data.id))
|
|
.orderBy(buildLogs.timestamp);
|
|
return reply.send({ logs });
|
|
});
|
|
|
|
// ---- Audit (system-wide) ----
|
|
app.get('/v1/admin/audit', { preHandler: requireAdmin }, async (req, reply) => {
|
|
const Query = z.object({
|
|
action: z.string().optional(),
|
|
resourceType: z.string().optional(),
|
|
limit: z.coerce.number().min(1).max(1000).default(200),
|
|
});
|
|
const parsed = Query.safeParse(req.query);
|
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
|
|
|
|
let rows = await db
|
|
.select({
|
|
entry: auditLog,
|
|
userEmail: users.email,
|
|
})
|
|
.from(auditLog)
|
|
.leftJoin(users, eq(users.id, auditLog.userId))
|
|
.orderBy(desc(auditLog.createdAt))
|
|
.limit(parsed.data.limit);
|
|
|
|
if (parsed.data.action) {
|
|
rows = rows.filter((r) => r.entry.action === parsed.data.action);
|
|
}
|
|
if (parsed.data.resourceType) {
|
|
rows = rows.filter((r) => r.entry.resourceType === parsed.data.resourceType);
|
|
}
|
|
return reply.send({ entries: rows });
|
|
});
|
|
|
|
// ---- System health ----
|
|
app.get('/v1/admin/system', { preHandler: requireAdmin }, async (_req, reply) => {
|
|
const t0 = Date.now();
|
|
// DB probe
|
|
let dbOk = false;
|
|
let dbLatency: number | null = null;
|
|
try {
|
|
const start = Date.now();
|
|
await db.execute(sql`SELECT 1`);
|
|
dbLatency = Date.now() - start;
|
|
dbOk = true;
|
|
} catch {
|
|
// remains false
|
|
}
|
|
|
|
// Redis probe
|
|
let redisOk = false;
|
|
let redisLatency: number | null = null;
|
|
let queueDepth: number | null = null;
|
|
try {
|
|
const start = Date.now();
|
|
const pong = await getRedis().ping();
|
|
redisLatency = Date.now() - start;
|
|
redisOk = pong === 'PONG';
|
|
const q = getBuildQueue();
|
|
const counts = await q.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed');
|
|
queueDepth =
|
|
(counts.waiting ?? 0) +
|
|
(counts.active ?? 0) +
|
|
(counts.delayed ?? 0);
|
|
} catch {
|
|
// remains false
|
|
}
|
|
|
|
// Docker container count
|
|
const containerCount = await new Promise<number | null>((resolve) => {
|
|
const child = spawn('docker', ['ps', '--filter', 'name=bmm-mcp-', '-q'], {
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
shell: process.platform === 'win32',
|
|
});
|
|
let out = '';
|
|
child.stdout?.on('data', (d: Buffer) => {
|
|
out += d.toString();
|
|
});
|
|
child.on('close', (code) => {
|
|
if (code !== 0) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
const lines = out
|
|
.split(/\r?\n/)
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
resolve(lines.length);
|
|
});
|
|
child.on('error', () => resolve(null));
|
|
});
|
|
|
|
return reply.send({
|
|
probedAtMs: Date.now() - t0,
|
|
db: { ok: dbOk, latencyMs: dbLatency },
|
|
redis: { ok: redisOk, latencyMs: redisLatency, queueDepth },
|
|
docker: { containerCount },
|
|
});
|
|
});
|
|
|
|
// ---- System prompt (read + override) ----
|
|
app.get('/v1/admin/prompt', { preHandler: requireAdmin }, async (_req, reply) => {
|
|
const [row] = await db
|
|
.select()
|
|
.from(adminSettings)
|
|
.where(eq(adminSettings.key, PROMPT_KEY))
|
|
.limit(1);
|
|
return reply.send({
|
|
builtin: SYSTEM_PROMPT,
|
|
override: row?.value ?? null,
|
|
updatedAt: row?.updatedAt ?? null,
|
|
});
|
|
});
|
|
|
|
app.patch('/v1/admin/prompt', { preHandler: requireAdmin }, async (req, reply) => {
|
|
const Body = z.object({
|
|
value: z.string().min(1).max(20_000).nullable(),
|
|
});
|
|
const parsed = Body.safeParse(req.body);
|
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
|
|
|
|
if (parsed.data.value === null) {
|
|
await db.delete(adminSettings).where(eq(adminSettings.key, PROMPT_KEY));
|
|
} else {
|
|
const [existing] = await db
|
|
.select()
|
|
.from(adminSettings)
|
|
.where(eq(adminSettings.key, PROMPT_KEY))
|
|
.limit(1);
|
|
if (existing) {
|
|
await db
|
|
.update(adminSettings)
|
|
.set({
|
|
value: parsed.data.value,
|
|
updatedAt: new Date(),
|
|
updatedBy: req.user!.userId,
|
|
})
|
|
.where(eq(adminSettings.id, existing.id));
|
|
} else {
|
|
await db.insert(adminSettings).values({
|
|
key: PROMPT_KEY,
|
|
value: parsed.data.value,
|
|
updatedBy: req.user!.userId,
|
|
});
|
|
}
|
|
}
|
|
await audit({
|
|
orgId: req.user!.orgId,
|
|
userId: req.user!.userId,
|
|
action: 'admin.prompt.update',
|
|
resourceType: 'admin_settings',
|
|
resourceId: PROMPT_KEY,
|
|
metadata: { hasOverride: parsed.data.value !== null },
|
|
ipAddress: req.ip,
|
|
});
|
|
return reply.send({ ok: true });
|
|
});
|
|
|
|
void inArray; // referenced for future bulk operations
|
|
}
|