- 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
113 lines
3.9 KiB
TypeScript
113 lines
3.9 KiB
TypeScript
// 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)
|
|
}
|
|
})
|