Add security middleware, audit log, alerts, PayPal, cron, radio + apps
Security (launch blockers): - src/middleware.ts (edge): forwards x-pathname + real client IP. - access-guard.ts (Node, from root layout): routes non-staff to /maintenance when maintenance mode is on, banned users to /banned. New /banned + /maintenance pages (the consumers the admin toggle was missing). Admin layout enforces force_staff_2fa before /admin. - staff-activity.ts audit log wired into ban/lift/give-currency/set-rank actions. Infra (parallel agents): alert service (alert_logs + Discord embed + email), PayPal top-up (create/capture API routes + /shop/topup), cron worker (scripts/jobs-worker.ts via croner: emulator-ping->alert, maintenance-check, bans-cleanup), social connections page, admin radio settings/banners/ranks. Public radio subsystem: /radio (+schedule, shouts+post, contests, giveaways, apply, leaderboard) and /apply/staff + /apply/team submission forms. Radio nav link added. .env.example documents the new optional vars. (radio song-requests dropped: its table is a stub in AtomCMS — columns added by un-modeled alter-migrations.) Verified: tsc exit 0, vitest 48/48, next build exit 0 (82 page routes).
This commit is contained in:
1 parent
e19debb795
commit
22d53d0e9c
40 files changed
+3781
-6
No files matched your search
@@ -6,6 +6,39 @@ DATABASE_URL=mysql://user:[email protected]:3306/atomcms
|
||||
# Optional pool tuning (defaults shown)
|
||||
DATABASE_POOL_SIZE=40
|
||||
DATABASE_IDLE_TIMEOUT_MS=300000
|
||||
DATABASE_CONNECT_TIMEOUT_MS=10000
|
||||
|
||||
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
|
||||
HOTEL_NAME=Atom
|
||||
APP_URL=http://localhost:3000
|
||||
|
||||
# NextAuth (>=32 chars) + Laravel APP_KEY (base64:...) for existing 2FA secrets
|
||||
AUTH_SECRET=
|
||||
APP_KEY=
|
||||
CONVERT_PASSWORDS=false
|
||||
|
||||
# RCON link to the Arcturus emulator
|
||||
RCON_HOST=127.0.0.1
|
||||
RCON_PORT=3001
|
||||
|
||||
# Optional OAuth (enabled when both id+secret are set)
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Optional SMTP (password reset / alert emails)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
|
||||
# Optional alerting (jobs worker / alert service)
|
||||
DISCORD_WEBHOOK_URL=
|
||||
ALERT_EMAIL=
|
||||
|
||||
# Optional PayPal top-up (sandbox by default)
|
||||
PAYPAL_CLIENT_ID=
|
||||
PAYPAL_SECRET=
|
||||
PAYPAL_API=https://api-m.sandbox.paypal.com
|
||||
+3
-1
@@ -14,12 +14,14 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status"
|
||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||
"jobs:worker": "tsx scripts/jobs-worker.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-mariadb": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"croner": "^10.0.1",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"next": "^16.2.9",
|
||||
"next-auth": "5.0.0-beta.31",
|
||||
|
||||
Generated
+9
@@ -17,6 +17,9 @@ importers:
|
||||
bcryptjs:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.3
|
||||
croner:
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1
|
||||
hash-wasm:
|
||||
specifier: ^4.12.0
|
||||
version: 4.12.0
|
||||
@@ -1483,6 +1486,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
|
||||
engines: {node: '>=18.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -3198,6 +3205,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Standalone cron worker for AtomCMS-Next.
|
||||
*
|
||||
* Replaces Laravel's scheduler (app/Console/Kernel.php + queue:work) with a
|
||||
* single long-running process driven by `croner`. Run it OUTSIDE the Next.js
|
||||
* request lifecycle (e.g. a separate container / pm2 process):
|
||||
*
|
||||
* pnpm jobs:worker # -> tsx scripts/jobs-worker.ts
|
||||
*
|
||||
* Each job body is wrapped in its own try/catch so one failing tick never
|
||||
* tears down the scheduler; a thrown error or a falsy result is logged and the
|
||||
* worker keeps ticking. Croner runs ticks serially per-job (protect: true) so a
|
||||
* slow run can't overlap itself.
|
||||
*
|
||||
* DEPENDENCIES THIS FILE EXPECTS (orchestrator must provide them — noted here
|
||||
* so the build isn't silently broken):
|
||||
* - `croner` package (not yet in package.json): `pnpm add croner`.
|
||||
* - `@/lib/services/alert` exporting `alert.emulatorOffline()` — a thin
|
||||
* notifier (email/Discord) for emulator-down events. Not present yet; this
|
||||
* worker is the first consumer.
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { Cron } from "croner";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { emulatorOffline } from "@/lib/services/alert";
|
||||
|
||||
// --- small logging helper (timestamped, namespaced) ------------------------
|
||||
|
||||
function log(scope: string, msg: string, ...rest: unknown[]): void {
|
||||
console.log(`[jobs:${scope}] ${new Date().toISOString()} ${msg}`, ...rest);
|
||||
}
|
||||
function logErr(scope: string, msg: string, e: unknown): void {
|
||||
console.error(`[jobs:${scope}] ${new Date().toISOString()} ${msg}`, e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
// --- (a) emulator health ping — every minute -------------------------------
|
||||
//
|
||||
// Our RconClient has no `isConnected`: the TCP transport is fire-and-forget and
|
||||
// resolves `false` on a dead/unreachable socket (it never throws for that). So
|
||||
// we treat BOTH a thrown error AND a falsy `send()` result as "offline" and
|
||||
// fire the alert. `data: null` matches the no-payload command shape AtomCMS
|
||||
// uses for keep-alive style commands.
|
||||
|
||||
async function pingEmulator(): Promise<void> {
|
||||
let online = false;
|
||||
try {
|
||||
online = await rcon.send("ping", null);
|
||||
} catch (e) {
|
||||
logErr("ping", "rcon.send threw", e);
|
||||
online = false;
|
||||
}
|
||||
|
||||
if (online) {
|
||||
log("ping", "emulator reachable");
|
||||
return;
|
||||
}
|
||||
|
||||
log("ping", "emulator unreachable — raising offline alert");
|
||||
try {
|
||||
await emulatorOffline();
|
||||
} catch (e) {
|
||||
// Never let the alert channel failing crash the tick.
|
||||
logErr("ping", "emulatorOffline failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- (b) maintenance:check — every minute ----------------------------------
|
||||
//
|
||||
// website_maintenance_tasks has no scheduled-time column (id, userId, task,
|
||||
// completed, timestamps), so "scheduled maintenance" == at least one row with
|
||||
// completed = false. When such a task exists we flip the `maintenance_enabled`
|
||||
// website_setting on; otherwise we clear it. The setting value is the canonical
|
||||
// '1' / '0' string the rest of the CMS reads (see SiteSettings.getBool).
|
||||
//
|
||||
// If the model isn't present in the running schema (older DB), the prisma call
|
||||
// throws and we skip — the catch keeps the worker alive.
|
||||
|
||||
async function maintenanceCheck(): Promise<void> {
|
||||
let pending = 0;
|
||||
try {
|
||||
pending = await prisma.websiteMaintenanceTasks.count({
|
||||
where: { completed: false },
|
||||
});
|
||||
} catch (e) {
|
||||
// Table absent / DB unreachable — skip this tick rather than thrashing.
|
||||
logErr("maintenance", "could not read website_maintenance_tasks — skipping", e);
|
||||
return;
|
||||
}
|
||||
|
||||
const desired = pending > 0 ? "1" : "0";
|
||||
|
||||
try {
|
||||
const current = await prisma.websiteSetting.findUnique({
|
||||
where: { key: "maintenance_enabled" },
|
||||
select: { value: true },
|
||||
});
|
||||
|
||||
if (current?.value === desired) {
|
||||
log("maintenance", `no change (maintenance_enabled=${desired}, pending=${pending})`);
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key: "maintenance_enabled" },
|
||||
update: { value: desired },
|
||||
create: { key: "maintenance_enabled", value: desired, comment: "Toggled by jobs-worker" },
|
||||
});
|
||||
log("maintenance", `maintenance_enabled -> ${desired} (pending tasks: ${pending})`);
|
||||
} catch (e) {
|
||||
logErr("maintenance", "failed to toggle maintenance_enabled", e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- (c) bans cleanup — hourly ---------------------------------------------
|
||||
//
|
||||
// Bans expire purely by the query-time `ban_expire > now()` filter applied
|
||||
// wherever bans are read, so there's nothing to delete on a schedule. We keep
|
||||
// the hourly tick for observability (and an obvious hook if a future hard-purge
|
||||
// is ever wanted).
|
||||
|
||||
async function bansCleanup(): Promise<void> {
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const active = await prisma.ban.count({ where: { banExpire: { gt: now } } });
|
||||
log("bans", `cleanup is automatic via ban_expire>now filter — ${active} active ban(s), nothing to purge`);
|
||||
} catch (e) {
|
||||
logErr("bans", "count failed (non-fatal)", e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- scheduler wiring ------------------------------------------------------
|
||||
|
||||
const jobs: Cron[] = [
|
||||
new Cron("* * * * *", { name: "emulator-ping", protect: true }, pingEmulator),
|
||||
new Cron("* * * * *", { name: "maintenance-check", protect: true }, maintenanceCheck),
|
||||
new Cron("0 * * * *", { name: "bans-cleanup", protect: true }, bansCleanup),
|
||||
];
|
||||
|
||||
log("worker", `started — ${jobs.length} scheduled job(s): ${jobs.map((j) => j.name).join(", ")}`);
|
||||
|
||||
// Run once immediately on boot so we don't wait up to a minute for first signal.
|
||||
void pingEmulator();
|
||||
void maintenanceCheck();
|
||||
|
||||
// --- graceful shutdown -----------------------------------------------------
|
||||
|
||||
async function shutdown(signal: string): Promise<void> {
|
||||
log("worker", `received ${signal} — stopping jobs and disconnecting`);
|
||||
for (const j of jobs) j.stop();
|
||||
try {
|
||||
await prisma.$disconnect();
|
||||
} catch {
|
||||
// ignore — we're exiting anyway
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
// Don't let an unexpected async throw kill the whole worker.
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
logErr("worker", "unhandledRejection", reason);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
type BanType = "account" | "ip" | "machine" | "super";
|
||||
const BAN_TYPES: ReadonlySet<string> = new Set(["account", "ip", "machine", "super"]);
|
||||
@@ -40,12 +41,26 @@ export async function createBan(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
if (user) await rcon.disconnectUser(userId, user.username);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "user_ban",
|
||||
description: `Banned user #${userId} (${type}, ${hours > 0 ? `${hours}h` : "permanent"}): ${reason}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
revalidatePath("/admin/bans");
|
||||
}
|
||||
|
||||
export async function liftBan(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
const id = Number(formData.get("id"));
|
||||
if (id > 0) await prisma.ban.delete({ where: { id } });
|
||||
if (id > 0) {
|
||||
await prisma.ban.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ban_lift",
|
||||
description: `Lifted ban #${id}`,
|
||||
});
|
||||
}
|
||||
revalidatePath("/admin/bans");
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { requireStaff } from '@/lib/admin/guard';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { siteSettings } from '@/lib/services/site-settings';
|
||||
|
||||
// ── 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';
|
||||
}
|
||||
|
||||
// ── Radio settings (website_settings radio_* keys) ─────────────────────────
|
||||
|
||||
/**
|
||||
* Upsert one radio_* website_settings key. Mirrors AtomCMS's
|
||||
* RadioSettings Filament page (key/value rows in website_settings). Busts the
|
||||
* siteSettings cache so the public radio pages pick the change up immediately.
|
||||
*/
|
||||
export async function saveRadioSetting(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const key = str(formData.get('key')).trim().slice(0, 255);
|
||||
const value = str(formData.get('value'));
|
||||
const comment = str(formData.get('comment')).trim().slice(0, 255);
|
||||
if (!key) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: comment || null },
|
||||
});
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
// DB unavailable — fail soft so the action does not throw.
|
||||
}
|
||||
revalidatePath('/admin/radio/settings');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-save every radio_* field submitted by the settings form in one pass.
|
||||
* The form posts a hidden `__keys` field listing the keys it rendered so we
|
||||
* only touch those (and never wipe unrelated settings).
|
||||
*/
|
||||
export async function saveRadioSettings(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const keysRaw = str(formData.get('__keys'));
|
||||
const keys = keysRaw
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.startsWith('radio_') || k.startsWith('auto_dj_'));
|
||||
if (keys.length === 0) return;
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
keys.map((key) => {
|
||||
const value = str(formData.get(key));
|
||||
return prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: null },
|
||||
});
|
||||
}),
|
||||
);
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
// Fail soft.
|
||||
}
|
||||
revalidatePath('/admin/radio/settings');
|
||||
}
|
||||
|
||||
// ── Radio banners CRUD (radio_banners) ─────────────────────────────────────
|
||||
|
||||
export async function createRadioBanner(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const imagePath = str(formData.get('imagePath')).trim().slice(0, 255);
|
||||
if (!imagePath) return;
|
||||
|
||||
const title = str(formData.get('title')).trim().slice(0, 255);
|
||||
const description = str(formData.get('description')).trim();
|
||||
const sortOrderNum = Number(str(formData.get('sortOrder')));
|
||||
const sortOrder = Number.isFinite(sortOrderNum) ? Math.trunc(sortOrderNum) : 0;
|
||||
const isActive = bool(formData.get('isActive'));
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await prisma.radioBanners.create({
|
||||
data: {
|
||||
userId: BigInt(staff.id),
|
||||
imagePath,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
sortOrder,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Fail soft.
|
||||
}
|
||||
revalidatePath('/admin/radio/banners');
|
||||
}
|
||||
|
||||
export async function updateRadioBanner(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = parseId(formData.get('id'));
|
||||
if (id === null) return;
|
||||
|
||||
const imagePath = str(formData.get('imagePath')).trim().slice(0, 255);
|
||||
const title = str(formData.get('title')).trim().slice(0, 255);
|
||||
const description = str(formData.get('description')).trim();
|
||||
const sortOrderNum = Number(str(formData.get('sortOrder')));
|
||||
const sortOrder = Number.isFinite(sortOrderNum) ? Math.trunc(sortOrderNum) : 0;
|
||||
const isActive = bool(formData.get('isActive'));
|
||||
if (!imagePath) return;
|
||||
|
||||
try {
|
||||
await prisma.radioBanners.update({
|
||||
where: { id },
|
||||
data: {
|
||||
imagePath,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
sortOrder,
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
revalidatePath('/admin/radio/banners');
|
||||
}
|
||||
|
||||
export async function deleteRadioBanner(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = parseId(formData.get('id'));
|
||||
if (id === null) return;
|
||||
try {
|
||||
await prisma.radioBanners.delete({ where: { id } });
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
revalidatePath('/admin/radio/banners');
|
||||
}
|
||||
|
||||
// ── Radio ranks CRUD (radio_ranks) ─────────────────────────────────────────
|
||||
|
||||
export async function createRadioRank(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const name = str(formData.get('name')).trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const description = str(formData.get('description')).trim().slice(0, 255);
|
||||
const badgeCode = str(formData.get('badgeCode')).trim().slice(0, 255);
|
||||
const isActive = bool(formData.get('isActive'));
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await prisma.radioRanks.create({
|
||||
data: {
|
||||
name,
|
||||
description: description || null,
|
||||
badgeCode: badgeCode || null,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Fail soft.
|
||||
}
|
||||
revalidatePath('/admin/radio/ranks');
|
||||
}
|
||||
|
||||
export async function updateRadioRank(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = parseId(formData.get('id'));
|
||||
if (id === null) return;
|
||||
|
||||
const name = str(formData.get('name')).trim().slice(0, 255);
|
||||
const description = str(formData.get('description')).trim().slice(0, 255);
|
||||
const badgeCode = str(formData.get('badgeCode')).trim().slice(0, 255);
|
||||
const isActive = bool(formData.get('isActive'));
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await prisma.radioRanks.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
description: description || null,
|
||||
badgeCode: badgeCode || null,
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
revalidatePath('/admin/radio/ranks');
|
||||
}
|
||||
|
||||
export async function deleteRadioRank(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = parseId(formData.get('id'));
|
||||
if (id === null) return;
|
||||
try {
|
||||
await prisma.radioRanks.delete({ where: { id } });
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
revalidatePath('/admin/radio/ranks');
|
||||
}
|
||||
@@ -5,16 +5,24 @@ import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const CURRENCIES: ReadonlySet<string> = new Set(["credits", "duckets", "diamonds", "points"]);
|
||||
|
||||
export async function giveCurrency(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const type = String(formData.get("type"));
|
||||
const amount = Number(formData.get("amount"));
|
||||
if (userId > 0 && amount > 0 && CURRENCIES.has(type)) {
|
||||
await sendCurrency({ rcon, db: prisma }, userId, type as CurrencyName, amount);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "give_currency",
|
||||
description: `Gave ${amount} ${type} to user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
}
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
}
|
||||
@@ -31,12 +39,19 @@ export async function setMotto(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
export async function setRank(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const rank = Number(formData.get("rank"));
|
||||
if (userId > 0 && rank > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: { rank } });
|
||||
await rcon.setRank(userId, rank);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "rank_change",
|
||||
description: `Set rank of user #${userId} to ${rank}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
}
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// AtomCMS validates the application body with `min:10`. Mirror that floor and
|
||||
// cap the write defensively (column is TEXT, but we keep applications sane).
|
||||
const CONTENT_MIN = 10;
|
||||
const CONTENT_MAX = 5000;
|
||||
|
||||
/**
|
||||
* Submit a STAFF application for an open position.
|
||||
*
|
||||
* Faithful to AtomCMS's StaffApplicationsController@store:
|
||||
* - the applicant (user_id) is re-read from the session via auth() and is
|
||||
* NEVER trusted from the submitted FormData;
|
||||
* - rank_id is the open position's permission id (the rank being applied for);
|
||||
* - a user may only apply once per rank (idempotency guard);
|
||||
* - content must be at least 10 characters.
|
||||
*/
|
||||
export async function applyStaff(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
// rank_id comes from the open position's permission_id (an Int in the schema).
|
||||
const rankId = Number(formData.get("rankId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
||||
|
||||
const content = String(formData.get("content") ?? "").trim().slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) return;
|
||||
|
||||
try {
|
||||
// Block duplicate applications for the same rank (AtomCMS hasAppliedForPosition).
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return;
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/apply/staff");
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a TEAM application.
|
||||
*
|
||||
* The Prisma `website_staff_applications` slice has no dedicated team column, so
|
||||
* (per the conversion brief) team applications REUSE the staff-applications
|
||||
* table with the team acting as the rank: rank_id carries the team id. The
|
||||
* applicant is re-read from the session, never trusted from the form, and a user
|
||||
* may only apply once per team.
|
||||
*/
|
||||
export async function applyTeam(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
// website_teams.id is a BigInt; rank_id on the application is an Int. The team
|
||||
// id is the application's rank flag.
|
||||
const rankId = Number(formData.get("teamId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
||||
|
||||
const content = String(formData.get("content") ?? "").trim().slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) return;
|
||||
|
||||
try {
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return;
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/apply/team");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Column bounds from prisma/schema.prisma (radio_applications):
|
||||
// real_name VARCHAR(255); the rest are TEXT. age is an INT.
|
||||
const NAME_MAX = 255;
|
||||
const TEXT_MAX = 5000;
|
||||
const STYLE_MAX = 5000;
|
||||
|
||||
function str(form: FormData, key: string, max: number): string {
|
||||
return String(form.get(key) ?? "").trim().slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a radio DJ application.
|
||||
*
|
||||
* The applicant (userId) is ALWAYS re-read from the session via auth() and is
|
||||
* never taken from the submitted FormData, so a crafted form cannot file an
|
||||
* application on behalf of another account. radio_applications.user_id is an
|
||||
* UnsignedBigInt, hence the BigInt() coercion.
|
||||
*/
|
||||
export async function applyDj(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
const realName = str(formData, "realName", NAME_MAX);
|
||||
const availability = str(formData, "availability", TEXT_MAX);
|
||||
const motivation = str(formData, "motivation", TEXT_MAX);
|
||||
const experience = str(formData, "experience", TEXT_MAX);
|
||||
const musicStyle = str(formData, "musicStyle", STYLE_MAX);
|
||||
|
||||
const ageRaw = Number(formData.get("age"));
|
||||
const age = Number.isInteger(ageRaw) ? ageRaw : 0;
|
||||
|
||||
// Required fields per the schema (NOT NULL): real_name, age, availability,
|
||||
// motivation. experience + music_style are nullable.
|
||||
if (!realName || !availability || !motivation || age <= 0) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.radioApplications.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
realName,
|
||||
age,
|
||||
availability,
|
||||
motivation,
|
||||
experience: experience || null,
|
||||
musicStyle: musicStyle || null,
|
||||
status: "pending",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable or duplicate — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/radio/apply");
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// radio_shouts.message is TEXT, but cap the write to keep shouts tweet-sized.
|
||||
const MESSAGE_MAX = 255;
|
||||
|
||||
/**
|
||||
* Post a radio shout.
|
||||
*
|
||||
* The AUTHOR (userId) is re-read from the session via auth() and is never
|
||||
* trusted from the submitted FormData, so a crafted form cannot impersonate
|
||||
* another account. radio_shouts.user_id is an UNSIGNED BIGINT, so the Int
|
||||
* session id is widened to BigInt for the insert.
|
||||
*/
|
||||
export async function postShout(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!message) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.radioShouts.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/radio/shouts");
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const staff = await requireStaff();
|
||||
|
||||
// Force staff 2FA before the admin panel (AtomCMS ForceStaffTwoFactorMiddleware).
|
||||
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
||||
const u = await prisma.user
|
||||
.findUnique({ where: { id: staff.id }, select: { twoFactorConfirmedAt: true } })
|
||||
.catch(() => null);
|
||||
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "190px 1fr", gap: "1.5rem" }}>
|
||||
<aside className="card" style={{ height: "fit-content" }}>
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
createRadioBanner,
|
||||
deleteRadioBanner,
|
||||
updateRadioBanner,
|
||||
} from '@/actions/admin-radio-extra';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Banner = {
|
||||
id: bigint;
|
||||
userId: bigint;
|
||||
imagePath: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date | null;
|
||||
};
|
||||
|
||||
function formatDate(d: Date | null): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
|
||||
}
|
||||
|
||||
export default async function AdminRadioBannersPage() {
|
||||
let banners: Banner[] = [];
|
||||
let dbError = false;
|
||||
|
||||
try {
|
||||
banners = await prisma.radioBanners.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
imagePath: true,
|
||||
title: true,
|
||||
description: true,
|
||||
sortOrder: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
} catch {
|
||||
dbError = true;
|
||||
banners = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
|
||||
<Link href="/admin/radio">Radio</Link> ·{' '}
|
||||
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
|
||||
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
|
||||
<Link href="/admin/radio/ranks">Ranks</Link>
|
||||
</nav>
|
||||
|
||||
<h1>Radio Banners</h1>
|
||||
<p className="muted">
|
||||
Promotional banners shown on the radio page, ordered by sort order.
|
||||
</p>
|
||||
|
||||
{dbError ? (
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Could not load banners (database unavailable).
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={createRadioBanner} className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add banner</h3>
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<label htmlFor="new_imagePath" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Image path / URL
|
||||
</label>
|
||||
<input
|
||||
id="new_imagePath"
|
||||
name="imagePath"
|
||||
placeholder="/storage/radio/banner.png"
|
||||
required
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new_title" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Title
|
||||
</label>
|
||||
<input id="new_title" name="title" style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label htmlFor="new_description" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="new_description"
|
||||
name="description"
|
||||
rows={2}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new_sortOrder" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Sort order
|
||||
</label>
|
||||
<input
|
||||
id="new_sortOrder"
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
defaultValue={0}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
|
||||
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create banner
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{banners.length === 0 ? (
|
||||
<p className="muted">No banners yet.</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{banners.map((b) => (
|
||||
<div key={String(b.id)} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<strong>#{String(b.id)}</strong>
|
||||
<span className="muted">
|
||||
by user {String(b.userId)} · {formatDate(b.createdAt)} ·{' '}
|
||||
{b.isActive ? 'active' : 'hidden'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{b.imagePath ? (
|
||||
<img
|
||||
src={b.imagePath}
|
||||
alt={b.title ?? 'Radio banner'}
|
||||
className="article-img"
|
||||
style={{ margin: '0.5rem 0' }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<form action={updateRadioBanner}>
|
||||
<input type="hidden" name="id" value={String(b.id)} />
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`img_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Image path / URL
|
||||
</label>
|
||||
<input
|
||||
id={`img_${b.id}`}
|
||||
name="imagePath"
|
||||
defaultValue={b.imagePath}
|
||||
required
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`title_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id={`title_${b.id}`}
|
||||
name="title"
|
||||
defaultValue={b.title ?? ''}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label
|
||||
htmlFor={`desc_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id={`desc_${b.id}`}
|
||||
name="description"
|
||||
rows={2}
|
||||
defaultValue={b.description ?? ''}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`sort_${b.id}`}
|
||||
style={{ display: 'block', fontWeight: 700 }}
|
||||
>
|
||||
Sort order
|
||||
</label>
|
||||
<input
|
||||
id={`sort_${b.id}`}
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
defaultValue={b.sortOrder}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label
|
||||
style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isActive"
|
||||
value="1"
|
||||
defaultChecked={b.isActive}
|
||||
/>{' '}
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.6rem' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={deleteRadioBanner} style={{ marginTop: '0.5rem' }}>
|
||||
<input type="hidden" name="id" value={String(b.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
createRadioRank,
|
||||
deleteRadioRank,
|
||||
updateRadioRank,
|
||||
} from '@/actions/admin-radio-extra';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Rank = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
description: string | null;
|
||||
badgeCode: string | null;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export default async function AdminRadioRanksPage() {
|
||||
let ranks: Rank[] = [];
|
||||
let dbError = false;
|
||||
|
||||
try {
|
||||
ranks = await prisma.radioRanks.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
badgeCode: true,
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
} catch {
|
||||
dbError = true;
|
||||
ranks = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
|
||||
<Link href="/admin/radio">Radio</Link> ·{' '}
|
||||
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
|
||||
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
|
||||
<Link href="/admin/radio/ranks">Ranks</Link>
|
||||
</nav>
|
||||
|
||||
<h1>Radio Ranks</h1>
|
||||
<p className="muted">DJ / staff ranks assignable on radio applications.</p>
|
||||
|
||||
{dbError ? (
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Could not load ranks (database unavailable).
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={createRadioRank} className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add rank</h3>
|
||||
<div className="grid cols-3">
|
||||
<div>
|
||||
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Name
|
||||
</label>
|
||||
<input id="new_name" name="name" required style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new_badge" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Badge code
|
||||
</label>
|
||||
<input id="new_badge" name="badgeCode" style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
|
||||
<input type="checkbox" name="isActive" value="1" defaultChecked /> Active
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label htmlFor="new_desc" style={{ display: 'block', fontWeight: 700 }}>
|
||||
Description
|
||||
</label>
|
||||
<input id="new_desc" name="description" style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create rank
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{ranks.length === 0 ? (
|
||||
<p className="muted">No ranks yet.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Badge</th>
|
||||
<th>Description</th>
|
||||
<th>Active</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ranks.map((r) => (
|
||||
<tr key={String(r.id)}>
|
||||
<td>{String(r.id)}</td>
|
||||
<td colSpan={5}>
|
||||
<form
|
||||
action={updateRadioRank}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={String(r.id)} />
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={r.name}
|
||||
required
|
||||
placeholder="Name"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<input
|
||||
name="badgeCode"
|
||||
defaultValue={r.badgeCode ?? ''}
|
||||
placeholder="Badge"
|
||||
style={{ minWidth: 100 }}
|
||||
/>
|
||||
<input
|
||||
name="description"
|
||||
defaultValue={r.description ?? ''}
|
||||
placeholder="Description"
|
||||
style={{ flex: 1, minWidth: 160 }}
|
||||
/>
|
||||
<label
|
||||
style={{ display: 'inline-flex', gap: '0.3rem', alignItems: 'center' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isActive"
|
||||
value="1"
|
||||
defaultChecked={r.isActive}
|
||||
/>{' '}
|
||||
Active
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{ranks.map((r) => (
|
||||
<tr key={`del_${String(r.id)}`}>
|
||||
<td colSpan={6} style={{ borderTop: 'none', paddingTop: 0 }}>
|
||||
<form action={deleteRadioRank} style={{ display: 'inline' }}>
|
||||
<input type="hidden" name="id" value={String(r.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete rank #{String(r.id)}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import Link from 'next/link';
|
||||
import { saveRadioSetting, saveRadioSettings } from '@/actions/admin-radio-extra';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Curated radio_* keys (faithful to AtomCMS RadioSettingsSeeder), grouped for
|
||||
// the editor. Any extra radio_* rows found in the DB are appended under "Other".
|
||||
type Field = { key: string; comment: string };
|
||||
type Group = { title: string; fields: Field[] };
|
||||
|
||||
const GROUPS: Group[] = [
|
||||
{
|
||||
title: 'AzureCast & Stream',
|
||||
fields: [
|
||||
{ key: 'radio_enabled', comment: 'Radio enabled (0=no, 1=yes)' },
|
||||
{ key: 'radio_stream_url', comment: 'Main stream URL' },
|
||||
{ key: 'radio_stream_backup_url', comment: 'Backup stream URL' },
|
||||
{ key: 'radio_azurecast_base_url', comment: 'AzureCast base URL' },
|
||||
{ key: 'radio_azurecast_station_id', comment: 'AzureCast station ID' },
|
||||
{ key: 'radio_azurecast_api_key', comment: 'AzureCast API key' },
|
||||
{ key: 'radio_azurecast_port', comment: 'AzureCast stream port' },
|
||||
{ key: 'radio_azurecast_protocol', comment: 'AzureCast protocol (http/https)' },
|
||||
{ key: 'radio_azurecast_use_proxy', comment: 'Use AzureCast proxy (0=no, 1=yes)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'External sources',
|
||||
fields: [
|
||||
{ key: 'radio_sambroadcaster_api_url', comment: 'Sambroadcaster API URL' },
|
||||
{ key: 'radio_sambroadcaster_password', comment: 'Sambroadcaster password' },
|
||||
{ key: 'radio_virtual_dj_url', comment: 'Virtual DJ URL' },
|
||||
{ key: 'radio_virtual_dj_password', comment: 'Virtual DJ password' },
|
||||
{ key: 'radio_djs_api_url', comment: 'DJs API URL' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Player',
|
||||
fields: [
|
||||
{ key: 'radio_style', comment: 'Player style (dark/light/blue/purple/pink/gold)' },
|
||||
{ key: 'radio_player_type', comment: 'Player type (default/minimal/embedded)' },
|
||||
{ key: 'radio_auto_play', comment: 'Auto-play on page load (0=no, 1=yes)' },
|
||||
{ key: 'radio_auto_play_delay', comment: 'Auto-play delay in seconds' },
|
||||
{ key: 'radio_mute_on_start', comment: 'Muted on start (0=no, 1=yes)' },
|
||||
{ key: 'radio_volume', comment: 'Default volume (0-100)' },
|
||||
{ key: 'radio_language', comment: 'Default language (nl/en/de/fr/es/pt/it/br)' },
|
||||
{ key: 'radio_multi_language_enabled', comment: 'Multi-language support (0=no, 1=yes)' },
|
||||
{ key: 'radio_player_color_primary', comment: 'Player primary color' },
|
||||
{ key: 'radio_player_color_secondary', comment: 'Player secondary color' },
|
||||
{ key: 'radio_player_color_text', comment: 'Player text color' },
|
||||
{ key: 'radio_player_color_accent', comment: 'Player accent color' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Now playing & history',
|
||||
fields: [
|
||||
{ key: 'radio_now_playing_enabled', comment: 'Show now playing info (0=no, 1=yes)' },
|
||||
{ key: 'radio_now_playing_api_url', comment: 'Now playing API URL' },
|
||||
{ key: 'radio_now_playing_cache_duration', comment: 'Now playing cache duration in seconds' },
|
||||
{ key: 'radio_show_song_history', comment: 'Show song history (0=no, 1=yes)' },
|
||||
{ key: 'radio_song_history_count', comment: 'Number of songs in history' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Listeners',
|
||||
fields: [
|
||||
{ key: 'radio_listeners_enabled', comment: 'Show listeners count (0=no, 1=yes)' },
|
||||
{ key: 'radio_listeners_api_url', comment: 'Listeners API URL' },
|
||||
{ key: 'radio_listeners_update_interval', comment: 'Listeners update interval in seconds' },
|
||||
{ key: 'radio_show_listener_peak', comment: 'Show listener peak (0=no, 1=yes)' },
|
||||
{ key: 'radio_peak_reset_time', comment: 'Peak reset time (HH:MM)' },
|
||||
{ key: 'radio_store_listener_stats', comment: 'Store listener statistics (0=no, 1=yes)' },
|
||||
{ key: 'radio_stats_retention_days', comment: 'Statistics retention in days' },
|
||||
{ key: 'radio_show_listener_graph', comment: 'Show listener graph (0=no, 1=yes)' },
|
||||
{ key: 'radio_listener_graph_period', comment: 'Listener graph period (1h/6h/12h/24h/7d)' },
|
||||
{ key: 'radio_show_average_listeners', comment: 'Show average listeners (0=no, 1=yes)' },
|
||||
{ key: 'radio_show_unique_listeners', comment: 'Show unique listeners (0=no, 1=yes)' },
|
||||
{ key: 'radio_listener_alerts_enabled', comment: 'Enable listener alerts (0=no, 1=yes)' },
|
||||
{ key: 'radio_min_listeners_threshold', comment: 'Minimum listeners threshold' },
|
||||
{ key: 'radio_listener_alert_threshold_high', comment: 'High listener alert threshold' },
|
||||
{ key: 'radio_listener_alert_threshold_low', comment: 'Low listener alert threshold' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'DJ',
|
||||
fields: [
|
||||
{ key: 'radio_show_current_dj', comment: 'Show current DJ (0=no, 1=yes)' },
|
||||
{ key: 'radio_current_dj_id', comment: 'Manual DJ user ID' },
|
||||
{ key: 'radio_dj_avatar_size', comment: 'DJ avatar size in pixels' },
|
||||
{ key: 'radio_show_dj_schedule', comment: 'Show DJ schedule (0=no, 1=yes)' },
|
||||
{ key: 'radio_auto_dj_detection', comment: 'Enable auto DJ detection (0=no, 1=yes)' },
|
||||
{ key: 'radio_dj_detection_interval', comment: 'DJ detection interval in seconds' },
|
||||
{ key: 'auto_dj_detected_action', comment: 'Action on DJ detection (set_dj/notify/both)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Shouts & chat',
|
||||
fields: [
|
||||
{ key: 'radio_shouts_enabled', comment: 'Enable shouts (0=no, 1=yes)' },
|
||||
{ key: 'radio_shouts_max_length', comment: 'Maximum shout length in characters' },
|
||||
{ key: 'radio_shouts_cooldown', comment: 'Shout cooldown in seconds' },
|
||||
{ key: 'radio_shouts_require_moderation', comment: 'Require shout moderation (0=no, 1=yes)' },
|
||||
{ key: 'radio_chat_enabled', comment: 'Enable live chat (0=no, 1=yes)' },
|
||||
{ key: 'radio_chat_position', comment: 'Chat position (left/right/bottom)' },
|
||||
{ key: 'radio_chat_width', comment: 'Chat width in pixels' },
|
||||
{ key: 'radio_chat_height', comment: 'Chat height in pixels' },
|
||||
{ key: 'radio_chat_messages_count', comment: 'Number of chat messages to show' },
|
||||
{ key: 'radio_chat_show_timestamps', comment: 'Show chat timestamps (0=no, 1=yes)' },
|
||||
{ key: 'radio_chat_allow_links', comment: 'Allow links in chat (0=no, 1=yes)' },
|
||||
{ key: 'radio_chat_allow_emojis', comment: 'Allow emojis in chat (0=no, 1=yes)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Requests & widget',
|
||||
fields: [
|
||||
{ key: 'radio_request_form_enabled', comment: 'Enable song requests (0=no, 1=yes)' },
|
||||
{ key: 'radio_request_max_per_user', comment: 'Maximum requests per user' },
|
||||
{ key: 'radio_request_cooldown', comment: 'Request cooldown in seconds' },
|
||||
{ key: 'radio_voting_enabled', comment: 'Enable request voting (0=no, 1=yes)' },
|
||||
{ key: 'radio_voting_show_song_requests', comment: 'Show requests with votes (0=no, 1=yes)' },
|
||||
{ key: 'radio_widget_enabled', comment: 'Enable radio widget (0=no, 1=yes)' },
|
||||
{ key: 'radio_widget_show_globally', comment: 'Show widget globally (0=no, 1=yes)' },
|
||||
{ key: 'radio_widget_position', comment: 'Widget position' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Offline & branding',
|
||||
fields: [
|
||||
{ key: 'radio_show_offline_message', comment: 'Show offline message (0=no, 1=yes)' },
|
||||
{ key: 'radio_offline_message', comment: 'Offline message text' },
|
||||
{ key: 'radio_offline_image', comment: 'Offline page image URL' },
|
||||
{ key: 'radio_background_image', comment: 'Background image URL' },
|
||||
{ key: 'radio_logo_url', comment: 'Radio logo URL' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Applications',
|
||||
fields: [
|
||||
{ key: 'radio_applications_enabled', comment: 'Enable DJ applications (0=no, 1=yes)' },
|
||||
{ key: 'radio_applications_max_per_day', comment: 'Maximum applications per day' },
|
||||
{ key: 'radio_applications_require_approval', comment: 'Require application approval (0=no, 1=yes)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Stats display',
|
||||
fields: [
|
||||
{ key: 'radio_stats_show_daily', comment: 'Show daily statistics (0=no, 1=yes)' },
|
||||
{ key: 'radio_stats_show_weekly', comment: 'Show weekly statistics (0=no, 1=yes)' },
|
||||
{ key: 'radio_stats_show_monthly', comment: 'Show monthly statistics (0=no, 1=yes)' },
|
||||
{ key: 'radio_stats_show_top_djs', comment: 'Show top DJs (0=no, 1=yes)' },
|
||||
{ key: 'radio_stats_show_top_songs', comment: 'Show top songs (0=no, 1=yes)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Social & contact',
|
||||
fields: [
|
||||
{ key: 'radio_social_facebook', comment: 'Facebook URL' },
|
||||
{ key: 'radio_social_twitter', comment: 'Twitter URL' },
|
||||
{ key: 'radio_social_instagram', comment: 'Instagram URL' },
|
||||
{ key: 'radio_social_discord', comment: 'Discord URL' },
|
||||
{ key: 'radio_social_youtube', comment: 'YouTube URL' },
|
||||
{ key: 'radio_social_twitch', comment: 'Twitch URL' },
|
||||
{ key: 'radio_contact_email', comment: 'Contact email' },
|
||||
{ key: 'radio_contact_phone', comment: 'Contact phone' },
|
||||
{ key: 'radio_about_text', comment: 'About radio text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Discord webhook & custom code',
|
||||
fields: [
|
||||
{ key: 'radio_discord_webhook_url', comment: 'Discord webhook URL' },
|
||||
{ key: 'radio_discord_enabled', comment: 'Enable Discord notifications (0=no, 1=yes)' },
|
||||
{ key: 'radio_discord_dj_live', comment: 'Discord DJ live notification (0=no, 1=yes)' },
|
||||
{ key: 'radio_discord_song_changes', comment: 'Discord song change notification (0=no, 1=yes)' },
|
||||
{ key: 'radio_discord_custom_message', comment: 'Custom Discord message template' },
|
||||
{ key: 'radio_custom_css', comment: 'Custom CSS code' },
|
||||
{ key: 'radio_custom_js', comment: 'Custom JavaScript code' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const CURATED_KEYS = new Set(GROUPS.flatMap((g) => g.fields.map((f) => f.key)));
|
||||
|
||||
export default async function AdminRadioSettingsPage() {
|
||||
let values: Map<string, string> = new Map();
|
||||
let dbError = false;
|
||||
let extra: Field[] = [];
|
||||
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
select: { key: true, value: true, comment: true },
|
||||
});
|
||||
values = new Map(rows.map((r) => [r.key, r.value]));
|
||||
extra = rows
|
||||
.filter(
|
||||
(r) =>
|
||||
(r.key.startsWith('radio_') || r.key.startsWith('auto_dj_')) &&
|
||||
!CURATED_KEYS.has(r.key),
|
||||
)
|
||||
.map((r) => ({ key: r.key, comment: r.comment ?? '' }));
|
||||
} catch {
|
||||
dbError = true;
|
||||
}
|
||||
|
||||
const groups: Group[] = extra.length
|
||||
? [...GROUPS, { title: 'Other radio settings', fields: extra }]
|
||||
: GROUPS;
|
||||
|
||||
const allKeys = [...CURATED_KEYS, ...extra.map((e) => e.key)].join(',');
|
||||
|
||||
return (
|
||||
<main>
|
||||
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
|
||||
<Link href="/admin/radio">Radio</Link> ·{' '}
|
||||
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
|
||||
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
|
||||
<Link href="/admin/radio/ranks">Ranks</Link>
|
||||
</nav>
|
||||
|
||||
<h1>Radio Settings</h1>
|
||||
<p className="muted">
|
||||
Edits write to <code>website_settings</code> (radio_* keys) and bust the
|
||||
runtime settings cache. Booleans use <code>0</code> / <code>1</code>.
|
||||
</p>
|
||||
|
||||
{dbError ? (
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Could not load settings (database unavailable). Defaults will be
|
||||
created on save.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={saveRadioSettings}>
|
||||
<input type="hidden" name="__keys" value={allKeys} />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{groups.map((group) => (
|
||||
<section key={group.title} className="card" style={{ marginBottom: '1.25rem' }}>
|
||||
<h2 style={{ marginTop: 0 }}>{group.title}</h2>
|
||||
<div className="grid cols-2">
|
||||
{group.fields.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label
|
||||
htmlFor={`f_${field.key}`}
|
||||
style={{ display: 'block', fontWeight: 700, fontSize: '0.85rem' }}
|
||||
>
|
||||
{field.key}
|
||||
</label>
|
||||
<input
|
||||
id={`f_${field.key}`}
|
||||
name={field.key}
|
||||
defaultValue={values.get(field.key) ?? ''}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{field.comment ? (
|
||||
<p className="muted" style={{ margin: '0.2rem 0 0' }}>
|
||||
{field.comment}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save all
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="card" style={{ marginTop: '1.5rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add / overwrite a single key</h3>
|
||||
<form
|
||||
action={saveRadioSetting}
|
||||
style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}
|
||||
>
|
||||
<input name="key" placeholder="radio_custom_key" required />
|
||||
<input name="value" placeholder="value" style={{ flex: 1, minWidth: 160 }} />
|
||||
<input name="comment" placeholder="comment (optional)" />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
captureOrder,
|
||||
creditsPerUnit,
|
||||
isPayPalConfigured,
|
||||
} from "@/lib/services/paypal";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { sendCurrency } from "@/lib/services/send-currency";
|
||||
import { env } from "@/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* POST /api/paypal/capture — capture an approved order and credit the buyer.
|
||||
* Body: { orderId: string } (PayPal order id from /api/paypal/create).
|
||||
*
|
||||
* On a COMPLETED capture we:
|
||||
* 1. record the payment in website_paypal_transactions (idempotent on
|
||||
* transaction_id so a double-submit can't double-credit), and
|
||||
* 2. credit the buyer's `credits` wallet via sendCurrency (RCON-first, DB
|
||||
* fallback). NOTE: the schema has no dedicated website-balance column —
|
||||
* User.credits is the website/in-game wallet, so the top-up lands there,
|
||||
* consistent with the voucher redeem flow.
|
||||
*
|
||||
* Auth-gated via auth(); the crediting user id is the session user, not a body
|
||||
* field, so a captured order always credits the person who is signed in.
|
||||
*/
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "You must be signed in." }, { status: 401 });
|
||||
}
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) {
|
||||
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isPayPalConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: "PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const orderId = String((body as { orderId?: unknown })?.orderId ?? "").trim();
|
||||
if (!orderId) {
|
||||
return NextResponse.json({ error: "Missing orderId." }, { status: 422 });
|
||||
}
|
||||
|
||||
// Idempotency: if this order was already recorded, don't capture/credit again.
|
||||
try {
|
||||
const existing = await prisma.websitePaypalTransactions.findFirst({
|
||||
where: { transactionId: orderId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (existing) {
|
||||
return NextResponse.json({
|
||||
ok: existing.status === "COMPLETED",
|
||||
alreadyProcessed: true,
|
||||
status: existing.status,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// If the lookup fails we fall through; the capture call itself is the source
|
||||
// of truth and PayPal rejects a second capture of the same order.
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await captureOrder(orderId);
|
||||
} catch (e) {
|
||||
console.error("[paypal/capture]", (e as Error).message);
|
||||
return NextResponse.json(
|
||||
{ error: "Could not capture the PayPal payment. If you were charged, contact staff." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status !== "COMPLETED") {
|
||||
// Record the non-completed attempt so support can trace it.
|
||||
try {
|
||||
await prisma.websitePaypalTransactions.create({
|
||||
data: {
|
||||
userId,
|
||||
transactionId: result.id || orderId,
|
||||
status: result.status,
|
||||
description: `${env.HOTEL_NAME} top-up (not completed)`,
|
||||
amount: result.amount,
|
||||
currency: result.currency,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* best-effort logging */
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ ok: false, status: result.status, error: "Payment was not completed." },
|
||||
{ status: 402 },
|
||||
);
|
||||
}
|
||||
|
||||
const credits = Math.floor(result.amount * creditsPerUnit());
|
||||
|
||||
// Record the transaction BEFORE crediting so a crash mid-grant can't be
|
||||
// reprocessed into a double credit (the idempotency check above keys on this).
|
||||
try {
|
||||
await prisma.websitePaypalTransactions.create({
|
||||
data: {
|
||||
userId,
|
||||
transactionId: result.captureId ?? result.id,
|
||||
status: "COMPLETED",
|
||||
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
|
||||
amount: result.amount,
|
||||
currency: result.currency,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[paypal/capture] record failed", (e as Error).message);
|
||||
return NextResponse.json(
|
||||
{ error: "Payment captured but could not be recorded. Contact staff with your order id." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Credit the buyer's website credits wallet (RCON-first, DB fallback).
|
||||
try {
|
||||
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
|
||||
} catch (e) {
|
||||
console.error("[paypal/capture] credit failed", (e as Error).message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Payment recorded but credits could not be delivered. Contact staff.",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
status: "COMPLETED",
|
||||
amount: result.amount,
|
||||
currency: result.currency,
|
||||
credits,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import {
|
||||
createOrder,
|
||||
creditsPerUnit,
|
||||
isPayPalConfigured,
|
||||
PAYPAL_CURRENCY,
|
||||
} from "@/lib/services/paypal";
|
||||
import { env } from "@/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MIN_AMOUNT = 1;
|
||||
const MAX_AMOUNT = 500;
|
||||
|
||||
/**
|
||||
* POST /api/paypal/create — create a PayPal CAPTURE order for the signed-in user.
|
||||
* Body: { amount: number } (in the configured currency, default USD).
|
||||
* Returns { id, approveUrl } on success; a clear JSON error otherwise.
|
||||
*
|
||||
* Auth-gated via auth(): the order is tied to the session, never to a body field.
|
||||
*/
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "You must be signed in to top up." }, { status: 401 });
|
||||
}
|
||||
|
||||
// Fail fast (and clearly) when the sandbox/live keys aren't set.
|
||||
if (!isPayPalConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: "PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const raw = (body as { amount?: unknown })?.amount;
|
||||
const amount = Math.round(Number(raw) * 100) / 100;
|
||||
if (!Number.isFinite(amount) || amount < MIN_AMOUNT || amount > MAX_AMOUNT) {
|
||||
return NextResponse.json(
|
||||
{ error: `Enter an amount between ${MIN_AMOUNT} and ${MAX_AMOUNT} ${PAYPAL_CURRENCY}.` },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const credits = Math.floor(amount * creditsPerUnit());
|
||||
const base = env.APP_URL.replace(/\/+$/, "");
|
||||
|
||||
try {
|
||||
const order = await createOrder(amount, {
|
||||
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
|
||||
returnUrl: `${base}/shop/topup?status=success`,
|
||||
cancelUrl: `${base}/shop/topup?status=cancel`,
|
||||
});
|
||||
|
||||
if (!order.approveUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "PayPal did not return an approval link. Try again." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: order.id,
|
||||
approveUrl: order.approveUrl,
|
||||
amount,
|
||||
currency: PAYPAL_CURRENCY,
|
||||
credits,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[paypal/create]", (e as Error).message);
|
||||
return NextResponse.json(
|
||||
{ error: "Could not start the PayPal checkout. Please try again." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { applyStaff } from "@/actions/applications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
function formatDeadline(d: Date | null | undefined): string {
|
||||
if (!d) return "No deadline set";
|
||||
return d.toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function ApplyStaffPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||
|
||||
// ── Open positions ──────────────────────────────────────────
|
||||
// Each query is isolated so a DB hiccup degrades that section to empty
|
||||
// rather than 500-ing the whole page.
|
||||
const positions = await prisma.websiteOpenPositions
|
||||
.findMany({ orderBy: { createdAt: "desc" } })
|
||||
.catch(() => []);
|
||||
|
||||
// Resolve display info for each position's rank. The rich rank fields
|
||||
// (rankName/badge/jobDescription/staffColor) live in website_teams, keyed by
|
||||
// the same rank id the position's permission_id points at.
|
||||
const rankIds = Array.from(new Set(positions.map((p) => p.permissionId)));
|
||||
const teams = rankIds.length
|
||||
? await prisma.websiteTeams
|
||||
.findMany({
|
||||
where: { id: { in: rankIds.map((r) => BigInt(r)) } },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const teamByRankId = new Map(teams.map((t) => [Number(t.id), t]));
|
||||
|
||||
// Ranks this user has already applied for (block re-applying).
|
||||
const myApps = await prisma.websiteStaffApplications
|
||||
.findMany({ where: { userId }, select: { rankId: true } })
|
||||
.catch(() => []);
|
||||
const appliedRankIds = new Set(myApps.map((a) => a.rankId));
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.5rem" }}>Apply for {hotelName} staff</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
We open staff applications every now and then. If you come across a position you feel you
|
||||
would fit perfectly into, do not hesitate to apply for it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{positions.length === 0 ? (
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>No positions open</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
There are currently no positions open. Please come back at a later time to check if we
|
||||
have any openings by then. Thank you for your interest.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{positions.map((position) => {
|
||||
const team = teamByRankId.get(position.permissionId);
|
||||
const rankName = team?.rankName ?? `Rank #${position.permissionId}`;
|
||||
const alreadyApplied = appliedRankIds.has(position.permissionId);
|
||||
return (
|
||||
<article key={String(position.id)} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
alignItems: "center",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{team?.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}/${team.badge}.gif`}
|
||||
alt={rankName}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: team?.staffColor || undefined }}>{rankName}</h3>
|
||||
{team?.jobDescription ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{team.jobDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
||||
Application deadline: {formatDeadline(position.applyTo)}
|
||||
</p>
|
||||
|
||||
{alreadyApplied ? (
|
||||
<button type="button" className="btn btn-danger" disabled style={{ width: "100%" }}>
|
||||
You have already applied for {rankName}
|
||||
</button>
|
||||
) : (
|
||||
<form action={applyStaff}>
|
||||
{/* rank_id is the position's permission id; the applicant is
|
||||
re-read from the session inside the action. */}
|
||||
<input type="hidden" name="rankId" value={String(position.permissionId)} />
|
||||
<label htmlFor={`content-${position.id}`} className="muted">
|
||||
About you
|
||||
</label>
|
||||
<textarea
|
||||
id={`content-${position.id}`}
|
||||
name="content"
|
||||
required
|
||||
minLength={10}
|
||||
rows={5}
|
||||
placeholder={`Tell us why you'd be a great ${rankName}…`}
|
||||
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-secondary" style={{ width: "100%" }}>
|
||||
Apply for {rankName}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { applyTeam } from "@/actions/applications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
export default async function ApplyTeamPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||
|
||||
// ── Teams open for application ──────────────────────────────
|
||||
// The Prisma slice exposes teams directly (no separate team-position table
|
||||
// with position_kind), so the team application concept lists website_teams.
|
||||
// Hidden ranks are excluded from the public apply page.
|
||||
const teams = await prisma.websiteTeams
|
||||
.findMany({
|
||||
where: { hiddenRank: false },
|
||||
orderBy: { rankName: "asc" },
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
// Teams this user has already applied to. Team applications reuse the staff
|
||||
// applications table with rank_id carrying the team id (the team flag).
|
||||
const myApps = await prisma.websiteStaffApplications
|
||||
.findMany({ where: { userId }, select: { rankId: true } })
|
||||
.catch(() => []);
|
||||
const appliedTeamIds = new Set(myApps.map((a) => a.rankId));
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.5rem" }}>Apply for the {hotelName} team</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
We open team applications periodically. If you see a team you fit, do not hesitate to
|
||||
apply!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{teams.length === 0 ? (
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>No team positions open</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
There are currently no open team positions. Please come back later to check for new
|
||||
openings. Thank you!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{teams.map((team) => {
|
||||
const teamId = Number(team.id);
|
||||
const alreadyApplied = appliedTeamIds.has(teamId);
|
||||
return (
|
||||
<article key={String(team.id)} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
alignItems: "center",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{team.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}/${team.badge}.gif`}
|
||||
alt={team.rankName}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: team.staffColor || undefined }}>
|
||||
{team.rankName}
|
||||
</h3>
|
||||
{team.jobDescription ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{team.jobDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{alreadyApplied ? (
|
||||
<button type="button" className="btn btn-danger" disabled style={{ width: "100%" }}>
|
||||
Your application is pending
|
||||
</button>
|
||||
) : (
|
||||
<form action={applyTeam}>
|
||||
{/* The team id is the application's rank flag; the applicant
|
||||
is re-read from the session inside the action. */}
|
||||
<input type="hidden" name="teamId" value={String(team.id)} />
|
||||
<label htmlFor={`content-${team.id}`} className="muted">
|
||||
About you
|
||||
</label>
|
||||
<textarea
|
||||
id={`content-${team.id}`}
|
||||
name="content"
|
||||
required
|
||||
minLength={10}
|
||||
rows={5}
|
||||
placeholder={`Tell us why you'd be a great fit for ${team.rankName}…`}
|
||||
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: "100%" }}>
|
||||
Apply for {team.rankName}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const FAR_FUTURE = Math.floor(Date.now() / 1000) + 50 * 365 * 24 * 3600;
|
||||
|
||||
export default async function BannedPage() {
|
||||
const session = await auth();
|
||||
let reason = "";
|
||||
let expire = 0;
|
||||
if (session?.user?.id) {
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const ban = await prisma.ban.findFirst({
|
||||
where: { userId: Number(session.user.id), banExpire: { gt: now } },
|
||||
orderBy: { banExpire: "desc" },
|
||||
select: { banReason: true, banExpire: true },
|
||||
});
|
||||
if (ban) {
|
||||
reason = ban.banReason;
|
||||
expire = ban.banExpire;
|
||||
}
|
||||
} catch {
|
||||
// show generic message
|
||||
}
|
||||
}
|
||||
|
||||
const expiryText =
|
||||
expire === 0
|
||||
? ""
|
||||
: expire > FAR_FUTURE
|
||||
? "This ban is permanent."
|
||||
: `Expires ${new Date(expire * 1000).toISOString().slice(0, 16).replace("T", " ")}.`;
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 540, margin: "2rem auto" }}>
|
||||
<div className="card" style={{ padding: "1.75rem", textAlign: "center" }}>
|
||||
<h1 style={{ marginTop: 0, color: "var(--color-danger)" }}>You are banned</h1>
|
||||
<p>Your account has been suspended from the hotel.</p>
|
||||
{reason ? (
|
||||
<p>
|
||||
<strong>Reason:</strong> {reason}
|
||||
</p>
|
||||
) : null}
|
||||
{expiryText ? <p className="muted">{expiryText}</p> : null}
|
||||
<p className="muted">If you believe this is a mistake, contact the staff team.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+3
-1
@@ -6,6 +6,7 @@ import { SiteFooter } from "@/components/site-footer";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { ThemeVars } from "@/components/theme-vars";
|
||||
import { TopHeader } from "@/components/top-header";
|
||||
import { enforceSiteAccess } from "@/lib/access-guard";
|
||||
import "./globals.css";
|
||||
|
||||
const nunito = Nunito({
|
||||
@@ -20,7 +21,8 @@ export const metadata: Metadata = {
|
||||
description: "AtomCMS — retro hotel CMS (Next.js conversion)",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
await enforceSiteAccess();
|
||||
return (
|
||||
<html lang="en" className={`app ${nunito.variable}`}>
|
||||
<body
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MaintenancePage() {
|
||||
const [hotel, message] = await Promise.all([
|
||||
siteSettings.get("hotel_name", "Atom"),
|
||||
siteSettings.get(
|
||||
"maintenance_message",
|
||||
"We are currently performing maintenance. Please check back soon.",
|
||||
),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 540, margin: "2rem auto" }}>
|
||||
<div className="card" style={{ padding: "1.75rem", textAlign: "center" }}>
|
||||
<h1 style={{ marginTop: 0 }}>{hotel} is under maintenance 🛠️</h1>
|
||||
<p>{message}</p>
|
||||
<p className="muted">Staff can still log in.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { applyDj } from "@/actions/radio-apply";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RadioApplyPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
// Surface the applicant's most recent application (if any) so they aren't
|
||||
// left guessing. Isolated query: a DB hiccup degrades this to null, not 500.
|
||||
const existing = await prisma.radioApplications
|
||||
.findFirst({
|
||||
where: { userId: BigInt(userId) },
|
||||
orderBy: { id: "desc" },
|
||||
select: { id: true, status: true, createdAt: true },
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
const submittedDate = existing?.createdAt
|
||||
? existing.createdAt.toISOString().slice(0, 10)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: "0 0 0.25rem" }}>Apply to be a Radio DJ</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Tell us about yourself and why you'd be a great fit for the airwaves.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{existing ? (
|
||||
<div className="card" style={{ marginBottom: "1.25rem" }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
<strong>Your latest application:</strong> {existing.status}
|
||||
{submittedDate ? (
|
||||
<span className="muted"> · submitted {submittedDate}</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
||||
You can submit again below if your details have changed.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={applyDj} className="card">
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<label htmlFor="realName" className="muted">
|
||||
Real name
|
||||
</label>
|
||||
<input
|
||||
id="realName"
|
||||
name="realName"
|
||||
type="text"
|
||||
required
|
||||
maxLength={255}
|
||||
placeholder="Your full name"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="age" className="muted">
|
||||
Age
|
||||
</label>
|
||||
<input
|
||||
id="age"
|
||||
name="age"
|
||||
type="number"
|
||||
required
|
||||
min={13}
|
||||
max={120}
|
||||
placeholder="18"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="availability" className="muted">
|
||||
Availability
|
||||
</label>
|
||||
<textarea
|
||||
id="availability"
|
||||
name="availability"
|
||||
required
|
||||
rows={3}
|
||||
placeholder="Which days and times can you broadcast?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="musicStyle" className="muted">
|
||||
Music style
|
||||
</label>
|
||||
<input
|
||||
id="musicStyle"
|
||||
name="musicStyle"
|
||||
type="text"
|
||||
maxLength={255}
|
||||
placeholder="e.g. House, Pop, Hip-Hop"
|
||||
style={{ width: "100%", marginTop: "0.3rem" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="experience" className="muted">
|
||||
Experience <span className="muted">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="experience"
|
||||
name="experience"
|
||||
rows={3}
|
||||
placeholder="Any prior DJ or hosting experience?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<label htmlFor="motivation" className="muted">
|
||||
Motivation
|
||||
</label>
|
||||
<textarea
|
||||
id="motivation"
|
||||
name="motivation"
|
||||
required
|
||||
rows={4}
|
||||
placeholder="Why do you want to join the radio team?"
|
||||
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" style={{ marginTop: "1.25rem" }}>
|
||||
Submit application
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
|
||||
}
|
||||
|
||||
export default async function RadioContestDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
// Route param is a string; RadioContests.id is a BigInt. Guard against a
|
||||
// non-numeric id before constructing the BigInt (would otherwise throw).
|
||||
let contestId: bigint;
|
||||
try {
|
||||
contestId = BigInt(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const contest = await prisma.radioContests
|
||||
.findUnique({ where: { id: contestId! } })
|
||||
.catch(() => null);
|
||||
|
||||
if (!contest) notFound();
|
||||
|
||||
const contestIdStr = contest.id.toString();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="muted" style={{ margin: "0 0 0.5rem" }}>
|
||||
<Link href="/radio/contests">← Back to contests</Link>
|
||||
</p>
|
||||
|
||||
<div className="card">
|
||||
<h1 style={{ margin: "0 0 0.5rem" }}>Contest #{contestIdStr}</h1>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<td>{formatDate(contest.createdAt) || <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Updated</th>
|
||||
<td>{formatDate(contest.updatedAt) || <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 10) : "";
|
||||
}
|
||||
|
||||
export default async function RadioContestsPage() {
|
||||
// RadioContests.id is a BigInt — stringify before use in keys/routes.
|
||||
const contests = await prisma.radioContests
|
||||
.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: 0 }}>Radio Contests</h1>
|
||||
<p className="muted" style={{ margin: "0.5rem 0 0" }}>
|
||||
Tune in and take part in our latest on-air contests.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{contests.length === 0 ? (
|
||||
<p className="muted">No contests yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{contests.map((c) => {
|
||||
const id = c.id.toString();
|
||||
return (
|
||||
<article key={id} className="card hover">
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>
|
||||
<Link href={`/radio/contests/${id}`}>Contest #{id}</Link>
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{formatDate(c.createdAt) || "Date unknown"}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
|
||||
}
|
||||
|
||||
export default async function RadioGiveawayDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
// Route param is a string; RadioGiveaways.id is a BigInt. Guard against a
|
||||
// non-numeric id before constructing the BigInt (would otherwise throw).
|
||||
let giveawayId: bigint;
|
||||
try {
|
||||
giveawayId = BigInt(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const giveaway = await prisma.radioGiveaways
|
||||
.findUnique({ where: { id: giveawayId! } })
|
||||
.catch(() => null);
|
||||
|
||||
if (!giveaway) notFound();
|
||||
|
||||
const giveawayIdStr = giveaway.id.toString();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="muted" style={{ margin: "0 0 0.5rem" }}>
|
||||
<Link href="/radio/giveaways">← Back to giveaways</Link>
|
||||
</p>
|
||||
|
||||
<div className="card">
|
||||
<h1 style={{ margin: "0 0 0.5rem" }}>Giveaway #{giveawayIdStr}</h1>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<td>{formatDate(giveaway.createdAt) || <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Updated</th>
|
||||
<td>{formatDate(giveaway.updatedAt) || <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 10) : "";
|
||||
}
|
||||
|
||||
export default async function RadioGiveawaysPage() {
|
||||
// RadioGiveaways.id is a BigInt — stringify before use in keys/routes.
|
||||
const giveaways = await prisma.radioGiveaways
|
||||
.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ margin: 0 }}>Radio Giveaways</h1>
|
||||
<p className="muted" style={{ margin: "0.5rem 0 0" }}>
|
||||
Listen in for your chance to win in our radio giveaways.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{giveaways.length === 0 ? (
|
||||
<p className="muted">No giveaways yet.</p>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{giveaways.map((g) => {
|
||||
const id = g.id.toString();
|
||||
return (
|
||||
<article key={id} className="card hover">
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>
|
||||
<Link href={`/radio/giveaways/${id}`}>Giveaway #{id}</Link>
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{formatDate(g.createdAt) || "Date unknown"}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Link from "next/link";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// The radio_listener_points table in the live AtomCMS DB carries user_id +
|
||||
// points columns, but the generated Prisma model is a stub (id/timestamps only),
|
||||
// so it has no `points`/`userId` delegate fields. We therefore read it via
|
||||
// $queryRaw, joining users for the username/look — the same approach used for
|
||||
// the keyless/@@ignore'd emulator tables (see src/app/admin/logs). MySQL may
|
||||
// return BigInt columns as bigint, so values are coerced defensively.
|
||||
type LeaderRow = {
|
||||
username: string;
|
||||
look: string;
|
||||
points: number | bigint;
|
||||
};
|
||||
|
||||
async function loadRows(): Promise<{ username: string; look: string; points: number }[]> {
|
||||
try {
|
||||
const rows = await prisma.$queryRaw<LeaderRow[]>`
|
||||
SELECT u.username AS username, u.look AS look, p.points AS points
|
||||
FROM radio_listener_points p
|
||||
INNER JOIN users u ON u.id = p.user_id
|
||||
ORDER BY p.points DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
return rows.map((r) => ({
|
||||
username: r.username,
|
||||
look: r.look,
|
||||
points: Number(r.points),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RadioLeaderboardPage() {
|
||||
const [rows, imagerBase] = await Promise.all([
|
||||
loadRows(),
|
||||
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Radio Listener Leaderboard</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Top 50 listeners by points earned tuning in to the radio.
|
||||
</p>
|
||||
|
||||
<div className="card">
|
||||
{rows.length === 0 ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
No listener points to show yet.
|
||||
</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "3rem" }}>#</th>
|
||||
<th style={{ width: "3.5rem" }}>Avatar</th>
|
||||
<th>Listener</th>
|
||||
<th style={{ textAlign: "right" }}>Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const avatar = avatarImageUrl(imagerBase ?? "", row.look, {
|
||||
size: "s",
|
||||
headOnly: true,
|
||||
});
|
||||
return (
|
||||
<tr key={`${row.username}-${i}`}>
|
||||
<td style={{ fontWeight: 800 }}>{i + 1}</td>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="avatar"
|
||||
src={avatar}
|
||||
alt={`${row.username} avatar`}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
href={`/u/${encodeURIComponent(row.username)}`}
|
||||
style={{ fontWeight: 700 }}
|
||||
>
|
||||
{row.username}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ textAlign: "right", fontWeight: 800 }}>
|
||||
{row.points.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Radio config + today's schedule are read live from the DB / settings table.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Sub-pages linked from the radio home hub.
|
||||
const RADIO_LINKS: { label: string; href: string }[] = [
|
||||
{ label: "Schedule", href: "/radio/schedule" },
|
||||
{ label: "Shouts", href: "/radio/shouts" },
|
||||
{ label: "Requests", href: "/radio/requests" },
|
||||
{ label: "Apply as DJ", href: "/radio/apply" },
|
||||
{ label: "Leaderboard", href: "/radio/leaderboard" },
|
||||
];
|
||||
|
||||
// Canonical weekday order so today's slots come out chronologically and we can
|
||||
// match the current day against whatever casing the `day` column stores.
|
||||
const WEEKDAYS = [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
];
|
||||
|
||||
// radio_schedules.start_time / end_time are stored as TIME and surface through
|
||||
// Prisma as a Date with the time component on a 1970 epoch date — render just
|
||||
// the HH:MM portion in UTC to avoid timezone drift.
|
||||
function formatTime(d: Date | null | undefined): string {
|
||||
if (!d) return "";
|
||||
return d.toISOString().slice(11, 16);
|
||||
}
|
||||
|
||||
export default async function RadioPage() {
|
||||
// ── Radio config from website_settings (graceful fallbacks) ──
|
||||
const [streamUrl, currentDj, currentSong] = await Promise.all([
|
||||
siteSettings.get("radio_stream_url", ""),
|
||||
siteSettings.get("radio_current_dj", ""),
|
||||
siteSettings.get("radio_current_song", ""),
|
||||
]);
|
||||
|
||||
// ── Today's schedule (isolated; a DB hiccup degrades to empty) ──
|
||||
const today = WEEKDAYS[new Date().getUTCDay()] ?? "";
|
||||
const schedules = await prisma.radioSchedules
|
||||
.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { startTime: "asc" },
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
const todaysSlots = schedules.filter(
|
||||
(s) => s.day.toLowerCase() === today.toLowerCase(),
|
||||
);
|
||||
|
||||
// Resolve DJ usernames for today's slots in one query. radio_schedules.user_id
|
||||
// is an UnsignedBigInt while users.id is an Int, so narrow to Number for the
|
||||
// lookup (DJ ids comfortably fit in a JS-safe integer).
|
||||
const djIds = Array.from(new Set(todaysSlots.map((s) => Number(s.userId))));
|
||||
const djs = djIds.length
|
||||
? await prisma.user
|
||||
.findMany({
|
||||
where: { id: { in: djIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const djById = new Map(djs.map((d) => [d.id, d.username]));
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* ── Hero: live stream + now playing ─────────────────────── */}
|
||||
<div className="hero">
|
||||
<h1 style={{ marginTop: 0 }}>Radio</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Tune in to the hotel radio — live DJ sets, requests and shoutouts.
|
||||
</p>
|
||||
|
||||
<div className="grid cols-2" style={{ marginTop: "1rem" }}>
|
||||
<div>
|
||||
<p style={{ margin: "0 0 0.25rem" }}>
|
||||
<strong>Now playing</strong>
|
||||
</p>
|
||||
<p style={{ margin: "0 0 0.5rem", fontSize: "1.1rem" }}>
|
||||
{currentSong ? currentSong : <span className="muted">Nothing playing right now.</span>}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{currentDj ? `On air: ${currentDj}` : "No DJ on air."}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{streamUrl ? (
|
||||
<audio
|
||||
controls
|
||||
src={streamUrl}
|
||||
style={{ width: "100%" }}
|
||||
preload="none"
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
) : (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
The stream is currently offline.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Sub-page links ──────────────────────────────────────── */}
|
||||
<section style={{ marginBottom: "1.5rem" }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem" }}>
|
||||
{RADIO_LINKS.map((l) => (
|
||||
<Link key={l.href} href={l.href} className="btn btn-outline">
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Today's schedule ────────────────────────────────────── */}
|
||||
<section>
|
||||
<h2>Today’s schedule{today ? ` · ${today}` : ""}</h2>
|
||||
{todaysSlots.length === 0 ? (
|
||||
<p className="muted">No shows scheduled for today.</p>
|
||||
) : (
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Show</th>
|
||||
<th>DJ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{todaysSlots.map((s) => {
|
||||
const djName = djById.get(Number(s.userId));
|
||||
return (
|
||||
<tr key={String(s.id)}>
|
||||
<td>
|
||||
{formatTime(s.startTime)} – {formatTime(s.endTime)}
|
||||
</td>
|
||||
<td>
|
||||
{s.showName || <span className="muted">Untitled show</span>}
|
||||
</td>
|
||||
<td>
|
||||
{djName ? (
|
||||
<Link href={`/u/${djName}`}>{djName}</Link>
|
||||
) : (
|
||||
<span className="muted">{`User #${s.userId}`}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Weekly order so the table reads Monday → Sunday regardless of insert order.
|
||||
// The `day` column stores a free-form string; we key case-insensitively.
|
||||
const DAYS = [
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
] as const;
|
||||
|
||||
// `start_time` / `end_time` are MySQL TIME columns; Prisma hydrates them as a
|
||||
// Date on the 1970-01-01 epoch. Pull just HH:MM in UTC (Time has no zone).
|
||||
function formatTime(d: Date | null | undefined): string {
|
||||
if (!d) return "";
|
||||
return d.toISOString().slice(11, 16);
|
||||
}
|
||||
|
||||
type Slot = {
|
||||
id: string;
|
||||
dj: string;
|
||||
showName: string | null;
|
||||
description: string | null;
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
|
||||
export default async function RadioSchedulePage() {
|
||||
const schedules = await prisma.radioSchedules
|
||||
.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { startTime: "asc" },
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
// Resolve DJ usernames. schedule.userId is BigInt; User.id is Int — convert.
|
||||
const djIds = Array.from(new Set(schedules.map((s) => Number(s.userId))));
|
||||
const djs = djIds.length
|
||||
? await prisma.user
|
||||
.findMany({
|
||||
where: { id: { in: djIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const djById = new Map(djs.map((u) => [u.id, u.username]));
|
||||
|
||||
// Bucket slots by normalised day name.
|
||||
const byDay = new Map<string, Slot[]>();
|
||||
for (const day of DAYS) byDay.set(day, []);
|
||||
for (const s of schedules) {
|
||||
const key =
|
||||
DAYS.find((d) => d.toLowerCase() === s.day.trim().toLowerCase()) ?? s.day;
|
||||
const slot: Slot = {
|
||||
id: String(s.id),
|
||||
dj: djById.get(Number(s.userId)) ?? `User #${s.userId}`,
|
||||
showName: s.showName,
|
||||
description: s.description,
|
||||
start: formatTime(s.startTime),
|
||||
end: formatTime(s.endTime),
|
||||
};
|
||||
const bucket = byDay.get(key);
|
||||
if (bucket) bucket.push(slot);
|
||||
else byDay.set(key, [slot]);
|
||||
}
|
||||
|
||||
const hasAny = schedules.length > 0;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Radio Schedule</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Our weekly line-up of DJs and shows.
|
||||
</p>
|
||||
|
||||
{!hasAny ? (
|
||||
<p className="muted">No shows scheduled yet.</p>
|
||||
) : (
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Day</th>
|
||||
<th>Time</th>
|
||||
<th>DJ</th>
|
||||
<th>Show</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DAYS.map((day) => {
|
||||
const slots = byDay.get(day) ?? [];
|
||||
if (slots.length === 0) {
|
||||
return (
|
||||
<tr key={day}>
|
||||
<td>
|
||||
<strong>{day}</strong>
|
||||
</td>
|
||||
<td colSpan={3} className="muted">
|
||||
No shows
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return slots.map((slot, i) => (
|
||||
<tr key={slot.id}>
|
||||
<td>{i === 0 ? <strong>{day}</strong> : ""}</td>
|
||||
<td>
|
||||
{slot.start}
|
||||
{slot.end ? ` – ${slot.end}` : ""}
|
||||
</td>
|
||||
<td>{slot.dj}</td>
|
||||
<td>
|
||||
{slot.showName || <span className="muted">—</span>}
|
||||
{slot.description ? (
|
||||
<div className="muted">{slot.description}</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { postShout } from "@/actions/radio-shouts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
|
||||
}
|
||||
|
||||
export default async function RadioShoutsPage() {
|
||||
const [shouts, imagerBase, session] = await Promise.all([
|
||||
prisma.radioShouts
|
||||
.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
})
|
||||
.catch(() => []),
|
||||
siteSettings.get(
|
||||
"habbo_imaging_url",
|
||||
"https://www.habbo.com/habbo-imaging/avatarimage",
|
||||
),
|
||||
auth(),
|
||||
]);
|
||||
|
||||
const isLoggedIn = Boolean(session?.user?.id);
|
||||
|
||||
// Resolve author usernames/looks. shout.userId is BigInt; User.id is Int.
|
||||
const authorIds = Array.from(new Set(shouts.map((s) => Number(s.userId))));
|
||||
const authors = authorIds.length
|
||||
? await prisma.user
|
||||
.findMany({
|
||||
where: { id: { in: authorIds } },
|
||||
select: { id: true, username: true, look: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Radio Shouts</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Shout-outs and requests from the community.
|
||||
</p>
|
||||
|
||||
{isLoggedIn ? (
|
||||
<form action={postShout} className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
{/* The author is taken from the session inside the action, never from
|
||||
this form — no user id is submitted. */}
|
||||
<label htmlFor="shout-message" className="muted">
|
||||
Post a shout
|
||||
</label>
|
||||
<textarea
|
||||
id="shout-message"
|
||||
name="message"
|
||||
required
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
placeholder="Send a shout to the DJ…"
|
||||
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Shout
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="muted">Log in to post a shout.</p>
|
||||
)}
|
||||
|
||||
{shouts.length === 0 ? (
|
||||
<p className="muted">No shouts yet. Be the first!</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{shouts.map((s) => {
|
||||
const author = authorById.get(Number(s.userId));
|
||||
const authorAvatar = author
|
||||
? avatarImageUrl(imagerBase ?? "", author.look, {
|
||||
size: "s",
|
||||
headOnly: true,
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={String(s.id)}
|
||||
className="card"
|
||||
style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}
|
||||
>
|
||||
{authorAvatar ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
className="avatar"
|
||||
src={authorAvatar}
|
||||
alt={author?.username ?? "author"}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ margin: "0 0 0.25rem" }}>
|
||||
<strong>{author?.username ?? `User #${s.userId}`}</strong>{" "}
|
||||
<span className="muted">{formatDate(s.createdAt)}</span>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{s.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { env } from "@/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Social providers we can link. `available` reflects whether the OAuth app
|
||||
// credentials are configured (mirrors src/lib/auth.ts, which only registers a
|
||||
// provider when both its id + secret env vars are set). When unavailable the
|
||||
// connect button is disabled so we don't bounce the user to a 404 sign-in URL.
|
||||
const PROVIDERS = [
|
||||
{
|
||||
key: "discord",
|
||||
label: "Discord",
|
||||
available: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET),
|
||||
},
|
||||
{
|
||||
key: "google",
|
||||
label: "Google",
|
||||
available: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default async function ConnectionsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const id = Number(session.user.id);
|
||||
|
||||
// social_accounts is a CMS-only table that may not exist in every install
|
||||
// (it has no bundled migration). Fall back to "nothing linked" on any error so
|
||||
// the page still renders the generic connect buttons.
|
||||
let linked: Set<string> = new Set();
|
||||
let lookupFailed = false;
|
||||
try {
|
||||
const rows = await prisma.socialAccounts.findMany({
|
||||
where: { userId: BigInt(id) },
|
||||
select: { provider: true },
|
||||
});
|
||||
linked = new Set(rows.map((r) => r.provider.toLowerCase()));
|
||||
} catch {
|
||||
lookupFailed = true;
|
||||
}
|
||||
|
||||
// After connecting/erroring, NextAuth bounces back here so we land on a known page.
|
||||
const callbackUrl = "/settings/connections";
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 560 }}>
|
||||
<p className="muted">
|
||||
<Link href="/settings">← Settings</Link>
|
||||
</p>
|
||||
<h1>Connected accounts</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Link a social account so you can sign in with one click. We match it to
|
||||
your hotel account by e-mail, so use the address on file:{" "}
|
||||
<strong>{session.user.name}</strong>.
|
||||
</p>
|
||||
|
||||
{lookupFailed ? (
|
||||
<div className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
We couldn't read your linked accounts right now. You can still connect
|
||||
below — your status will show once it's available.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid" style={{ gap: "1rem" }}>
|
||||
{PROVIDERS.map((p) => {
|
||||
const isLinked = linked.has(p.key);
|
||||
return (
|
||||
<div
|
||||
key={p.key}
|
||||
className="card"
|
||||
style={{ display: "flex", alignItems: "center", gap: "1rem" }}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{ margin: "0 0 0.25rem" }}>{p.label}</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{!p.available
|
||||
? "Not available on this hotel."
|
||||
: isLinked
|
||||
? "Connected — you can sign in with this account."
|
||||
: "Not connected."}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{isLinked ? (
|
||||
<span
|
||||
className="btn btn-outline"
|
||||
style={{ cursor: "default", pointerEvents: "none" }}
|
||||
aria-disabled="true"
|
||||
>
|
||||
Connected
|
||||
</span>
|
||||
) : p.available ? (
|
||||
// Plain link into NextAuth's built-in sign-in handler. Re-using
|
||||
// the same OAuth flow as /login binds the provider to the
|
||||
// matching hotel account (see auth.ts signIn callback).
|
||||
<a
|
||||
className="btn btn-primary"
|
||||
href={`/api/auth/signin/${p.key}?callbackUrl=${encodeURIComponent(
|
||||
callbackUrl,
|
||||
)}`}
|
||||
>
|
||||
Connect {p.label}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
className="btn"
|
||||
style={{ cursor: "not-allowed", opacity: 0.55, pointerEvents: "none" }}
|
||||
aria-disabled="true"
|
||||
>
|
||||
Unavailable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="muted" style={{ marginTop: "1.5rem", marginBottom: 0 }}>
|
||||
Connecting takes you to the provider to authorise, then back here. If your
|
||||
social account's e-mail doesn't match a hotel account, you'll be returned
|
||||
to the login screen with a note.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -92,6 +92,16 @@ export default async function SettingsPage() {
|
||||
Two-factor authentication
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ maxWidth: 520, marginTop: "1.5rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>Connected accounts</h3>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Link Discord or Google to sign in with one click.
|
||||
</p>
|
||||
<Link href="/settings/connections" className="btn btn-outline">
|
||||
Manage connections
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Client form for /shop/topup. Posts the chosen amount to /api/paypal/create,
|
||||
* then redirects the browser to PayPal's approval URL. After PayPal returns to
|
||||
* /shop/topup?token=<orderId>&status=success, it captures via /api/paypal/capture.
|
||||
*/
|
||||
export default function TopUpForm({
|
||||
currency,
|
||||
creditsPerUnit,
|
||||
}: {
|
||||
currency: string;
|
||||
creditsPerUnit: number;
|
||||
}) {
|
||||
const [amount, setAmount] = useState("5.00");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const numeric = Number(amount);
|
||||
const credits = Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric * creditsPerUnit) : 0;
|
||||
|
||||
async function startCheckout(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
try {
|
||||
const res = await fetch("/api/paypal/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ amount: numeric }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
approveUrl?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (!res.ok || !data.approveUrl) {
|
||||
setError(data.error ?? "Could not start the PayPal checkout.");
|
||||
setPending(false);
|
||||
return;
|
||||
}
|
||||
// Hand off to PayPal for approval.
|
||||
window.location.href = data.approveUrl;
|
||||
} catch {
|
||||
setError("Network error — please try again.");
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function capture(orderId: string) {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/paypal/capture", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orderId }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
ok?: boolean;
|
||||
credits?: number;
|
||||
error?: string;
|
||||
};
|
||||
if (res.ok && data.ok) {
|
||||
setSuccess(
|
||||
`Payment complete! ${(data.credits ?? 0).toLocaleString()} credits were added.`,
|
||||
);
|
||||
} else {
|
||||
setError(data.error ?? "We couldn't confirm your payment. Contact staff if charged.");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error confirming payment — contact staff if you were charged.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
// Clean the token/status query so a refresh doesn't re-capture.
|
||||
window.history.replaceState(null, "", "/shop/topup");
|
||||
}
|
||||
}
|
||||
|
||||
// On return from PayPal (?token=<orderId>&status=success), auto-capture once.
|
||||
if (typeof window !== "undefined" && !pending && !success) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get("token");
|
||||
if (token && params.get("status") === "success") {
|
||||
// Defer to avoid setState during render.
|
||||
queueMicrotask(() => capture(token));
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{ margin: 0, fontWeight: 700, color: "var(--color-accent)" }}
|
||||
>
|
||||
{success}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={startCheckout}
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}
|
||||
>
|
||||
<label htmlFor="amount" style={{ fontWeight: 700 }}>
|
||||
Amount ({currency})
|
||||
</label>
|
||||
<input
|
||||
id="amount"
|
||||
name="amount"
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
step={0.01}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
aria-label={`Amount in ${currency}`}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
{["2.00", "5.00", "10.00", "25.00"].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={`btn ${amount === v ? "btn-primary" : "btn-outline"}`}
|
||||
onClick={() => setAmount(v)}
|
||||
>
|
||||
{currency} {v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
You'll receive{" "}
|
||||
<strong>{credits.toLocaleString()}</strong> credits.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<button type="submit" className="btn btn-primary" disabled={pending || credits <= 0}>
|
||||
{pending ? "Redirecting…" : "Pay with PayPal"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
style={{ margin: 0, fontWeight: 700, color: "var(--color-danger)" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import TopUpForm from "./TopUpForm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { creditsPerUnit, isPayPalConfigured, PAYPAL_CURRENCY } from "@/lib/services/paypal";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TopUpPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ status?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const sp = await searchParams;
|
||||
const id = Number(session.user.id);
|
||||
|
||||
// Current credits balance for context (fail soft).
|
||||
let user: { username: string; credits: number } | null = null;
|
||||
try {
|
||||
user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { username: true, credits: true },
|
||||
});
|
||||
} catch {
|
||||
user = null;
|
||||
}
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const configured = isPayPalConfigured();
|
||||
const rate = creditsPerUnit();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="hero">
|
||||
<h1 style={{ marginBottom: "0.25rem" }}>Top up with PayPal</h1>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Pay securely with PayPal and your credits are added the moment the payment clears.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sp.status === "cancel" ? (
|
||||
<div className="card" style={{ marginBottom: "1.5rem", borderColor: "var(--color-danger)" }}>
|
||||
<p style={{ margin: 0 }}>Checkout was cancelled — no payment was taken.</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Choose an amount</h3>
|
||||
{configured ? (
|
||||
<TopUpForm currency={PAYPAL_CURRENCY} creditsPerUnit={rate} />
|
||||
) : (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
PayPal isn't configured yet. An administrator needs to set{" "}
|
||||
<code>PAYPAL_CLIENT_ID</code> and <code>PAYPAL_SECRET</code> (and optionally{" "}
|
||||
<code>PAYPAL_API</code> for sandbox vs. live).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Your balance</h3>
|
||||
<p style={{ margin: "0 0 0.6rem" }}>
|
||||
Signed in as <strong>{user.username}</strong>.
|
||||
</p>
|
||||
<span className="currency">
|
||||
<span className="coin credits">cr</span>
|
||||
{user.credits.toLocaleString()}
|
||||
</span>
|
||||
<p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}>
|
||||
You receive <strong>{rate.toLocaleString()}</strong> credits per 1.00 {PAYPAL_CURRENCY}.
|
||||
Credits appear in-game the next time you log in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,9 @@ export async function Navigation() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<Link href="/radio" className="nav-item">
|
||||
Radio
|
||||
</Link>
|
||||
{session?.user ? (
|
||||
<Link href="/friends" className="nav-item">
|
||||
Friends
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Paths that must never be gated (otherwise banned/maintenance loop forever).
|
||||
const EXEMPT = ["/banned", "/maintenance", "/login", "/register", "/forgot", "/reset", "/api"];
|
||||
|
||||
function isExempt(path: string): boolean {
|
||||
return EXEMPT.some((p) => path === p || path.startsWith(`${p}/`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Site-wide access enforcement (called from the root layout): routes non-staff
|
||||
* to /maintenance when maintenance mode is on, and banned users to /banned.
|
||||
* Runs in the Node runtime so it can query the DB. The redirect decision is
|
||||
* computed inside try/catch and the redirect() (which throws NEXT_REDIRECT) is
|
||||
* issued OUTSIDE it.
|
||||
*/
|
||||
export async function enforceSiteAccess(): Promise<void> {
|
||||
const h = await headers();
|
||||
const path = h.get("x-pathname") ?? "/";
|
||||
if (isExempt(path)) return;
|
||||
|
||||
let target: string | null = null;
|
||||
try {
|
||||
const session = await auth();
|
||||
const rank = session?.user?.rank ?? 0;
|
||||
|
||||
if (await siteSettings.getBool("maintenance_enabled", false)) {
|
||||
const minLogin = Number(await siteSettings.get("min_maintenance_login_rank", "7")) || 7;
|
||||
if (rank < minLogin) target = "/maintenance";
|
||||
}
|
||||
|
||||
if (!target && session?.user?.id) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const ban = await prisma.ban.findFirst({
|
||||
where: { userId: Number(session.user.id), banExpire: { gt: now } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (ban) target = "/banned";
|
||||
}
|
||||
} catch {
|
||||
// On any failure, fail open (don't lock the whole site out on a DB hiccup).
|
||||
}
|
||||
|
||||
if (target) redirect(target);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
import { env } from "@/env";
|
||||
|
||||
// === Alert service (AtomCMS → Next.js) ===========================================
|
||||
//
|
||||
// A pure, dependency-free server module for raising operational alerts. A single
|
||||
// sendAlert() call (a) persists a row in alert_logs (prisma.alertLogs delegate),
|
||||
// (b) pushes a Discord embed to DISCORD_WEBHOOK_URL when set, and (c) optionally
|
||||
// emails staff via sendMail() when ALERT_EMAIL is set.
|
||||
//
|
||||
// Every external side-effect is wrapped in try/catch so a failing webhook, dead
|
||||
// SMTP host, or unreachable DB never throws into the caller (which is usually a
|
||||
// background path: emulator health checks, DDoS detection, etc.). Uses the global
|
||||
// fetch (Node 18+/Next 16) — no extra packages.
|
||||
//
|
||||
// NOTE: DISCORD_WEBHOOK_URL and ALERT_EMAIL are NOT declared in src/env.ts, so we
|
||||
// read them from process.env directly with safe fallbacks (the features simply
|
||||
// no-op when their env var is unset). Add them to env.ts later if you want them
|
||||
// validated at boot.
|
||||
|
||||
export type AlertSeverity = "info" | "notice" | "warning" | "error" | "critical";
|
||||
|
||||
export interface SendAlertInput {
|
||||
/** Machine-readable category, e.g. "emulator", "security", "ddos". */
|
||||
type: string;
|
||||
/** Free-text severity; drives Discord embed colour + email subject prefix. */
|
||||
severity: AlertSeverity | string;
|
||||
/** Human-readable message body. */
|
||||
message: string;
|
||||
/** Optional structured payload stored verbatim in alert_logs.context (JSON). */
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SendAlertResult {
|
||||
logged: boolean;
|
||||
sentViaDiscord: boolean;
|
||||
sentViaEmail: boolean;
|
||||
}
|
||||
|
||||
// Discord embed sidebar colours (decimal RGB) keyed by normalised severity.
|
||||
const DISCORD_COLORS: Record<string, number> = {
|
||||
critical: 0xc0392b,
|
||||
error: 0xe74c3c,
|
||||
danger: 0xe74c3c,
|
||||
warning: 0xf39c12,
|
||||
warn: 0xf39c12,
|
||||
success: 0x2ecc71,
|
||||
info: 0x3498db,
|
||||
notice: 0x9b59b6,
|
||||
};
|
||||
|
||||
function severityColor(severity: string): number {
|
||||
return DISCORD_COLORS[severity.toLowerCase()] ?? 0x95a5a6;
|
||||
}
|
||||
|
||||
function discordWebhookUrl(): string | undefined {
|
||||
const url = process.env.DISCORD_WEBHOOK_URL?.trim();
|
||||
return url ? url : undefined;
|
||||
}
|
||||
|
||||
function alertEmail(): string | undefined {
|
||||
const addr = process.env.ALERT_EMAIL?.trim();
|
||||
return addr ? addr : undefined;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a Discord embed to DISCORD_WEBHOOK_URL. Returns false (without throwing)
|
||||
* when the webhook is unset, the request fails, or Discord returns non-2xx.
|
||||
*/
|
||||
async function postDiscord(input: SendAlertInput): Promise<boolean> {
|
||||
const url = discordWebhookUrl();
|
||||
if (!url) return false;
|
||||
|
||||
const fields = input.context
|
||||
? Object.entries(input.context)
|
||||
.slice(0, 10)
|
||||
.map(([name, value]) => ({
|
||||
name: String(name).slice(0, 256) || "",
|
||||
value: String(value ?? "").slice(0, 1024) || "",
|
||||
inline: true,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const body = {
|
||||
username: `${env.HOTEL_NAME} Alerts`,
|
||||
embeds: [
|
||||
{
|
||||
title: `[${String(input.severity).toUpperCase()}] ${input.type}`.slice(0, 256),
|
||||
description: input.message.slice(0, 4096),
|
||||
color: severityColor(input.severity),
|
||||
timestamp: new Date().toISOString(),
|
||||
...(fields && fields.length ? { fields } : {}),
|
||||
footer: { text: env.HOTEL_NAME },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error("[alert] Discord webhook returned", res.status);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[alert] Discord webhook failed:", (e as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Email the alert to ALERT_EMAIL via sendMail(). Returns false (without throwing)
|
||||
* when ALERT_EMAIL is unset, SMTP isn't configured, or sending fails. sendMail
|
||||
* already swallows its own errors, but we guard defensively anyway.
|
||||
*/
|
||||
async function emailStaff(input: SendAlertInput): Promise<boolean> {
|
||||
const to = alertEmail();
|
||||
if (!to) return false;
|
||||
|
||||
const subject = `[${env.HOTEL_NAME}] ${String(input.severity).toUpperCase()} · ${input.type}`;
|
||||
const contextRows = input.context
|
||||
? Object.entries(input.context)
|
||||
.map(
|
||||
([k, v]) =>
|
||||
`<tr><td style="padding:4px 10px;font-weight:600">${escapeHtml(String(k))}</td>` +
|
||||
`<td style="padding:4px 10px">${escapeHtml(String(v ?? ""))}</td></tr>`,
|
||||
)
|
||||
.join("")
|
||||
: "";
|
||||
const html =
|
||||
`<h2 style="margin:0 0 8px">${escapeHtml(input.type)} — ${escapeHtml(String(input.severity))}</h2>` +
|
||||
`<p style="margin:0 0 12px">${escapeHtml(input.message)}</p>` +
|
||||
(contextRows
|
||||
? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>`
|
||||
: "") +
|
||||
`<p style="margin-top:16px;color:#888;font-size:12px">` +
|
||||
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/admin/alerts">view alerts</a></p>`;
|
||||
|
||||
try {
|
||||
return await sendMail(to, subject, html);
|
||||
} catch (e) {
|
||||
console.error("[alert] staff email failed:", (e as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise an operational alert: persist to alert_logs and fan out to Discord +
|
||||
* email. Never throws — each side-effect is isolated; a failure in one channel
|
||||
* does not block the others. The returned result reports which channels
|
||||
* succeeded (also reflected in the alert_logs row's sent_via_* flags).
|
||||
*/
|
||||
export async function sendAlert(input: SendAlertInput): Promise<SendAlertResult> {
|
||||
// Fan out Discord + email first so we can record their outcome on the row.
|
||||
const [sentViaDiscord, sentViaEmail] = await Promise.all([
|
||||
postDiscord(input),
|
||||
emailStaff(input),
|
||||
]);
|
||||
|
||||
let logged = false;
|
||||
try {
|
||||
await prisma.alertLogs.create({
|
||||
data: {
|
||||
type: input.type.slice(0, 255),
|
||||
severity: String(input.severity).slice(0, 255),
|
||||
message: input.message,
|
||||
context: input.context ? (input.context as object) : undefined,
|
||||
sentViaDiscord,
|
||||
sentViaEmail,
|
||||
isRead: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
logged = true;
|
||||
} catch (e) {
|
||||
// DB unreachable / schema drift: keep the alert best-effort. We already
|
||||
// notified Discord/email above, so the alert isn't lost.
|
||||
console.error("[alert] failed to persist alert_logs row:", (e as Error).message);
|
||||
}
|
||||
|
||||
return { logged, sentViaDiscord, sentViaEmail };
|
||||
}
|
||||
|
||||
// === Helpers =====================================================================
|
||||
|
||||
/**
|
||||
* Raise a CRITICAL alert that the Arcturus emulator appears to be offline
|
||||
* (e.g. raised by a health-check cron when the RCON socket can't connect).
|
||||
*/
|
||||
export function emulatorOffline(detail?: string): Promise<SendAlertResult> {
|
||||
return sendAlert({
|
||||
type: "emulator",
|
||||
severity: "critical",
|
||||
message: detail
|
||||
? `Emulator appears offline: ${detail}`
|
||||
: "Emulator appears offline — RCON connection could not be established.",
|
||||
context: {
|
||||
rconHost: env.RCON_HOST,
|
||||
rconPort: env.RCON_PORT,
|
||||
...(detail ? { detail } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise a WARNING alert that a possible DDoS / abusive request pattern was
|
||||
* detected from a single IP (count = requests seen in the sampling window).
|
||||
*/
|
||||
export function ddosDetected(ip: string, count: number): Promise<SendAlertResult> {
|
||||
return sendAlert({
|
||||
type: "ddos",
|
||||
severity: count >= 1000 ? "critical" : "warning",
|
||||
message: `Possible DDoS detected from ${ip} — ${count} requests in window.`,
|
||||
context: { ip, count },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// PayPal v2 REST helper (Orders API) for the top-up flow. SDK-free: uses the
|
||||
// global fetch only. Credentials and base URL come from process.env because they
|
||||
// are not declared in src/env.ts:
|
||||
// PAYPAL_CLIENT_ID – REST app client id
|
||||
// PAYPAL_SECRET – REST app secret
|
||||
// PAYPAL_API – API base, defaults to the sandbox host
|
||||
// PAYPAL_CURRENCY – ISO currency for orders, defaults to USD
|
||||
// PAYPAL_CREDITS_PER_USD – credits granted per 1.00 unit, defaults to 100
|
||||
//
|
||||
// The website has no dedicated balance column (see prisma/schema.prisma — User
|
||||
// carries `credits`, the in-game wallet), so a top-up credits the buyer's
|
||||
// `credits` wallet via sendCurrency(), exactly like the voucher flow.
|
||||
|
||||
export const PAYPAL_API =
|
||||
process.env.PAYPAL_API?.replace(/\/+$/, "") ?? "https://api-m.sandbox.paypal.com";
|
||||
|
||||
export const PAYPAL_CURRENCY = (process.env.PAYPAL_CURRENCY ?? "USD").toUpperCase();
|
||||
|
||||
/** Credits granted per 1.00 of the order currency (configurable, sane default). */
|
||||
export function creditsPerUnit(): number {
|
||||
const n = Number(process.env.PAYPAL_CREDITS_PER_USD ?? "100");
|
||||
return Number.isFinite(n) && n > 0 ? n : 100;
|
||||
}
|
||||
|
||||
export class PayPalConfigError extends Error {}
|
||||
|
||||
function credentials(): { clientId: string; secret: string } {
|
||||
const clientId = process.env.PAYPAL_CLIENT_ID;
|
||||
const secret = process.env.PAYPAL_SECRET;
|
||||
if (!clientId || !secret) {
|
||||
throw new PayPalConfigError(
|
||||
"PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET.",
|
||||
);
|
||||
}
|
||||
return { clientId, secret };
|
||||
}
|
||||
|
||||
/** True when both PayPal credentials are present. */
|
||||
export function isPayPalConfigured(): boolean {
|
||||
return Boolean(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_SECRET);
|
||||
}
|
||||
|
||||
/** OAuth2 client-credentials token (short-lived; we fetch one per request). */
|
||||
async function getAccessToken(): Promise<string> {
|
||||
const { clientId, secret } = credentials();
|
||||
const basic = Buffer.from(`${clientId}:${secret}`).toString("base64");
|
||||
const res = await fetch(`${PAYPAL_API}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${basic}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`PayPal auth failed (${res.status}): ${body.slice(0, 300)}`);
|
||||
}
|
||||
const json = (await res.json()) as { access_token?: string };
|
||||
if (!json.access_token) throw new Error("PayPal auth returned no access_token.");
|
||||
return json.access_token;
|
||||
}
|
||||
|
||||
export interface CreatedOrder {
|
||||
id: string;
|
||||
approveUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CAPTURE order for `amount` of the configured currency. Returns the
|
||||
* order id and the payer approval URL (rel === "approve") to redirect to.
|
||||
*/
|
||||
export async function createOrder(
|
||||
amount: number,
|
||||
opts: { description?: string; returnUrl?: string; cancelUrl?: string } = {},
|
||||
): Promise<CreatedOrder> {
|
||||
const token = await getAccessToken();
|
||||
const value = amount.toFixed(2);
|
||||
|
||||
const res = await fetch(`${PAYPAL_API}/v2/checkout/orders`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({
|
||||
intent: "CAPTURE",
|
||||
purchase_units: [
|
||||
{
|
||||
amount: { currency_code: PAYPAL_CURRENCY, value },
|
||||
description: opts.description?.slice(0, 127),
|
||||
},
|
||||
],
|
||||
application_context: {
|
||||
shipping_preference: "NO_SHIPPING",
|
||||
user_action: "PAY_NOW",
|
||||
...(opts.returnUrl ? { return_url: opts.returnUrl } : {}),
|
||||
...(opts.cancelUrl ? { cancel_url: opts.cancelUrl } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`PayPal create order failed (${res.status}): ${body.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
id: string;
|
||||
links?: { rel: string; href: string }[];
|
||||
};
|
||||
const approveUrl =
|
||||
json.links?.find((l) => l.rel === "approve" || l.rel === "payer-action")?.href ?? null;
|
||||
return { id: json.id, approveUrl };
|
||||
}
|
||||
|
||||
export interface CaptureResult {
|
||||
id: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
captureId: string | null;
|
||||
payerEmail: string | null;
|
||||
}
|
||||
|
||||
/** Capture a previously-approved order id. */
|
||||
export async function captureOrder(orderId: string): Promise<CaptureResult> {
|
||||
const token = await getAccessToken();
|
||||
const res = await fetch(`${PAYPAL_API}/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`PayPal capture failed (${res.status}): ${body.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
id: string;
|
||||
status: string;
|
||||
payer?: { email_address?: string };
|
||||
purchase_units?: {
|
||||
payments?: {
|
||||
captures?: {
|
||||
id: string;
|
||||
amount?: { value?: string; currency_code?: string };
|
||||
}[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
const capture = json.purchase_units?.[0]?.payments?.captures?.[0];
|
||||
const amount = capture?.amount?.value ? Number(capture.amount.value) : 0;
|
||||
const currency = capture?.amount?.currency_code ?? PAYPAL_CURRENCY;
|
||||
|
||||
return {
|
||||
id: json.id,
|
||||
status: json.status,
|
||||
amount,
|
||||
currency,
|
||||
captureId: capture?.id ?? null,
|
||||
payerEmail: json.payer?.email_address ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* Append a staff-action audit entry (AtomCMS StaffActivity). Never throws —
|
||||
* logging must not block the action it records.
|
||||
*/
|
||||
export async function logStaffActivity(opts: {
|
||||
staffId: number;
|
||||
action: string;
|
||||
description: string;
|
||||
targetType?: string;
|
||||
targetId?: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
let ip: string | null = null;
|
||||
try {
|
||||
const h = await headers();
|
||||
ip = h.get("x-real-client-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
|
||||
} catch {
|
||||
ip = null;
|
||||
}
|
||||
await prisma.staffActivities.create({
|
||||
data: {
|
||||
userId: BigInt(opts.staffId),
|
||||
action: opts.action.slice(0, 50),
|
||||
description: opts.description,
|
||||
targetType: opts.targetType ?? null,
|
||||
targetId: opts.targetId != null ? BigInt(opts.targetId) : null,
|
||||
ipAddress: ip,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// swallow — audit logging is best-effort
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
// Edge middleware: Prisma can't run here, so we only forward the request path
|
||||
// (so server components / the access guard can read it via headers()) and
|
||||
// normalize the real client IP. The DB-backed banned/maintenance checks happen
|
||||
// in src/lib/access-guard.ts (Node runtime) from the root layout.
|
||||
export function middleware(req: NextRequest) {
|
||||
const headers = new Headers(req.headers);
|
||||
headers.set("x-pathname", req.nextUrl.pathname);
|
||||
const ip =
|
||||
req.headers.get("cf-connecting-ip") ??
|
||||
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
req.headers.get("x-real-ip") ??
|
||||
"";
|
||||
if (ip) headers.set("x-real-client-ip", ip);
|
||||
return NextResponse.next({ request: { headers } });
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next/static|_next/image|assets|favicon.ico).*)"],
|
||||
};
|
||||
Reference in new issue
Block a user