diff --git a/prisma/migrations/20250531210837_punishments_returned/migration.sql b/prisma/migrations/20250531210837_punishments_returned/migration.sql new file mode 100644 index 0000000..51a04bd --- /dev/null +++ b/prisma/migrations/20250531210837_punishments_returned/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "punishments" ADD COLUMN "returned" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9eaf964..ebd21f2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -136,9 +136,10 @@ model MiiPunishment { } model Punishment { - id Int @id @default(autoincrement()) - userId Int - type PunishmentType + id Int @id @default(autoincrement()) + userId Int + type PunishmentType + returned Boolean @default(false) notes String reasons String[] diff --git a/src/app/api/admin/lookup/route.ts b/src/app/api/admin/lookup/route.ts index 3020f3c..e5307f3 100644 --- a/src/app/api/admin/lookup/route.ts +++ b/src/app/api/admin/lookup/route.ts @@ -22,9 +22,14 @@ export async function GET(request: NextRequest) { }, include: { punishments: { + orderBy: { + createdAt: "desc", + }, select: { id: true, type: true, + returned: true, + notes: true, reasons: true, violatingMiis: { @@ -33,6 +38,7 @@ export async function GET(request: NextRequest) { reason: true, }, }, + expiresAt: true, createdAt: true, }, diff --git a/src/app/api/admin/punish/route.ts b/src/app/api/admin/punish/route.ts index 80000d2..c386f6a 100644 --- a/src/app/api/admin/punish/route.ts +++ b/src/app/api/admin/punish/route.ts @@ -67,3 +67,24 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true }); } + +export async function DELETE(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 parsedPunishmentId = idSchema.safeParse(searchParams.get("id")); + + if (!parsedPunishmentId.success) return NextResponse.json({ error: parsedPunishmentId.error.errors[0].message }, { status: 400 }); + const punishmentId = parsedPunishmentId.data; + + await prisma.punishment.delete({ + where: { + id: punishmentId, + }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/return/route.ts b/src/app/api/return/route.ts new file mode 100644 index 0000000..c3cb3eb --- /dev/null +++ b/src/app/api/return/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { auth } from "@/lib/auth"; +import { RateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/prisma"; + +export async function DELETE(request: NextRequest) { + const session = await auth(); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const rateLimit = new RateLimit(request, 1); + const check = await rateLimit.handle(); + if (check) return check; + + const activePunishment = await prisma.punishment.findFirst({ + where: { + userId: Number(session.user.id), + returned: false, + }, + include: { + violatingMiis: { + include: { + mii: { + select: { + name: true, + }, + }, + }, + }, + }, + }); + + if (!activePunishment) return rateLimit.sendResponse({ error: "You have no active punishments!" }, 404); + if (activePunishment.type === "PERM_EXILE") return rateLimit.sendResponse({ error: "Your punishment is permanent" }, 403); + if (activePunishment.type === "TEMP_EXILE" && activePunishment.expiresAt! > new Date()) + return rateLimit.sendResponse({ error: "Your punishment has not expired yet." }, 403); + + await prisma.punishment.update({ + where: { + id: activePunishment.id, + }, + data: { + returned: true, + }, + }); + + return rateLimit.sendResponse({ success: true }); +} diff --git a/src/app/globals.css b/src/app/globals.css index ffec983..40a1877 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -39,6 +39,10 @@ body { @apply hover:bg-orange-400 transition cursor-pointer; } +.button:disabled { + @apply text-zinc-600 !bg-zinc-100 !border-zinc-300 cursor-auto; +} + .input { @apply !bg-orange-200 outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40; } diff --git a/src/app/off-the-island/page.tsx b/src/app/off-the-island/page.tsx new file mode 100644 index 0000000..cea9dd9 --- /dev/null +++ b/src/app/off-the-island/page.tsx @@ -0,0 +1,124 @@ +import { Metadata } from "next"; +import { redirect } from "next/navigation"; +import Image from "next/image"; +import Link from "next/link"; + +import dayjs from "dayjs"; + +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +import ReturnToIsland from "@/components/admin/return-to-island"; + +export const metadata: Metadata = { + title: "Exiled - TomodachiShare", + description: "You have been exiled from the TomodachiShare island...", + robots: { + index: false, + follow: false, + }, +}; + +export default async function ExiledPage() { + const session = await auth(); + + if (!session?.user) redirect("/"); + + const activePunishment = await prisma.punishment.findFirst({ + where: { + userId: Number(session?.user.id), + returned: false, + }, + include: { + violatingMiis: { + include: { + mii: { + select: { + name: true, + }, + }, + }, + }, + }, + }); + + if (!activePunishment) redirect("/"); + + const expiresAt = dayjs(activePunishment.expiresAt); + const createdAt = dayjs(activePunishment.createdAt); + + const hasExpired = activePunishment.type === "TEMP_EXILE" && activePunishment.expiresAt! > new Date(); + const duration = activePunishment.type === "TEMP_EXILE" && Math.ceil(expiresAt.diff(createdAt, "days", true)); + + return ( +
+ You have been exiled from the TomodachiShare island because you violated the{" "} + + Terms of Service + + . +
+ ++ Reviewed: {activePunishment.createdAt.toLocaleDateString("en-GB")} at{" "} + {activePunishment.createdAt.toLocaleString("en-GB")} +
+ ++ Note: {activePunishment.notes} +
+ ++ Reason: {reason} +
++ {mii.mii.name} +
++ Reason: {mii.reason} +
+Once your punishment ends, you can return by checking the box below.
+Your punishment is permanent, therefore you cannot return.
+ > + )} +Are you sure? This will delete the user‘s punishment and they will be able to come back.
+ + {error && Error: {error}} + +Notes: {punishment.notes}
-- Expires:{" "} - {punishment.expiresAt - ? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }) - : "Never"} -
+ {punishment.type !== "WARNING" && ( ++ Expires:{" "} + {punishment.expiresAt + ? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }) + : "Never"} +
+ )} + {punishment.type !== "PERM_EXILE" && ( ++ Returned: {JSON.stringify(punishment.returned)} +
+ )}Reasons: