feat(admin): 제품 관리 — CRUD + 파일 업로드·제품 배정
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,7 +34,7 @@ export async function PATCH(request: Request) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const { id, label, sort_order, min_tier } = await request.json();
|
||||
const { id, label, sort_order, min_tier, product_id } = await request.json();
|
||||
if (!id) return NextResponse.json({ error: 'id 필요' }, { status: 400 });
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
@@ -43,6 +43,7 @@ export async function PATCH(request: Request) {
|
||||
if (typeof min_tier === 'string' && VALID_TIERS.has(min_tier as PackTier)) {
|
||||
updates.min_tier = min_tier;
|
||||
}
|
||||
if (typeof product_id === 'string' || product_id === null) updates.product_id = product_id;
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: '변경할 필드 없음' }, { status: 400 });
|
||||
}
|
||||
|
||||
111
app/api/admin/products/route.ts
Normal file
111
app/api/admin/products/route.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
async function checkAuth() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('admin_token')?.value;
|
||||
return token && verifyAdminTokenNode(token);
|
||||
}
|
||||
|
||||
const ID_RE = /^[a-z0-9_]{2,40}$/;
|
||||
|
||||
function sanitizeFeatures(input: unknown): string[] | undefined {
|
||||
if (!Array.isArray(input)) return undefined;
|
||||
return input.filter((v): v is string => typeof v === 'string');
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const supabase = createAdminClient();
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.order('sort_order')
|
||||
.order('id');
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ products: data ?? [] });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { id, name, description, description_long, price, features, is_listed, sort_order } = body;
|
||||
|
||||
if (typeof id !== 'string' || !ID_RE.test(id)) {
|
||||
return NextResponse.json({ error: 'id는 영소문자/숫자/언더스코어 2-40자' }, { status: 400 });
|
||||
}
|
||||
if (typeof name !== 'string' || name.trim().length === 0) {
|
||||
return NextResponse.json({ error: 'name 필요' }, { status: 400 });
|
||||
}
|
||||
if (typeof price !== 'number' || !Number.isInteger(price) || price < 0) {
|
||||
return NextResponse.json({ error: 'price는 0 이상의 정수' }, { status: 400 });
|
||||
}
|
||||
|
||||
const insert: Record<string, unknown> = {
|
||||
id,
|
||||
name: name.trim(),
|
||||
price,
|
||||
category: 'software',
|
||||
pay_method: 'bank_transfer',
|
||||
is_active: true,
|
||||
};
|
||||
if (typeof description === 'string') insert.description = description;
|
||||
if (typeof description_long === 'string') insert.description_long = description_long;
|
||||
const feats = sanitizeFeatures(features);
|
||||
if (feats !== undefined) insert.features = feats;
|
||||
if (typeof is_listed === 'boolean') insert.is_listed = is_listed;
|
||||
if (typeof sort_order === 'number') insert.sort_order = sort_order;
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { data, error } = await supabase.from('products').insert(insert).select().single();
|
||||
if (error) {
|
||||
if (error.code === '23505') {
|
||||
return NextResponse.json({ error: '이미 존재하는 제품 id' }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ product: data });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { id } = body;
|
||||
if (typeof id !== 'string' || !id) {
|
||||
return NextResponse.json({ error: 'id 필요' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (typeof body.name === 'string') updates.name = body.name.trim();
|
||||
if (typeof body.description === 'string') updates.description = body.description;
|
||||
if (typeof body.description_long === 'string') updates.description_long = body.description_long;
|
||||
if (typeof body.price === 'number' && Number.isInteger(body.price) && body.price >= 0) {
|
||||
updates.price = body.price;
|
||||
}
|
||||
const feats = sanitizeFeatures(body.features);
|
||||
if (feats !== undefined) updates.features = feats;
|
||||
if (typeof body.is_listed === 'boolean') updates.is_listed = body.is_listed;
|
||||
if (typeof body.is_active === 'boolean') updates.is_active = body.is_active;
|
||||
if (typeof body.sort_order === 'number') updates.sort_order = body.sort_order;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: '변경할 필드 없음' }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { error } = await supabase.from('products').update(updates).eq('id', id);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
Reference in New Issue
Block a user