d3ro-voice/server/supabase/functions/admin-subscriptions/index.ts
윤찬 dca1b90faa feat(admin): Phase V2-6 Admin CRM 고도화 — CRUD + 차트 + 결제 + 감사
- DB: audit_log 테이블(diff 포함) + subscriptions.admin_note + super_admin role
- Edge Functions 4개: admin-users, admin-subscriptions, admin-payments, admin-audit-log
- 공유 유틸: admin-auth.ts(권한 검증), audit.ts(감사로그 기록)
- Swagger UI: 독립 정적 페이지 + OpenAPI 3.0 spec
- CRUD 페이지: 구독 생성/수정/삭제, role 변경, 감사로그 목록/상세
- recharts: feature별 StackedBar + DAU Line + Top Users HorizontalBar
- 결제 이력: DB + Payple API 병행 조회
- 권한: super_admin만 위험 작업, admin은 조회 전용
- RLS: admin/super_admin IN 정책 + super_admin 쓰기 정책
- SQL RPC: admin_usage_by_feature, admin_top_users, admin_dau
2026-04-12 21:32:47 +09:00

273 lines
9 KiB
TypeScript

// server/supabase/functions/admin-subscriptions/index.ts
// 구독 CRUD — admin: 조회 / super_admin: 생성/수정/삭제
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
import { requireAdmin, requireSuperAdmin } from '../_shared/admin-auth.ts'
import { createServiceRoleClient } from '../_shared/quota.ts'
import { writeAuditLog } from '../_shared/audit.ts'
function jsonResponse(body: Record<string, unknown> | Record<string, unknown>[], 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: 목록/상세 ──
if (req.method === 'GET') {
await requireAdmin(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: 생성 (super_admin) ──
if (req.method === 'POST') {
const admin = await requireSuperAdmin(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<string, unknown>,
memo: body.memo,
})
return jsonResponse({ success: true, subscription: created as unknown as Record<string, unknown> }, 201)
}
// ── PATCH: 수정 (super_admin) ──
if (req.method === 'PATCH') {
const admin = await requireSuperAdmin(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<string, unknown> = { 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<string, unknown>,
afterData: after as unknown as Record<string, unknown>,
memo: body.memo,
})
return jsonResponse({ success: true, subscription: after as unknown as Record<string, unknown> })
}
// ── DELETE: 소프트 삭제 (super_admin) ──
if (req.method === 'DELETE') {
const admin = await requireSuperAdmin(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<string, unknown>,
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)
}
})