feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -1,273 +1,106 @@
|
|||
// 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 { requireAdmin, requireManager } from '../_shared/admin-auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import { writeAuditLog } from '../_shared/audit.ts'
|
||||
import { verifyCurrentAdminActor } from '../_shared/admin-current-role.ts'
|
||||
import {
|
||||
AdminPublicError,
|
||||
type SubscriptionAction,
|
||||
actorEmail,
|
||||
adminErrorResponse,
|
||||
adminJsonResponse,
|
||||
adminRpcError,
|
||||
parsePagination,
|
||||
parseSubscriptionMutationRequest,
|
||||
readAdminJson,
|
||||
requireIdempotencyKey,
|
||||
requireUuid,
|
||||
subscriptionStatusFilter,
|
||||
subscriptionTierFilter,
|
||||
validateQueryKeys,
|
||||
validateSubscriptionRpcResult,
|
||||
} from '../_shared/admin-contract.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' },
|
||||
const SUBSCRIPTION_COLUMNS = 'id, user_id, tier, status, provider, payment_provider, current_period_start, current_period_end, overage_credits, admin_note, cancel_at, created_at, updated_at'
|
||||
|
||||
async function readSubscriptions(req: Request): Promise<Response> {
|
||||
const admin = await requireManager(req)
|
||||
const url = new URL(req.url)
|
||||
validateQueryKeys(url, ['userId', 'page', 'limit', 'status', 'tier'])
|
||||
const serviceClient = createServiceRoleClient()
|
||||
await verifyCurrentAdminActor(serviceClient, admin)
|
||||
const userIdValue = url.searchParams.get('userId')
|
||||
|
||||
if (userIdValue) {
|
||||
const userId = requireUuid(userIdValue, 'invalid_userId')
|
||||
const [{ data: subscription, error: subscriptionError }, { data: profile, error: profileError }] =
|
||||
await Promise.all([
|
||||
serviceClient.from('subscriptions').select(SUBSCRIPTION_COLUMNS).eq('user_id', userId).maybeSingle(),
|
||||
serviceClient.from('profiles').select('id, name, tier, role').eq('id', userId).maybeSingle(),
|
||||
])
|
||||
if (subscriptionError || profileError) throw new AdminPublicError(500, 'admin_subscription_read_failed')
|
||||
if (!subscription) throw new AdminPublicError(404, 'subscription_not_found')
|
||||
return adminJsonResponse({ subscription, profile: profile ?? null }, corsHeaders)
|
||||
}
|
||||
|
||||
const { page, limit, from, to } = parsePagination(url)
|
||||
const status = subscriptionStatusFilter(url.searchParams.get('status'))
|
||||
const tier = subscriptionTierFilter(url.searchParams.get('tier'))
|
||||
let query = serviceClient
|
||||
.from('subscriptions')
|
||||
.select(`${SUBSCRIPTION_COLUMNS}, profiles!subscriptions_user_id_fkey(name, avatar_url)`, { count: 'exact' })
|
||||
if (status) query = query.eq('status', status)
|
||||
if (tier) query = query.eq('tier', tier)
|
||||
|
||||
const { data, count, error } = await query
|
||||
.order('updated_at', { ascending: false })
|
||||
.range(from, to)
|
||||
if (error) throw new AdminPublicError(500, 'admin_subscription_read_failed')
|
||||
return adminJsonResponse({ subscriptions: data ?? [], total: count ?? 0, page, limit }, corsHeaders)
|
||||
}
|
||||
|
||||
async function mutateSubscription(req: Request, action: SubscriptionAction): Promise<Response> {
|
||||
const admin = action === 'update' ? await requireManager(req) : await requireAdmin(req)
|
||||
const url = new URL(req.url)
|
||||
validateQueryKeys(url, action === 'create' ? [] : ['userId'])
|
||||
const body = await readAdminJson(req)
|
||||
const mutation = parseSubscriptionMutationRequest(
|
||||
action,
|
||||
body,
|
||||
requireIdempotencyKey(req),
|
||||
url.searchParams.get('userId'),
|
||||
)
|
||||
const serviceClient = createServiceRoleClient()
|
||||
const { data, error } = await serviceClient.rpc('admin_mutate_subscription_v1', {
|
||||
p_actor_email: actorEmail(admin),
|
||||
p_idempotency_key: mutation.idempotencyKey,
|
||||
p_action: mutation.action,
|
||||
p_user_id: mutation.userId,
|
||||
p_tier: mutation.tier ?? null,
|
||||
p_status: mutation.status ?? null,
|
||||
p_current_period_end: mutation.currentPeriodEnd ?? null,
|
||||
p_overage_credits: mutation.overageCredits ?? null,
|
||||
p_admin_note: mutation.adminNote ?? null,
|
||||
p_memo: mutation.memo,
|
||||
})
|
||||
if (error) throw adminRpcError(error)
|
||||
const response = validateSubscriptionRpcResult(data, mutation)
|
||||
return adminJsonResponse(response, corsHeaders, action === 'create' ? 201 : 200)
|
||||
}
|
||||
|
||||
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) => {
|
||||
export async function handleAdminSubscriptions(req: Request): Promise<Response> {
|
||||
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<string, unknown>,
|
||||
memo: body.memo,
|
||||
})
|
||||
|
||||
return jsonResponse({ success: true, subscription: created as unknown as Record<string, unknown> }, 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<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: 소프트 삭제 (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<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)
|
||||
if (req.method === 'GET') return await readSubscriptions(req)
|
||||
if (req.method === 'POST') return await mutateSubscription(req, 'create')
|
||||
if (req.method === 'PATCH') return await mutateSubscription(req, 'update')
|
||||
if (req.method === 'DELETE') return await mutateSubscription(req, 'delete')
|
||||
return adminJsonResponse({ error: 'method_not_allowed' }, corsHeaders, 405)
|
||||
} catch (error) {
|
||||
console.error('admin-subscriptions request failed', error)
|
||||
return adminErrorResponse(error, corsHeaders)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (import.meta.main) Deno.serve(handleAdminSubscriptions)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue