// server/supabase/functions/admin-subscriptions/index.ts // 구독 CRUD — manager: 조회+수정 / admin+: 생성/수정/삭제 import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' import { authErrorResponse, type AuthError } from '../_shared/auth.ts' import { requireManager, requireAdmin } from '../_shared/admin-auth.ts' import { createServiceRoleClient } from '../_shared/quota.ts' import { writeAuditLog } from '../_shared/audit.ts' function jsonResponse(body: Record | Record[], status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } interface CreateBody { userId: string tier: 'free' | 'pro' | 'pro_plus' status: 'active' | 'canceled' | 'past_due' | 'expired' currentPeriodEnd?: string adminNote?: string memo: string } interface UpdateBody { tier?: 'free' | 'pro' | 'pro_plus' status?: 'active' | 'canceled' | 'past_due' | 'expired' currentPeriodEnd?: string overageCredits?: number adminNote?: string memo: string } interface DeleteBody { memo: string } // @ts-expect-error — Deno 런타임 전역 Deno.serve(async (req: Request) => { const preflight = handleCorsPreflightRequest(req) if (preflight) return preflight try { const url = new URL(req.url) const serviceClient = createServiceRoleClient() // ── GET: 목록/상세 (manager 이상) ── if (req.method === 'GET') { await requireManager(req) const userId = url.searchParams.get('userId') if (userId) { const { data: sub, error } = await serviceClient .from('subscriptions') .select('*') .eq('user_id', userId) .maybeSingle() if (error) return jsonResponse({ error: error.message }, 500) if (!sub) return jsonResponse({ error: 'Subscription not found' }, 404) // 해당 유저 프로필도 함께 const { data: profile } = await serviceClient .from('profiles') .select('id, name, tier, role') .eq('id', userId) .maybeSingle() return jsonResponse({ subscription: sub, profile }) } // 목록 const page = parseInt(url.searchParams.get('page') ?? '1', 10) const limit = parseInt(url.searchParams.get('limit') ?? '20', 10) const statusFilter = url.searchParams.get('status') ?? '' const tierFilter = url.searchParams.get('tier') ?? '' const from = (page - 1) * limit const to = from + limit - 1 let query = serviceClient .from('subscriptions') .select('*, profiles!subscriptions_user_id_fkey(name, avatar_url)', { count: 'exact' }) if (statusFilter) query = query.eq('status', statusFilter) if (tierFilter) query = query.eq('tier', tierFilter) const { data, count, error } = await query .order('updated_at', { ascending: false }) .range(from, to) if (error) return jsonResponse({ error: error.message }, 500) return jsonResponse({ subscriptions: data ?? [], total: count ?? 0, page, limit }) } // ── POST: 생성 (admin 이상) ── if (req.method === 'POST') { const admin = await requireAdmin(req) const body = (await req.json()) as CreateBody if (!body.userId || !body.tier || !body.memo) { return jsonResponse({ error: 'userId, tier, memo are required' }, 400) } // 기존 구독 확인 const { data: existing } = await serviceClient .from('subscriptions') .select('id') .eq('user_id', body.userId) .maybeSingle() if (existing) { return jsonResponse({ error: 'Subscription already exists for this user. Use PATCH to update.' }, 409) } const now = new Date().toISOString() const newSub = { user_id: body.userId, tier: body.tier, status: body.status ?? 'active', payment_provider: 'none', current_period_start: now, current_period_end: body.currentPeriodEnd ?? null, admin_note: body.adminNote ?? null, created_at: now, updated_at: now, } const { data: created, error } = await serviceClient .from('subscriptions') .insert(newSub) .select() .single() if (error) return jsonResponse({ error: error.message }, 500) // profiles.tier 동기화 await serviceClient .from('profiles') .update({ tier: body.tier, updated_at: now }) .eq('id', body.userId) await writeAuditLog(serviceClient, { adminId: admin.id, action: 'subscription.create', targetType: 'subscription', targetId: body.userId, beforeData: null, afterData: created as unknown as Record, memo: body.memo, }) return jsonResponse({ success: true, subscription: created as unknown as Record }, 201) } // ── PATCH: 수정 (manager 이상) ── if (req.method === 'PATCH') { const admin = await requireManager(req) const userId = url.searchParams.get('userId') if (!userId) return jsonResponse({ error: 'userId query param required' }, 400) const body = (await req.json()) as UpdateBody if (!body.memo) return jsonResponse({ error: 'memo is required' }, 400) // before 스냅샷 const { data: before } = await serviceClient .from('subscriptions') .select('*') .eq('user_id', userId) .maybeSingle() if (!before) return jsonResponse({ error: 'Subscription not found' }, 404) // 업데이트 페이로드 const updates: Record = { updated_at: new Date().toISOString() } if (body.tier !== undefined) updates.tier = body.tier if (body.status !== undefined) updates.status = body.status if (body.currentPeriodEnd !== undefined) updates.current_period_end = body.currentPeriodEnd if (body.overageCredits !== undefined) updates.overage_credits = body.overageCredits if (body.adminNote !== undefined) updates.admin_note = body.adminNote const { data: after, error } = await serviceClient .from('subscriptions') .update(updates) .eq('user_id', userId) .select() .single() if (error) return jsonResponse({ error: error.message }, 500) // profiles.tier 동기화 if (body.tier) { await serviceClient .from('profiles') .update({ tier: body.tier, updated_at: new Date().toISOString() }) .eq('id', userId) } await writeAuditLog(serviceClient, { adminId: admin.id, action: 'subscription.update', targetType: 'subscription', targetId: userId, beforeData: before as unknown as Record, afterData: after as unknown as Record, memo: body.memo, }) return jsonResponse({ success: true, subscription: after as unknown as Record }) } // ── DELETE: 소프트 삭제 (admin 이상) ── if (req.method === 'DELETE') { const admin = await requireAdmin(req) const userId = url.searchParams.get('userId') if (!userId) return jsonResponse({ error: 'userId query param required' }, 400) const body = (await req.json()) as DeleteBody if (!body.memo) return jsonResponse({ error: 'memo is required' }, 400) const { data: before } = await serviceClient .from('subscriptions') .select('*') .eq('user_id', userId) .maybeSingle() if (!before) return jsonResponse({ error: 'Subscription not found' }, 404) // 소프트 삭제: status = 'expired', tier = 'free' const now = new Date().toISOString() const { error } = await serviceClient .from('subscriptions') .update({ status: 'expired', tier: 'free', cancel_at: now, updated_at: now, admin_note: `[DELETED] ${body.memo}`, }) .eq('user_id', userId) if (error) return jsonResponse({ error: error.message }, 500) // profiles.tier → free await serviceClient .from('profiles') .update({ tier: 'free', updated_at: now }) .eq('id', userId) await writeAuditLog(serviceClient, { adminId: admin.id, action: 'subscription.delete', targetType: 'subscription', targetId: userId, beforeData: before as unknown as Record, afterData: { status: 'expired', tier: 'free', cancel_at: now }, memo: body.memo, }) return jsonResponse({ success: true, message: 'Subscription soft-deleted' }) } return jsonResponse({ error: 'Method not allowed' }, 405) } catch (err) { if (err && typeof err === 'object' && 'status' in err && 'message' in err) { return authErrorResponse(err as AuthError, corsHeaders) } const message = err instanceof Error ? err.message : 'Unknown error' return jsonResponse({ error: message }, 500) } })