feat(admin): add admin panel with user management, daily puzzle CRUD, and activity graph

- Add admin page at /admin (restricted to ADMIN_EMAIL via proxy)
- Implement user ban/unban and deletion
- Add daily puzzle creation, modification, deletion
- Include 14-day activity chart with hover details
- Add User.banned field to schema and migrate database
- Block banned users from logging in
- Add ADMIN_EMAIL to .env.local configuration
- Update Prisma client after schema changes
This commit is contained in:
jessy-david-dev
2026-04-11 15:10:08 +02:00
parent 5d8f7eae45
commit 8ff64b3470
22 changed files with 899 additions and 122 deletions
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../../lib/prisma";
type Params = { params: Promise<{ id: string }> };
// PATCH /api/admin/daily/[id] — modifier un puzzle
export async function PATCH(req: Request, { params }: Params) {
const { id } = await params;
const { startArticle, targetArticle } = await req.json() as {
startArticle: string;
targetArticle: string;
};
const puzzle = await prisma.dailyPuzzle.update({
where: { id },
data: { startArticle, targetArticle },
});
return NextResponse.json(puzzle);
}
// DELETE /api/admin/daily/[id] — supprimer un puzzle
export async function DELETE(_req: Request, { params }: Params) {
const { id } = await params;
await prisma.dailyPuzzle.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
// GET /api/admin/daily — liste tous les puzzles
export async function GET() {
const puzzles = await prisma.dailyPuzzle.findMany({
orderBy: { date: "desc" },
take: 30,
include: { _count: { select: { results: true } } },
});
return NextResponse.json(puzzles);
}
// POST /api/admin/daily — créer un puzzle pour une date
export async function POST(req: Request) {
const { date, startArticle, targetArticle } = await req.json() as {
date: string;
startArticle: string;
targetArticle: string;
};
const puzzle = await prisma.dailyPuzzle.upsert({
where: { date },
create: { date, startArticle, targetArticle },
update: { startArticle, targetArticle },
});
return NextResponse.json(puzzle);
}
+80
View File
@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
export async function GET() {
const now = new Date();
const todayStart = new Date(now);
todayStart.setHours(0, 0, 0, 0);
// Activité sur les 14 derniers jours
const since14 = new Date(todayStart);
since14.setDate(since14.getDate() - 13);
const [totalUsers, totalGames, todayGames, recentUsers, recentGames, modeStats, activityRaw] =
await Promise.all([
prisma.user.count(),
prisma.game.count(),
prisma.game.count({ where: { playedAt: { gte: todayStart } } }),
prisma.user.findMany({
orderBy: { createdAt: "desc" },
take: 50,
select: {
id: true,
name: true,
email: true,
banned: true,
createdAt: true,
_count: { select: { games: true } },
},
}),
prisma.game.findMany({
orderBy: { playedAt: "desc" },
take: 50,
select: {
id: true,
mode: true,
startArticle: true,
targetArticle: true,
clicks: true,
timeSeconds: true,
won: true,
playedAt: true,
user: { select: { name: true } },
},
}),
prisma.game.groupBy({
by: ["mode"],
_count: { id: true },
_avg: { clicks: true, timeSeconds: true },
}),
prisma.game.findMany({
where: { playedAt: { gte: since14 } },
select: { playedAt: true },
}),
]);
// Agréger l'activité par jour
const activityMap = new Map<string, number>();
for (let i = 0; i < 14; i++) {
const d = new Date(since14);
d.setDate(d.getDate() + i);
activityMap.set(d.toISOString().slice(0, 10), 0);
}
for (const g of activityRaw) {
const key = g.playedAt.toISOString().slice(0, 10);
activityMap.set(key, (activityMap.get(key) ?? 0) + 1);
}
const activity = Array.from(activityMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, count]) => ({ date, count }));
return NextResponse.json({
totalUsers,
totalGames,
todayGames,
recentUsers,
recentGames,
modeStats,
activity,
});
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../../lib/prisma";
type Params = { params: Promise<{ id: string }> };
// DELETE /api/admin/users/[id] — supprimer un utilisateur
export async function DELETE(_req: Request, { params }: Params) {
const { id } = await params;
await prisma.user.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
// PATCH /api/admin/users/[id] — ban/unban
export async function PATCH(req: Request, { params }: Params) {
const { id } = await params;
const { banned } = await req.json() as { banned: boolean };
const user = await prisma.user.update({ where: { id }, data: { banned } });
return NextResponse.json({ id: user.id, banned: user.banned });
}