diff --git a/prisma/migrations/20250524155917_punishments/migration.sql b/prisma/migrations/20250524155917_punishments/migration.sql new file mode 100644 index 0000000..6efd7e0 --- /dev/null +++ b/prisma/migrations/20250524155917_punishments/migration.sql @@ -0,0 +1,36 @@ +-- CreateEnum +CREATE TYPE "PunishmentType" AS ENUM ('WARNING', 'TEMP_EXILE', 'PERM_EXILE'); + +-- AlterTable +ALTER TABLE "miis" ADD COLUMN "punishmentId" INTEGER; + +-- CreateTable +CREATE TABLE "mii_punishments" ( + "punishmentId" INTEGER NOT NULL, + "miiId" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + + CONSTRAINT "mii_punishments_pkey" PRIMARY KEY ("punishmentId","miiId") +); + +-- CreateTable +CREATE TABLE "punishments" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "type" "PunishmentType" NOT NULL, + "notes" TEXT NOT NULL, + "reasons" TEXT[], + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "punishments_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "mii_punishments" ADD CONSTRAINT "mii_punishments_punishmentId_fkey" FOREIGN KEY ("punishmentId") REFERENCES "punishments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "mii_punishments" ADD CONSTRAINT "mii_punishments_miiId_fkey" FOREIGN KEY ("miiId") REFERENCES "miis"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "punishments" ADD CONSTRAINT "punishments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fe62043..9eaf964 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,12 +21,14 @@ model User { usernameUpdatedAt DateTime? imageUpdatedAt DateTime? - accounts Account[] - sessions Session[] - miis Mii[] - likes Like[] - reportsAuthored Report[] @relation("ReportAuthor") - reports Report[] @relation("ReportTargetCreator") + accounts Account[] + sessions Session[] + miis Mii[] + likes Like[] + + reportsAuthored Report[] @relation("ReportAuthor") + reports Report[] @relation("ReportTargetCreator") + punishments Punishment[] @@map("users") } @@ -84,6 +86,9 @@ model Mii { user User @relation(fields: [userId], references: [id], onDelete: Cascade) likedBy Like[] + punishmentId Int? + punishments MiiPunishment[] + @@map("miis") } @@ -118,6 +123,35 @@ model Report { @@map("reports") } +model MiiPunishment { + punishmentId Int + miiId Int + reason String + + punishment Punishment @relation(fields: [punishmentId], references: [id], onDelete: Cascade) + mii Mii @relation(fields: [miiId], references: [id], onDelete: Cascade) + + @@id([punishmentId, miiId]) + @@map("mii_punishments") +} + +model Punishment { + id Int @id @default(autoincrement()) + userId Int + type PunishmentType + + notes String + reasons String[] + violatingMiis MiiPunishment[] + + expiresAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + + @@map("punishments") +} + enum MiiGender { MALE FEMALE @@ -140,3 +174,9 @@ enum ReportStatus { RESOLVED DISMISSED } + +enum PunishmentType { + WARNING + TEMP_EXILE + PERM_EXILE +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index ceb9a48..5b0a5f1 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -5,6 +5,7 @@ import { auth } from "@/lib/auth"; import BannerForm from "@/components/admin/banner-form"; import ControlCenter from "@/components/admin/control-center"; +import UserManagement from "@/components/admin/user-management"; import Reports from "@/components/admin/reports"; export const metadata: Metadata = { @@ -46,6 +47,15 @@ export default async function AdminPage() { + {/* Separator */} +
+
+ User Management +
+
+ + + {/* Separator */}

