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,113 +1,117 @@
|
|||
// server/supabase/functions/admin-audit-log/index.ts
|
||||
// 감사로그 조회 — manager 이상
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { requireManager } from '../_shared/admin-auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import { verifyCurrentAdminActor } from '../_shared/admin-current-role.ts'
|
||||
import {
|
||||
AdminPublicError,
|
||||
adminErrorResponse,
|
||||
adminJsonResponse,
|
||||
parseIsoDate,
|
||||
parsePagination,
|
||||
parsePositiveInteger,
|
||||
requireUuid,
|
||||
sanitizeAuditRecord,
|
||||
validateQueryKeys,
|
||||
} 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 AUDIT_COLUMNS = 'id, admin_id, action, target_type, target_id, before_data, after_data, memo, created_at'
|
||||
|
||||
function targetTypeFilter(value: string | null): string | undefined {
|
||||
if (value === null || value === '') return undefined
|
||||
if (value !== 'subscription' && value !== 'profile') {
|
||||
throw new AdminPublicError(400, 'invalid_target_type')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
Deno.serve(async (req: Request) => {
|
||||
async function readAuditLog(req: Request): Promise<Response> {
|
||||
const admin = await requireManager(req)
|
||||
const url = new URL(req.url)
|
||||
validateQueryKeys(url, ['id', 'page', 'limit', 'target_type', 'admin_id', 'target_id', 'from', 'to'])
|
||||
const serviceClient = createServiceRoleClient()
|
||||
await verifyCurrentAdminActor(serviceClient, admin)
|
||||
const logId = parsePositiveInteger(url.searchParams.get('id'), 'invalid_audit_id')
|
||||
|
||||
if (logId !== undefined) {
|
||||
const { data: log, error: logError } = await serviceClient
|
||||
.from('audit_log')
|
||||
.select(AUDIT_COLUMNS)
|
||||
.eq('id', logId)
|
||||
.maybeSingle()
|
||||
if (logError) throw new AdminPublicError(500, 'admin_audit_read_failed')
|
||||
if (!log) throw new AdminPublicError(404, 'audit_log_not_found')
|
||||
if (typeof log.admin_id !== 'string') throw new AdminPublicError(502, 'invalid_admin_read_response')
|
||||
|
||||
const { data: admin, error: adminError } = await serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url')
|
||||
.eq('id', log.admin_id)
|
||||
.maybeSingle()
|
||||
if (adminError) throw new AdminPublicError(500, 'admin_audit_read_failed')
|
||||
return adminJsonResponse({ log: sanitizeAuditRecord(log), admin: admin ?? null }, corsHeaders)
|
||||
}
|
||||
|
||||
const { page, limit, from, to } = parsePagination(url)
|
||||
const targetType = targetTypeFilter(url.searchParams.get('target_type'))
|
||||
const adminIdValue = url.searchParams.get('admin_id')
|
||||
const targetIdValue = url.searchParams.get('target_id')
|
||||
const adminId = adminIdValue ? requireUuid(adminIdValue, 'invalid_admin_id') : undefined
|
||||
const targetId = targetIdValue ? requireUuid(targetIdValue, 'invalid_target_id') : undefined
|
||||
const fromDate = parseIsoDate(url.searchParams.get('from'), 'invalid_from_date')
|
||||
const toDate = parseIsoDate(url.searchParams.get('to'), 'invalid_to_date')
|
||||
if (fromDate && toDate && fromDate > toDate) throw new AdminPublicError(400, 'invalid_date_range')
|
||||
|
||||
let query = serviceClient.from('audit_log').select(AUDIT_COLUMNS, { 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:00.000Z`)
|
||||
if (toDate) query = query.lte('created_at', `${toDate}T23:59:59.999Z`)
|
||||
|
||||
const { data, count, error } = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to)
|
||||
if (error) throw new AdminPublicError(500, 'admin_audit_read_failed')
|
||||
|
||||
const logs = (data ?? []) as Array<Record<string, unknown>>
|
||||
const adminIds = [...new Set(logs
|
||||
.map((log) => log.admin_id)
|
||||
.filter((value): value is string => typeof value === 'string'))]
|
||||
const adminNames = new Map<string, string | null>()
|
||||
if (adminIds.length > 0) {
|
||||
const { data: admins, error: adminsError } = await serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name')
|
||||
.in('id', adminIds)
|
||||
if (adminsError) throw new AdminPublicError(500, 'admin_audit_read_failed')
|
||||
for (const admin of (admins ?? []) as Array<{ id: string; name: string | null }>) {
|
||||
adminNames.set(admin.id, admin.name)
|
||||
}
|
||||
}
|
||||
|
||||
return adminJsonResponse({
|
||||
logs: logs.map((log) => ({
|
||||
...sanitizeAuditRecord(log),
|
||||
admin_name: typeof log.admin_id === 'string' ? adminNames.get(log.admin_id) ?? null : null,
|
||||
})),
|
||||
total: count ?? 0,
|
||||
page,
|
||||
limit,
|
||||
}, corsHeaders)
|
||||
}
|
||||
|
||||
export async function handleAdminAuditLog(req: Request): Promise<Response> {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, 405)
|
||||
}
|
||||
|
||||
try {
|
||||
await requireManager(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>,
|
||||
})
|
||||
if (req.method !== 'GET') {
|
||||
return adminJsonResponse({ error: 'method_not_allowed' }, corsHeaders, 405)
|
||||
}
|
||||
|
||||
// 목록 조회
|
||||
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)
|
||||
return await readAuditLog(req)
|
||||
} catch (error) {
|
||||
console.error('admin-audit-log request failed', error)
|
||||
return adminErrorResponse(error, corsHeaders)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (import.meta.main) Deno.serve(handleAdminAuditLog)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue