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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user