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:
Simo committed 2026-06-28 15:10:19 +02:00
1 parent e19debb795
commit 22d53d0e9c
40 files changed
+3781 -6

No files matched your search

+58
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}