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).
138 lines
4.5 KiB
TypeScript
138 lines
4.5 KiB
TypeScript
'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');
|
|
}
|