diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 11e8060..db709de 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -220,6 +220,7 @@ model User { adminNotifications AdminNotification[] @relation("AdminNotificationCreator") ipBlocksCreated IPBlock[] @relation("IPBlockCreator") postViewLogs PostViewLog[] @relation("PostViewLogUser") + couponRedemptions CouponRedemption[] @@index([status]) @@index([createdAt]) @@ -583,3 +584,35 @@ model PasswordResetToken { @@index([userId, expiresAt]) @@map("password_reset_tokens") } + +// 쿠폰 +model Coupon { + id String @id @default(cuid()) + code String @unique + title String + description String? + stock Int @default(0) // 총 재고 + maxPerUser Int @default(1) // 1인당 제한 + expiresAt DateTime? + active Boolean @default(true) + createdAt DateTime @default(now()) + + redemptions CouponRedemption[] + + @@index([active, expiresAt]) + @@map("coupons") +} + +model CouponRedemption { + id String @id @default(cuid()) + couponId String + userId String + createdAt DateTime @default(now()) + + coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [userId], onDelete: Cascade) + + @@unique([couponId, userId]) + @@index([userId, createdAt]) + @@map("coupon_redemptions") +} diff --git a/src/app/api/coupons/redeem/route.ts b/src/app/api/coupons/redeem/route.ts new file mode 100644 index 0000000..7814b33 --- /dev/null +++ b/src/app/api/coupons/redeem/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { getUserIdFromRequest } from "@/lib/auth"; +import { z } from "zod"; + +const schema = z.object({ code: z.string().min(1) }); + +export async function POST(req: Request) { + const userId = getUserIdFromRequest(req); + if (!userId) return NextResponse.json({ error: "login required" }, { status: 401 }); + const body = await req.json().catch(() => ({})); + const parsed = schema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + const coupon = await prisma.coupon.findUnique({ where: { code: parsed.data.code } }); + if (!coupon || !coupon.active) return NextResponse.json({ error: "invalid coupon" }, { status: 404 }); + if (coupon.expiresAt && coupon.expiresAt < new Date()) return NextResponse.json({ error: "expired" }, { status: 400 }); + const used = await prisma.couponRedemption.count({ where: { couponId: coupon.id } }); + if (coupon.stock > 0 && used >= coupon.stock) return NextResponse.json({ error: "out of stock" }, { status: 400 }); + const mine = await prisma.couponRedemption.count({ where: { couponId: coupon.id, userId } }); + if (mine >= coupon.maxPerUser) return NextResponse.json({ error: "quota exceeded" }, { status: 400 }); + await prisma.couponRedemption.create({ data: { couponId: coupon.id, userId } }); + return NextResponse.json({ ok: true }); +} + + diff --git a/src/app/api/coupons/route.ts b/src/app/api/coupons/route.ts new file mode 100644 index 0000000..a32be0f --- /dev/null +++ b/src/app/api/coupons/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { z } from "zod"; + +export async function GET() { + const now = new Date(); + const coupons = await prisma.coupon.findMany({ + where: { active: true, OR: [{ expiresAt: null }, { expiresAt: { gte: now } }] }, + orderBy: { createdAt: "desc" }, + select: { id: true, code: true, title: true, description: true, stock: true, maxPerUser: true, expiresAt: true }, + }); + // 사용량 계산 + const ids = coupons.map((c) => c.id); + const agg = await prisma.couponRedemption.groupBy({ by: ["couponId"], where: { couponId: { in: ids } }, _count: { couponId: true } }); + const usedMap = new Map(agg.map((a) => [a.couponId, a._count.couponId])); + const items = coupons.map((c) => ({ ...c, used: usedMap.get(c.id) ?? 0 })); + return NextResponse.json({ coupons: items }); +} + +const createSchema = z.object({ + code: z.string().min(1), + title: z.string().min(1), + description: z.string().optional(), + stock: z.coerce.number().int().min(0).default(0), + maxPerUser: z.coerce.number().int().min(1).default(1), + expiresAt: z.coerce.date().optional(), +}); + +export async function POST(req: Request) { + const body = await req.json().catch(() => ({})); + const parsed = createSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + const data = await prisma.coupon.create({ data: parsed.data }); + return NextResponse.json({ coupon: data }, { status: 201 }); +} + + diff --git a/src/app/coupons/page.tsx b/src/app/coupons/page.tsx new file mode 100644 index 0000000..1eb9848 --- /dev/null +++ b/src/app/coupons/page.tsx @@ -0,0 +1,33 @@ +"use client"; +import useSWR from "swr"; + +const fetcher = (url: string) => fetch(url).then((r) => r.json()); + +export default function CouponsPage() { + const { data, mutate } = useSWR<{ coupons: { id: string; code: string; title: string; description?: string; stock: number; maxPerUser: number; expiresAt?: string; used: number }[] }>("/api/coupons", fetcher); + async function redeem(code: string) { + await fetch("/api/coupons/redeem", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code }) }); + mutate(); + } + return ( +