perf: add bans.user_id index, Redis cache layer, rate-limit improvements, radio contest/giveaway columns, and tests

- Add DB index on bans.user_id to speed up per-request ban lookups (migration 0008)
- Replace in-process rate limiter with Redis-backed implementation with in-memory fallback
- Add Redis caching layer for site settings with TTL invalidation (migration 0009)
- Add rate limiting to resetPassword to prevent token brute-force attacks
- Update all rateLimit callers to await the now-async function
- Flesh out RadioContests and RadioGiveaways models with title, description, prize, date, and winner columns
- Update radio contest/giveaway pages to display new fields
- Add tests for rate limiter (4 tests) and password-reset actions (3 tests)
- Add REDIS_URL environment variable (optional, falls back to in-memory)
This commit is contained in:
openhands committed 2026-07-08 12:49:24 +02:00
1 parent 43c0ba6614
commit 5c638cd6bc
25 files changed
+449 -63

No files matched your search

+25 -5
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
@@ -15,9 +16,8 @@ export default async function RadioContestDetailPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const t = await getTranslations("pages.radioContests");
// 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);
@@ -31,7 +31,7 @@ export default async function RadioContestDetailPage({
if (!contest) notFound();
const contestIdStr = contest.id.toString();
const active = contest.isActive ? "Active" : "Ended";
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
@@ -39,9 +39,29 @@ export default async function RadioContestDetailPage({
<Link href="/radio/contests">← Back to contests</Link>
</p>
<ContentCard icon="🎉" title={`Contest #${contestIdStr}`} subtitle="Radio contest details">
<table>
<ContentCard icon="🎉" title={contest.title || `Contest #${contest.id}`} subtitle={active}>
<table style={{ width: "100%" }}>
<tbody>
{contest.description ? (
<tr>
<th style={{ verticalAlign: "top", width: 120 }}>Description</th>
<td>{contest.description}</td>
</tr>
) : null}
{contest.prize ? (
<tr>
<th>Prize</th>
<td>{contest.prize}</td>
</tr>
) : null}
<tr>
<th>Period</th>
<td>
{contest.startDate ? formatDate(contest.startDate) : "—"}
{" — "}
{contest.endDate ? formatDate(contest.endDate) : "ongoing"}
</td>
</tr>
<tr>
<th>Created</th>
<td>{formatDate(contest.createdAt) || <span className="muted">—</span>}</td>