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
This commit is contained in:
parent
f7c50eb2ed
commit
dca1b90faa
34 changed files with 3142 additions and 45 deletions
45
server/supabase/functions/_shared/admin-auth.ts
Normal file
45
server/supabase/functions/_shared/admin-auth.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// server/supabase/functions/_shared/admin-auth.ts
|
||||
// Admin/Super-admin 권한 검증 — requireUser 확장
|
||||
|
||||
// @ts-expect-error — Deno 런타임 import
|
||||
import type { User } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
||||
import { requireUser, type AuthError } from './auth.ts'
|
||||
|
||||
export type AdminRole = 'admin' | 'super_admin'
|
||||
|
||||
/**
|
||||
* admin 이상 권한 필요 (admin, super_admin).
|
||||
* 실패 시 AuthError throw.
|
||||
*/
|
||||
export async function requireAdmin(req: Request): Promise<User> {
|
||||
const user = await requireUser(req)
|
||||
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
|
||||
if (role !== 'admin' && role !== 'super_admin') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw { status: 403, message: 'Admin access required' } as AuthError
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* super_admin 전용 권한 필요.
|
||||
* 실패 시 AuthError throw.
|
||||
*/
|
||||
export async function requireSuperAdmin(req: Request): Promise<User> {
|
||||
const user = await requireUser(req)
|
||||
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
|
||||
if (role !== 'super_admin') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw { status: 403, message: 'Super admin access required' } as AuthError
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 유저의 admin role 반환. admin이 아니면 null.
|
||||
*/
|
||||
export function getAdminRole(user: User): AdminRole | null {
|
||||
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
|
||||
if (role === 'admin' || role === 'super_admin') return role
|
||||
return null
|
||||
}
|
||||
37
server/supabase/functions/_shared/audit.ts
Normal file
37
server/supabase/functions/_shared/audit.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// server/supabase/functions/_shared/audit.ts
|
||||
// 감사로그 기록 유틸리티
|
||||
|
||||
// @ts-expect-error — Deno 런타임 import
|
||||
import type { SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
||||
|
||||
export interface AuditLogEntry {
|
||||
adminId: string
|
||||
action: string // 'subscription.create', 'subscription.update', 'subscription.delete', 'user.role_change'
|
||||
targetType: string // 'subscription', 'profile'
|
||||
targetId: string
|
||||
beforeData: Record<string, unknown> | null
|
||||
afterData: Record<string, unknown> | null
|
||||
memo: string
|
||||
}
|
||||
|
||||
/**
|
||||
* audit_log 테이블에 감사 기록을 삽입한다.
|
||||
* service_role 클라이언트를 사용해야 RLS를 우회한다.
|
||||
*/
|
||||
export async function writeAuditLog(
|
||||
supabase: SupabaseClient,
|
||||
entry: AuditLogEntry
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('audit_log').insert({
|
||||
admin_id: entry.adminId,
|
||||
action: entry.action,
|
||||
target_type: entry.targetType,
|
||||
target_id: entry.targetId,
|
||||
before_data: entry.beforeData,
|
||||
after_data: entry.afterData,
|
||||
memo: entry.memo,
|
||||
})
|
||||
if (error) {
|
||||
throw new Error(`Failed to write audit log: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ export const corsHeaders: Record<string, string> = {
|
|||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers':
|
||||
'authorization, x-client-info, apikey, content-type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS'
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS'
|
||||
}
|
||||
|
||||
export function handleCorsPreflightRequest(req: Request): Response | null {
|
||||
|
|
|
|||
113
server/supabase/functions/admin-audit-log/index.ts
Normal file
113
server/supabase/functions/admin-audit-log/index.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// server/supabase/functions/admin-audit-log/index.ts
|
||||
// 감사로그 조회 — admin 이상
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { requireAdmin } from '../_shared/admin-auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.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' },
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, 405)
|
||||
}
|
||||
|
||||
try {
|
||||
await requireAdmin(req)
|
||||
const url = new URL(req.url)
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
// 단건 상세
|
||||
const logId = url.searchParams.get('id')
|
||||
if (logId) {
|
||||
const { data: log, error } = await serviceClient
|
||||
.from('audit_log')
|
||||
.select('*')
|
||||
.eq('id', parseInt(logId, 10))
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return jsonResponse({ error: error.message }, 500)
|
||||
if (!log) return jsonResponse({ error: 'Audit log not found' }, 404)
|
||||
|
||||
// admin 프로필 정보 함께
|
||||
const { data: adminProfile } = await serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url')
|
||||
.eq('id', (log as Record<string, unknown>).admin_id)
|
||||
.maybeSingle()
|
||||
|
||||
return jsonResponse({
|
||||
log: log as unknown as Record<string, unknown>,
|
||||
admin: adminProfile as unknown as Record<string, unknown>,
|
||||
})
|
||||
}
|
||||
|
||||
// 목록 조회
|
||||
const page = parseInt(url.searchParams.get('page') ?? '1', 10)
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '20', 10)
|
||||
const targetType = url.searchParams.get('target_type') ?? ''
|
||||
const adminId = url.searchParams.get('admin_id') ?? ''
|
||||
const fromDate = url.searchParams.get('from') ?? ''
|
||||
const toDate = url.searchParams.get('to') ?? ''
|
||||
const targetId = url.searchParams.get('target_id') ?? ''
|
||||
const rangeFrom = (page - 1) * limit
|
||||
const rangeTo = rangeFrom + limit - 1
|
||||
|
||||
let query = serviceClient
|
||||
.from('audit_log')
|
||||
.select('*', { count: 'exact' })
|
||||
|
||||
if (targetType) query = query.eq('target_type', targetType)
|
||||
if (adminId) query = query.eq('admin_id', adminId)
|
||||
if (targetId) query = query.eq('target_id', targetId)
|
||||
if (fromDate) query = query.gte('created_at', `${fromDate}T00:00:00Z`)
|
||||
if (toDate) query = query.lte('created_at', `${toDate}T23:59:59Z`)
|
||||
|
||||
const { data, count, error } = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.range(rangeFrom, rangeTo)
|
||||
|
||||
if (error) return jsonResponse({ error: error.message }, 500)
|
||||
|
||||
// admin 이름 매핑
|
||||
const logs = (data ?? []) as unknown as Record<string, unknown>[]
|
||||
const adminIds = [...new Set(logs.map(l => l.admin_id as string))]
|
||||
|
||||
let adminMap: Record<string, string> = {}
|
||||
if (adminIds.length > 0) {
|
||||
const { data: admins } = await serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name')
|
||||
.in('id', adminIds)
|
||||
|
||||
if (admins) {
|
||||
adminMap = Object.fromEntries(
|
||||
(admins as Array<{ id: string; name: string | null }>).map(a => [a.id, a.name ?? 'Unknown'])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const enrichedLogs = logs.map(log => ({
|
||||
...log,
|
||||
admin_name: adminMap[log.admin_id as string] ?? 'Unknown',
|
||||
}))
|
||||
|
||||
return jsonResponse({ logs: enrichedLogs, total: count ?? 0, page, limit })
|
||||
} 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)
|
||||
}
|
||||
})
|
||||
95
server/supabase/functions/admin-payments/index.ts
Normal file
95
server/supabase/functions/admin-payments/index.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// server/supabase/functions/admin-payments/index.ts
|
||||
// 결제 이력 조회 — DB 기반 + Payple API 직접 조회
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { requireAdmin } from '../_shared/admin-auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import { getPaypleConfig, paypleAuth } from '../_shared/payple.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' },
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, 405)
|
||||
}
|
||||
|
||||
try {
|
||||
await requireAdmin(req)
|
||||
const url = new URL(req.url)
|
||||
const userId = url.searchParams.get('userId')
|
||||
const source = url.searchParams.get('source') // 'db' | 'payple' | null(=db)
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
if (!userId) {
|
||||
return jsonResponse({ error: 'userId query param required' }, 400)
|
||||
}
|
||||
|
||||
// ── DB 조회 (기본) ──
|
||||
// 구독 현황
|
||||
const { data: sub } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
// 감사로그에서 구독 관련 액션만
|
||||
const { data: auditLogs } = await serviceClient
|
||||
.from('audit_log')
|
||||
.select('*')
|
||||
.eq('target_id', userId)
|
||||
.eq('target_type', 'subscription')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
subscription: sub,
|
||||
auditLogs: auditLogs ?? [],
|
||||
}
|
||||
|
||||
// ── Payple API 직접 조회 (요청 시) ──
|
||||
if (source === 'payple' && sub?.payple_payer_id) {
|
||||
try {
|
||||
const config = getPaypleConfig()
|
||||
const auth = await paypleAuth(config, { payWork: 'TSRCH' })
|
||||
|
||||
// Payple 결제 내역 조회
|
||||
const paypleResponse = await fetch(`${config.baseUrl}/php/PayCardListAct.php`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
PCD_CST_ID: auth.PCD_CST_ID,
|
||||
PCD_CUST_KEY: auth.PCD_CUST_KEY,
|
||||
PCD_AUTH_KEY: auth.PCD_AUTH_KEY,
|
||||
PCD_PAYER_ID: sub.payple_payer_id,
|
||||
PCD_PAY_YEAR: new Date().getFullYear().toString(),
|
||||
PCD_PAY_MONTH: '',
|
||||
}),
|
||||
})
|
||||
|
||||
const paypleData = await paypleResponse.json()
|
||||
result.paypleHistory = paypleData
|
||||
} catch (paypleErr) {
|
||||
const errMsg = paypleErr instanceof Error ? paypleErr.message : 'Payple API error'
|
||||
result.paypleError = errMsg
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse(result)
|
||||
} 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)
|
||||
}
|
||||
})
|
||||
273
server/supabase/functions/admin-subscriptions/index.ts
Normal file
273
server/supabase/functions/admin-subscriptions/index.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
// 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)
|
||||
}
|
||||
})
|
||||
149
server/supabase/functions/admin-users/index.ts
Normal file
149
server/supabase/functions/admin-users/index.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// server/supabase/functions/admin-users/index.ts
|
||||
// Admin: 유저 목록/상세 조회 + super_admin: role 변경
|
||||
|
||||
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'
|
||||
|
||||
// @ts-expect-error — Deno 런타임 import
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
||||
|
||||
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 RoleChangeBody {
|
||||
userId: string
|
||||
newRole: 'user' | 'admin' | 'super_admin'
|
||||
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)
|
||||
|
||||
// ── GET: 유저 목록/상세 ──
|
||||
if (req.method === 'GET') {
|
||||
const admin = await requireAdmin(req)
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
const userId = url.searchParams.get('userId')
|
||||
|
||||
if (userId) {
|
||||
// 유저 상세
|
||||
const { data: profile, error } = await serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url, locale, tier, role, created_at, updated_at')
|
||||
.eq('id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return jsonResponse({ error: error.message }, 500)
|
||||
if (!profile) return jsonResponse({ error: 'User not found' }, 404)
|
||||
|
||||
const { data: sub } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
return jsonResponse({ profile, subscription: sub })
|
||||
}
|
||||
|
||||
// 유저 목록
|
||||
const page = parseInt(url.searchParams.get('page') ?? '1', 10)
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '20', 10)
|
||||
const search = url.searchParams.get('search') ?? ''
|
||||
const roleFilter = url.searchParams.get('role') ?? ''
|
||||
const from = (page - 1) * limit
|
||||
const to = from + limit - 1
|
||||
|
||||
let query = serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url, tier, role, created_at', { count: 'exact' })
|
||||
|
||||
if (search) {
|
||||
query = query.ilike('name', `%${search}%`)
|
||||
}
|
||||
if (roleFilter) {
|
||||
query = query.eq('role', roleFilter)
|
||||
}
|
||||
|
||||
const { data: profiles, count, error } = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to)
|
||||
|
||||
if (error) return jsonResponse({ error: error.message }, 500)
|
||||
|
||||
return jsonResponse({ profiles: profiles ?? [], total: count ?? 0, page, limit })
|
||||
}
|
||||
|
||||
// ── PATCH: role 변경 (super_admin 전용) ──
|
||||
if (req.method === 'PATCH') {
|
||||
const admin = await requireSuperAdmin(req)
|
||||
const body = (await req.json()) as RoleChangeBody
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
if (!body.userId || !body.newRole || !body.memo) {
|
||||
return jsonResponse({ error: 'userId, newRole, memo are required' }, 400)
|
||||
}
|
||||
|
||||
const validRoles = ['user', 'admin', 'super_admin']
|
||||
if (!validRoles.includes(body.newRole)) {
|
||||
return jsonResponse({ error: `Invalid role. Must be one of: ${validRoles.join(', ')}` }, 400)
|
||||
}
|
||||
|
||||
// 현재 프로필 조회 (before 스냅샷)
|
||||
const { data: before } = await serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name, role')
|
||||
.eq('id', body.userId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!before) return jsonResponse({ error: 'User not found' }, 404)
|
||||
|
||||
// 1. auth.users.raw_app_meta_data.role 변경
|
||||
const { error: authError } = await serviceClient.auth.admin.updateUserById(body.userId, {
|
||||
app_metadata: { role: body.newRole },
|
||||
})
|
||||
if (authError) return jsonResponse({ error: `Auth update failed: ${authError.message}` }, 500)
|
||||
|
||||
// 2. profiles.role 동기화
|
||||
const { error: profileError } = await serviceClient
|
||||
.from('profiles')
|
||||
.update({ role: body.newRole, updated_at: new Date().toISOString() })
|
||||
.eq('id', body.userId)
|
||||
|
||||
if (profileError) return jsonResponse({ error: `Profile update failed: ${profileError.message}` }, 500)
|
||||
|
||||
// 3. 감사로그
|
||||
await writeAuditLog(serviceClient, {
|
||||
adminId: admin.id,
|
||||
action: 'user.role_change',
|
||||
targetType: 'profile',
|
||||
targetId: body.userId,
|
||||
beforeData: { role: before.role },
|
||||
afterData: { role: body.newRole },
|
||||
memo: body.memo,
|
||||
})
|
||||
|
||||
return jsonResponse({ success: true, userId: body.userId, newRole: body.newRole })
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue