Fill the remaining gaps: badge upload, radio tools, VPN, writeable boxes
Built the admin tools previously listed as missing: - Badge upload (/admin/badges): uploads a <code>.gif into the emulator's badge dir via BADGE_UPLOAD_DIR (node:fs); validated code/type/size, logged. Made configurable rather than skipped. - Radio tools: /admin/radio/api-keys (CRUD, server-generated keys), /admin/radio/autodj (Auto-DJ playlist CRUD), /admin/radio/embed (embed snippet generator), /admin/radio/points (points settings), /admin/radio/monitoring (live stream/now-playing/listeners status). radio_api_keys + radio_auto_dj_playlist already had real columns. - /admin/vpn: VPN/proxy detection config (block toggle + provider + key), complementing /admin/ip's raw blacklist. - Writeable boxes: new website_writeable_boxes table (model + migration 0006) + /admin/writeable-boxes CRUD; active boxes render on the public home page. env: BADGE_UPLOAD_DIR. Verified live (prod, amx_test): all 8 pages render with real data; a test writeable box appeared on the public home and was reverted. tsc 0, vitest 49/49, next build 0 (7 new admin routes).
This commit is contained in:
1 parent
e004dfedaf
commit
0cd4d06ff6
21 files changed
+2443
-61
No files matched your search
@@ -0,0 +1,66 @@
|
||||
"use server";
|
||||
|
||||
import path from "node:path";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// Writes a badge image to the configured emulator badge directory. The path is
|
||||
// read from BADGE_UPLOAD_DIR so deployments can point it at their emulator's
|
||||
// `swf/c_images/album1584` (or equivalent) without code changes. AtomCMS only
|
||||
// ever stores .gif badges, so every upload is normalised to `<code>.gif`.
|
||||
|
||||
const CODE_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const MAX_BYTES = 1024 * 1024; // 1MB
|
||||
const ALLOWED_TYPES = new Set(["image/gif", "image/png"]);
|
||||
|
||||
function back(param: string, value: string): never {
|
||||
redirect(`/admin/badges?${param}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
|
||||
export async function uploadBadge(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const dir = process.env.BADGE_UPLOAD_DIR;
|
||||
if (!dir) {
|
||||
back("error", "Badge upload directory not configured");
|
||||
}
|
||||
|
||||
const code = String(formData.get("code") ?? "").trim();
|
||||
if (!CODE_RE.test(code)) {
|
||||
back("error", "Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)");
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
back("error", "No file uploaded");
|
||||
}
|
||||
|
||||
if (file.size === 0) {
|
||||
back("error", "Uploaded file is empty");
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
back("error", "File too large (max 1MB)");
|
||||
}
|
||||
if (!ALLOWED_TYPES.has(file.type)) {
|
||||
back("error", "File must be a GIF or PNG image");
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const target = path.join(dir, `${code}.gif`);
|
||||
await writeFile(target, buffer);
|
||||
} catch {
|
||||
back("error", "Could not write the badge file to disk");
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "badge_upload",
|
||||
description: `Uploaded badge image "${code}.gif"`,
|
||||
targetType: "badge",
|
||||
});
|
||||
|
||||
redirect(`/admin/badges?uploaded=${encodeURIComponent(code)}`);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use server";
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// Radio API keys (radio_api_keys). External integrations (AzureCast bridges,
|
||||
// widgets, bots) authenticate with a server-generated key. The key itself is
|
||||
// minted here with crypto.randomBytes — never accepted from the form — and the
|
||||
// `permissions` JSON column is intentionally left untouched by this CMS slice.
|
||||
|
||||
function str(raw: FormDataEntryValue | null): string {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
/** Parse a BigInt id from a form value, or null when blank/invalid. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
const s = str(raw).trim();
|
||||
if (!s) return null;
|
||||
try {
|
||||
return BigInt(s);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clamp a form value to a non-negative integer (defaulting to `fallback`). */
|
||||
function intOr(raw: FormDataEntryValue | null, fallback: number): number {
|
||||
const n = Number(str(raw).trim());
|
||||
if (!Number.isFinite(n) || n < 0) return fallback;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
export async function createApiKey(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const rateLimit = intOr(formData.get("rateLimit"), 300);
|
||||
const allowedIps = str(formData.get("allowedIps")).trim().slice(0, 255) || null;
|
||||
|
||||
// Server-side key generation — 24 random bytes → 48 hex chars (fits VarChar(64)).
|
||||
const key = randomBytes(24).toString("hex");
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const created = await prisma.radioApiKeys.create({
|
||||
data: {
|
||||
name,
|
||||
key,
|
||||
allowedIps,
|
||||
rateLimit,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_create",
|
||||
description: `Created radio API key "${name}" (#${created.id}, rate limit ${rateLimit})`,
|
||||
targetType: "radio_api_key",
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch {
|
||||
// Unique-key collision (astronomically unlikely) or DB down — fail soft.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
redirect("/admin/radio/api-keys?created=1");
|
||||
}
|
||||
|
||||
export async function toggleApiKey(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id == null) return;
|
||||
|
||||
try {
|
||||
const existing = await prisma.radioApiKeys.findUnique({
|
||||
where: { id },
|
||||
select: { name: true, isActive: true },
|
||||
});
|
||||
if (!existing) return;
|
||||
|
||||
const next = !existing.isActive;
|
||||
await prisma.radioApiKeys.update({
|
||||
where: { id },
|
||||
data: { isActive: next, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_toggle",
|
||||
description: `${next ? "Activated" : "Deactivated"} radio API key "${existing.name}" (#${id})`,
|
||||
targetType: "radio_api_key",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
}
|
||||
|
||||
export async function deleteApiKey(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id == null) return;
|
||||
|
||||
try {
|
||||
await prisma.radioApiKeys.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_delete",
|
||||
description: `Deleted radio API key #${id}`,
|
||||
targetType: "radio_api_key",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { requireStaff } from '@/lib/admin/guard';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { logStaffActivity } from '@/lib/services/staff-activity';
|
||||
|
||||
// AutoDJ playlist CRUD (radio_auto_dj_playlist). CMS-owned table backing the
|
||||
// fallback playlist the radio rotates through when no live DJ is streaming.
|
||||
// Faithful to AtomCMS: a flat list of tracks ordered by sort_order then title.
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function str(raw: FormDataEntryValue | null): string {
|
||||
return typeof raw === 'string' ? raw : '';
|
||||
}
|
||||
|
||||
/** Checkbox/select truthiness: '1', 'true', 'on' → true. */
|
||||
function bool(raw: FormDataEntryValue | null): boolean {
|
||||
const v = str(raw).trim().toLowerCase();
|
||||
return v === '1' || v === 'true' || v === 'on';
|
||||
}
|
||||
|
||||
/** Parse a non-negative UnsignedInt, falling back to 0. */
|
||||
function reqUInt(raw: FormDataEntryValue | null): number {
|
||||
const n = Number(str(raw).trim());
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return Math.trunc(n);
|
||||
}
|
||||
|
||||
/** Parse an optional non-negative UnsignedInt; blank/invalid/negative → null. */
|
||||
function optUInt(raw: FormDataEntryValue | null): number | null {
|
||||
const s = str(raw).trim();
|
||||
if (s === '') return null;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
return Math.trunc(n);
|
||||
}
|
||||
|
||||
// ── AutoDJ playlist CRUD (radio_auto_dj_playlist) ────────────────────────
|
||||
|
||||
export async function createTrack(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const title = str(formData.get('title')).trim().slice(0, 255);
|
||||
if (!title) return;
|
||||
|
||||
const artist = str(formData.get('artist')).trim().slice(0, 255);
|
||||
const album = str(formData.get('album')).trim().slice(0, 255);
|
||||
const artworkUrl = str(formData.get('artworkUrl')).trim().slice(0, 255);
|
||||
const duration = optUInt(formData.get('duration'));
|
||||
const sortOrder = reqUInt(formData.get('sortOrder'));
|
||||
const isActive = bool(formData.get('isActive'));
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const created = await prisma.radioAutoDjPlaylist.create({
|
||||
data: {
|
||||
title,
|
||||
artist: artist || null,
|
||||
album: album || null,
|
||||
artworkUrl: artworkUrl || null,
|
||||
duration,
|
||||
sortOrder,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: 'radio_autodj_create',
|
||||
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ''}`,
|
||||
targetType: 'radio_auto_dj_track',
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch {
|
||||
// Fail soft — DB unavailable; re-render without throwing.
|
||||
}
|
||||
revalidatePath('/admin/radio/autodj');
|
||||
}
|
||||
|
||||
export async function toggleTrack(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get('id'));
|
||||
if (id === null) return;
|
||||
|
||||
// The form posts the desired next state so the toggle is idempotent.
|
||||
const isActive = bool(formData.get('isActive'));
|
||||
|
||||
try {
|
||||
await prisma.radioAutoDjPlaylist.update({
|
||||
where: { id },
|
||||
data: { isActive, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: 'radio_autodj_toggle',
|
||||
description: `${isActive ? 'Activated' : 'Deactivated'} AutoDJ track #${id}`,
|
||||
targetType: 'radio_auto_dj_track',
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
revalidatePath('/admin/radio/autodj');
|
||||
}
|
||||
|
||||
export async function deleteTrack(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get('id'));
|
||||
if (id === null) return;
|
||||
|
||||
try {
|
||||
await prisma.radioAutoDjPlaylist.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: 'radio_autodj_delete',
|
||||
description: `Deleted AutoDJ track #${id}`,
|
||||
targetType: 'radio_auto_dj_track',
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
revalidatePath('/admin/radio/autodj');
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Radio listener-points settings (website_settings radio_points_* keys).
|
||||
// Mirrors AtomCMS's RadioPoints Filament page: key/value rows in
|
||||
// website_settings that reward listeners for time spent on the radio. Booleans
|
||||
// use the string '0' / '1'. Busts the siteSettings cache so the public radio
|
||||
// pages pick the change up immediately.
|
||||
|
||||
const POINTS_KEYS = [
|
||||
"radio_points_enabled",
|
||||
"radio_points_per_minute",
|
||||
"radio_points_currency",
|
||||
"radio_points_max_per_day",
|
||||
"radio_points_min_listeners",
|
||||
] as const;
|
||||
|
||||
const ALLOWED_CURRENCIES = new Set(["credits", "duckets", "diamonds", "points"]);
|
||||
|
||||
function str(raw: FormDataEntryValue | null): string {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
/** Checkbox/select truthiness → '1' / '0'. */
|
||||
function boolStr(raw: FormDataEntryValue | null): "0" | "1" {
|
||||
const v = str(raw).trim().toLowerCase();
|
||||
return v === "1" || v === "true" || v === "on" ? "1" : "0";
|
||||
}
|
||||
|
||||
/** Clamp a form value to a non-negative integer string (defaulting to 0). */
|
||||
function intStr(raw: FormDataEntryValue | null): string {
|
||||
const n = Number(str(raw).trim());
|
||||
if (!Number.isFinite(n) || n < 0) return "0";
|
||||
return String(Math.floor(n));
|
||||
}
|
||||
|
||||
export async function savePoints(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const currencyRaw = str(formData.get("radio_points_currency")).trim().toLowerCase();
|
||||
const currency = ALLOWED_CURRENCIES.has(currencyRaw) ? currencyRaw : "credits";
|
||||
|
||||
const values: Record<(typeof POINTS_KEYS)[number], string> = {
|
||||
radio_points_enabled: boolStr(formData.get("radio_points_enabled")),
|
||||
radio_points_per_minute: intStr(formData.get("radio_points_per_minute")),
|
||||
radio_points_currency: currency,
|
||||
radio_points_max_per_day: intStr(formData.get("radio_points_max_per_day")),
|
||||
radio_points_min_listeners: intStr(formData.get("radio_points_min_listeners")),
|
||||
};
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
POINTS_KEYS.map((key) =>
|
||||
prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value: values[key] },
|
||||
create: { key, value: values[key], comment: "Radio points" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_points_update",
|
||||
description: `Updated radio listener-points settings (enabled=${values.radio_points_enabled}, ${values.radio_points_per_minute}/min ${currency})`,
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft so the action does not throw.
|
||||
}
|
||||
|
||||
revalidatePath("/admin/radio/points");
|
||||
redirect("/admin/radio/points?saved=1");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// VPN / proxy detection config. Stored as website_settings key/value rows
|
||||
// (CMS-owned, BigInt id). Booleans use the strings "0" / "1", faithful to
|
||||
// AtomCMS's setting() convention. This is registration-time protection only;
|
||||
// the raw IP allow/deny list lives under /admin/ip (website_ip_*).
|
||||
|
||||
const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]);
|
||||
|
||||
/** Upsert one website_settings key with a stable housekeeping comment. */
|
||||
async function writeSetting(key: string, value: string, comment: string): Promise<void> {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment },
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveVpn(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
// Toggle: an unchecked checkbox submits nothing, so absence === disabled.
|
||||
const enabled = String(formData.get("vpn_block_enabled") ?? "").trim() !== "";
|
||||
|
||||
const providerRaw = String(formData.get("vpn_provider") ?? "").trim().toLowerCase();
|
||||
const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none";
|
||||
|
||||
const apiKey = String(formData.get("vpn_api_key") ?? "").trim().slice(0, 255);
|
||||
const blockMessage = String(formData.get("vpn_block_message") ?? "").trim().slice(0, 255);
|
||||
|
||||
try {
|
||||
await writeSetting(
|
||||
"vpn_block_enabled",
|
||||
enabled ? "1" : "0",
|
||||
"Block registrations from detected VPN/proxy IPs (0=no, 1=yes)",
|
||||
);
|
||||
await writeSetting(
|
||||
"vpn_provider",
|
||||
provider,
|
||||
"VPN/proxy detection provider (none/proxycheck/ipqualityscore)",
|
||||
);
|
||||
await writeSetting("vpn_api_key", apiKey, "API key for the VPN/proxy detection provider");
|
||||
await writeSetting(
|
||||
"vpn_block_message",
|
||||
blockMessage,
|
||||
"Message shown to users blocked for using a VPN/proxy",
|
||||
);
|
||||
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "vpn_update",
|
||||
description: `Updated VPN/proxy detection (block=${enabled ? "on" : "off"}, provider=${provider})`,
|
||||
});
|
||||
revalidatePath("/admin/vpn");
|
||||
} catch {
|
||||
// DB unavailable — fail soft so the action does not throw; the page
|
||||
// re-renders the current (stored) state.
|
||||
}
|
||||
|
||||
redirect("/admin/vpn?saved=1");
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// Writeable boxes (website_writeable_boxes). CMS-owned table backing the
|
||||
// content panels rendered on the public home page. Active boxes (is_active)
|
||||
// are the ones shown publicly, ordered by `position`.
|
||||
|
||||
/** Parse a non-negative Int form value, falling back to 0. */
|
||||
function reqInt(formData: FormData, key: string): number {
|
||||
const raw = String(formData.get(key) ?? "").trim();
|
||||
if (raw === "") return 0;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
/** Parse the BigInt `id` form value, returning null when blank/invalid. */
|
||||
function parseId(formData: FormData): bigint | null {
|
||||
const raw = String(formData.get("id") ?? "").trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return BigInt(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function revalidate(): void {
|
||||
revalidatePath("/admin/writeable-boxes");
|
||||
// Active boxes render on the public home page (root layout).
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
export async function createBox(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const title = String(formData.get("title") ?? "").trim().slice(0, 255);
|
||||
if (!title) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const created = await prisma.websiteWriteableBoxes.create({
|
||||
data: {
|
||||
title,
|
||||
icon: (String(formData.get("icon") ?? "").trim().slice(0, 255)) || null,
|
||||
content: String(formData.get("content") ?? ""),
|
||||
position: reqInt(formData, "position"),
|
||||
isActive: String(formData.get("isActive") ?? "") === "1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_create",
|
||||
description: `Created writeable box "${title}" (#${created.id})`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — swallow and re-render.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidate();
|
||||
}
|
||||
|
||||
export async function updateBox(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const id = parseId(formData);
|
||||
if (id == null) return;
|
||||
|
||||
const title = String(formData.get("title") ?? "").trim().slice(0, 255);
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteWriteableBoxes.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title,
|
||||
icon: (String(formData.get("icon") ?? "").trim().slice(0, 255)) || null,
|
||||
content: String(formData.get("content") ?? ""),
|
||||
position: reqInt(formData, "position"),
|
||||
isActive: String(formData.get("isActive") ?? "") === "1",
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_update",
|
||||
description: `Updated writeable box #${id} ("${title}")`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidate();
|
||||
}
|
||||
|
||||
export async function deleteBox(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const id = parseId(formData);
|
||||
if (id == null) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteWriteableBoxes.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_delete",
|
||||
description: `Deleted writeable box #${id}`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidate();
|
||||
}
|
||||
|
||||
export async function toggleBox(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const id = parseId(formData);
|
||||
if (id == null) return;
|
||||
|
||||
// `next` carries the desired state ("1" to activate, anything else to hide).
|
||||
const next = String(formData.get("next") ?? "") === "1";
|
||||
|
||||
try {
|
||||
await prisma.websiteWriteableBoxes.update({
|
||||
where: { id },
|
||||
data: { isActive: next, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_toggle",
|
||||
description: `${next ? "Activated" : "Hid"} writeable box #${id}`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidate();
|
||||
}
|
||||
Reference in new issue
Block a user