diff --git a/src/app/api/admin/lookup/route.ts b/src/app/api/admin/lookup/route.ts new file mode 100644 index 0000000..3020f3c --- /dev/null +++ b/src/app/api/admin/lookup/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { idSchema } from "@/lib/schemas"; + +export async function GET(request: NextRequest) { + const session = await auth(); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const searchParams = request.nextUrl.searchParams; + const parsed = idSchema.safeParse(searchParams.get("id")); + + if (!parsed.success) return NextResponse.json({ error: parsed.error.errors[0].message }, { status: 400 }); + const userId = parsed.data; + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + include: { + punishments: { + select: { + id: true, + type: true, + notes: true, + reasons: true, + violatingMiis: { + select: { + miiId: true, + reason: true, + }, + }, + expiresAt: true, + createdAt: true, + }, + }, + }, + }); + + if (!user) return NextResponse.json({ error: "No user found" }, { status: 404 }); + + return NextResponse.json({ + success: true, + name: user.name, + username: user.username, + image: user.image, + createdAt: user.createdAt, + punishments: user.punishments, + }); +} diff --git a/src/app/api/admin/punish/route.ts b/src/app/api/admin/punish/route.ts new file mode 100644 index 0000000..80000d2 --- /dev/null +++ b/src/app/api/admin/punish/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { z } from "zod"; +import dayjs from "dayjs"; + +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { idSchema } from "@/lib/schemas"; +import { PunishmentType } from "@prisma/client"; + +const punishSchema = z.object({ + type: z.enum([PunishmentType.WARNING, PunishmentType.TEMP_EXILE, PunishmentType.PERM_EXILE]), + duration: z + .number({ message: "Duration (days) must be a number" }) + .int({ message: "Duration (days) must be an integer" }) + .positive({ message: "Duration (days) must be valid" }), + notes: z.string(), + reasons: z.array(z.string()).optional(), + miiReasons: z + .array( + z.object({ + id: z + .number({ message: "Mii ID must be a number" }) + .int({ message: "Mii ID must be an integer" }) + .positive({ message: "Mii ID must be valid" }), + reason: z.string(), + }) + ) + .optional(), +}); + +export async function POST(request: NextRequest) { + const session = await auth(); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const searchParams = request.nextUrl.searchParams; + const parsedUserId = idSchema.safeParse(searchParams.get("id")); + + if (!parsedUserId.success) return NextResponse.json({ error: parsedUserId.error.errors[0].message }, { status: 400 }); + const userId = parsedUserId.data; + + const body = await request.json(); + const parsed = punishSchema.safeParse(body); + + if (!parsed.success) return NextResponse.json({ error: parsed.error.errors[0].message }, { status: 400 }); + const { type, duration, notes, reasons, miiReasons } = parsed.data; + + const expiresAt = type === "TEMP_EXILE" ? dayjs().add(duration, "days").toDate() : null; + + await prisma.punishment.create({ + data: { + userId, + type: type as PunishmentType, + expiresAt, + notes, + reasons: reasons?.length !== 0 ? reasons : [], + violatingMiis: { + create: miiReasons?.map((mii) => ({ + miiId: mii.id, + reason: mii.reason, + })), + }, + }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/src/components/admin/reports.tsx b/src/components/admin/reports.tsx index d42bcdc..3f56c84 100644 --- a/src/components/admin/reports.tsx +++ b/src/components/admin/reports.tsx @@ -26,7 +26,7 @@ export default async function Reports() {
{reports.map((report) => ( -
+
(); + + const [type, setType] = useState("WARNING"); + const [duration, setDuration] = useState(1); + const [notes, setNotes] = useState(""); + const [reasons, setReasons] = useState(""); + + const [miiList, setMiiList] = useState([]); + const [newMii, setNewMii] = useState({ + id: 0, + reason: "", + }); + + const [error, setError] = useState(undefined); + + const addMiiToList = () => { + if (newMii.id && newMii.reason) { + setMiiList([...miiList, { ...newMii, id: Number(newMii.id) }]); + setNewMii({ id: 0, reason: "" }); + } + }; + + const removeMiiFromList = (index: number) => { + setMiiList(miiList.filter((_, i) => i !== index)); + }; + + const handleLookup = async () => { + const response = await fetch(`/api/admin/lookup?id=${userId}`); + const data = await response.json(); + setUser(data); + }; + + const handleSubmit = async () => { + // todo: delete punishments + const response = await fetch(`/api/admin/punish?id=${userId}`, { + method: "POST", + body: JSON.stringify({ + type, + duration, + notes, + reasons: reasons.split(","), + miiReasons: miiList, + }), + }); + + if (!response.ok) { + const { error } = await response.json(); + setError(error); + } + + // Set all inputs to empty/default + setType("WARNING"); + setDuration(1); + setNotes(""); + setReasons(""); + setMiiList([]); + setNewMii({ id: 0, reason: "" }); + setError(""); + + await handleLookup(); + }; + + return ( +
+
+ setUserId(Number(e.target.value))} + className="pill input w-full max-w-lg" + /> + +
+ + {user && ( +
+
+
+ Profile picture +
+

{user.name}

+

@{user.username}

+

+ Created:{" "} + {new Date(user.createdAt).toLocaleString("en-GB", { + day: "2-digit", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZone: "UTC", + })}{" "} + UTC +

+
+
+ +
+ +
+ {user.punishments.length === 0 ? ( +

No punishments found.

+ ) : ( + <> + {user.punishments.map((punishment) => ( +
+
+ + {punishment.type} + + + {new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })} + +
+

+ Notes: {punishment.notes} +

+

+ Expires:{" "} + {punishment.expiresAt + ? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }) + : "Never"} +

+

+ Reasons: +

+
    + {punishment.reasons.map((reason, index) => ( +
  • {reason}
  • + ))} +
+

+ Mii Reasons: +

+
    + {punishment.violatingMiis.map((mii) => ( +
  • + {mii.miiId}: {mii.reason} +
  • + ))} +
+
+ ))} + + )} +
+
+ +
+ {/* Punishment type */} +

Punishment Type

+ + + {/* Punishment duration */} + {type === "TEMP_EXILE" && ( + <> +

Duration

+ + + )} + + {/* Punishment notes */} +

Notes

+