diff --git a/apps/admin-swagger/index.html b/apps/admin-swagger/index.html
new file mode 100644
index 0000000..2b85700
--- /dev/null
+++ b/apps/admin-swagger/index.html
@@ -0,0 +1,100 @@
+
+
+
+
+
+ D3RO-VOICE Admin API
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin-swagger/openapi.json b/apps/admin-swagger/openapi.json
new file mode 100644
index 0000000..2dc4ce6
--- /dev/null
+++ b/apps/admin-swagger/openapi.json
@@ -0,0 +1,336 @@
+{
+ "openapi": "3.0.3",
+ "info": {
+ "title": "D3RO-VOICE Admin API",
+ "description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.",
+ "version": "1.0.0"
+ },
+ "servers": [
+ {
+ "url": "https://{supabaseRef}.supabase.co/functions/v1",
+ "description": "Production",
+ "variables": {
+ "supabaseRef": {
+ "default": "your-project-ref"
+ }
+ }
+ },
+ {
+ "url": "http://localhost:54321/functions/v1",
+ "description": "Local development"
+ }
+ ],
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "components": {
+ "securitySchemes": {
+ "BearerAuth": {
+ "type": "http",
+ "scheme": "bearer",
+ "bearerFormat": "JWT",
+ "description": "Supabase access_token (admin or super_admin role required)"
+ }
+ },
+ "schemas": {
+ "Profile": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "string", "format": "uuid" },
+ "name": { "type": "string", "nullable": true },
+ "avatar_url": { "type": "string", "nullable": true },
+ "locale": { "type": "string" },
+ "tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
+ "role": { "type": "string", "enum": ["user", "admin", "super_admin"] },
+ "created_at": { "type": "string", "format": "date-time" },
+ "updated_at": { "type": "string", "format": "date-time" }
+ }
+ },
+ "Subscription": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "string", "format": "uuid" },
+ "user_id": { "type": "string", "format": "uuid" },
+ "tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
+ "status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] },
+ "payment_provider": { "type": "string", "enum": ["none", "stripe", "payple"] },
+ "current_period_start": { "type": "string", "format": "date-time", "nullable": true },
+ "current_period_end": { "type": "string", "format": "date-time", "nullable": true },
+ "overage_credits": { "type": "integer" },
+ "admin_note": { "type": "string", "nullable": true },
+ "renewal_failures": { "type": "integer" },
+ "created_at": { "type": "string", "format": "date-time" },
+ "updated_at": { "type": "string", "format": "date-time" }
+ }
+ },
+ "AuditLog": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer" },
+ "admin_id": { "type": "string", "format": "uuid" },
+ "admin_name": { "type": "string" },
+ "action": { "type": "string" },
+ "target_type": { "type": "string" },
+ "target_id": { "type": "string", "format": "uuid" },
+ "before_data": { "type": "object", "nullable": true },
+ "after_data": { "type": "object", "nullable": true },
+ "memo": { "type": "string" },
+ "created_at": { "type": "string", "format": "date-time" }
+ }
+ },
+ "Error": {
+ "type": "object",
+ "properties": {
+ "error": { "type": "string" }
+ }
+ }
+ }
+ },
+ "paths": {
+ "/admin-users": {
+ "get": {
+ "tags": ["Users"],
+ "summary": "List or get user details",
+ "description": "Admin+. Pass userId for single user detail, or omit for paginated list.",
+ "parameters": [
+ { "name": "userId", "in": "query", "schema": { "type": "string", "format": "uuid" }, "description": "Specific user ID for detail view" },
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
+ { "name": "search", "in": "query", "schema": { "type": "string" }, "description": "Name search (ilike)" },
+ { "name": "role", "in": "query", "schema": { "type": "string", "enum": ["user", "admin", "super_admin"] } }
+ ],
+ "responses": {
+ "200": {
+ "description": "User list or detail",
+ "content": {
+ "application/json": {
+ "schema": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "profiles": { "type": "array", "items": { "$ref": "#/components/schemas/Profile" } },
+ "total": { "type": "integer" },
+ "page": { "type": "integer" },
+ "limit": { "type": "integer" }
+ }
+ },
+ {
+ "type": "object",
+ "properties": {
+ "profile": { "$ref": "#/components/schemas/Profile" },
+ "subscription": { "$ref": "#/components/schemas/Subscription" }
+ }
+ }
+ ]
+ }
+ }
+ }
+ },
+ "403": { "description": "Not admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
+ }
+ },
+ "patch": {
+ "tags": ["Users"],
+ "summary": "Change user role",
+ "description": "Super admin only. Changes both auth.users.app_metadata.role and profiles.role.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["userId", "newRole", "memo"],
+ "properties": {
+ "userId": { "type": "string", "format": "uuid" },
+ "newRole": { "type": "string", "enum": ["user", "admin", "super_admin"] },
+ "memo": { "type": "string", "description": "Required reason for audit log" }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Role changed successfully" },
+ "403": { "description": "Not super_admin" }
+ }
+ }
+ },
+ "/admin-subscriptions": {
+ "get": {
+ "tags": ["Subscriptions"],
+ "summary": "List or get subscription details",
+ "parameters": [
+ { "name": "userId", "in": "query", "schema": { "type": "string", "format": "uuid" } },
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
+ { "name": "status", "in": "query", "schema": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] } },
+ { "name": "tier", "in": "query", "schema": { "type": "string", "enum": ["free", "pro", "pro_plus"] } }
+ ],
+ "responses": {
+ "200": { "description": "Subscription list or detail" }
+ }
+ },
+ "post": {
+ "tags": ["Subscriptions"],
+ "summary": "Create subscription (VIP grant / record recovery)",
+ "description": "Super admin only.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["userId", "tier", "memo"],
+ "properties": {
+ "userId": { "type": "string", "format": "uuid" },
+ "tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
+ "status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"], "default": "active" },
+ "currentPeriodEnd": { "type": "string", "format": "date-time" },
+ "adminNote": { "type": "string" },
+ "memo": { "type": "string" }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": { "description": "Subscription created" },
+ "409": { "description": "Subscription already exists" }
+ }
+ },
+ "patch": {
+ "tags": ["Subscriptions"],
+ "summary": "Update subscription",
+ "description": "Super admin only.",
+ "parameters": [
+ { "name": "userId", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" } }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["memo"],
+ "properties": {
+ "tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
+ "status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] },
+ "currentPeriodEnd": { "type": "string", "format": "date-time" },
+ "overageCredits": { "type": "integer" },
+ "adminNote": { "type": "string" },
+ "memo": { "type": "string" }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Subscription updated" }
+ }
+ },
+ "delete": {
+ "tags": ["Subscriptions"],
+ "summary": "Soft-delete subscription",
+ "description": "Super admin only. Sets status=expired, tier=free.",
+ "parameters": [
+ { "name": "userId", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" } }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["memo"],
+ "properties": {
+ "memo": { "type": "string" }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Subscription soft-deleted" }
+ }
+ }
+ },
+ "/admin-payments": {
+ "get": {
+ "tags": ["Payments"],
+ "summary": "Get payment history for a user",
+ "description": "Admin+. Returns DB subscription data + audit logs. Pass source=payple for Payple API history.",
+ "parameters": [
+ { "name": "userId", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" } },
+ { "name": "source", "in": "query", "schema": { "type": "string", "enum": ["db", "payple"] }, "description": "Add 'payple' to also fetch from Payple API" }
+ ],
+ "responses": {
+ "200": {
+ "description": "Payment history",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "subscription": { "$ref": "#/components/schemas/Subscription" },
+ "auditLogs": { "type": "array", "items": { "$ref": "#/components/schemas/AuditLog" } },
+ "paypleHistory": { "type": "object", "description": "Payple API response (when source=payple)" },
+ "paypleError": { "type": "string", "description": "Error message if Payple API call failed" }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/admin-audit-log": {
+ "get": {
+ "tags": ["Audit Log"],
+ "summary": "List or get audit log entries",
+ "description": "Admin+. Pass id for single entry detail.",
+ "parameters": [
+ { "name": "id", "in": "query", "schema": { "type": "integer" }, "description": "Specific log entry ID" },
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
+ { "name": "target_type", "in": "query", "schema": { "type": "string", "enum": ["subscription", "profile"] } },
+ { "name": "admin_id", "in": "query", "schema": { "type": "string", "format": "uuid" } },
+ { "name": "target_id", "in": "query", "schema": { "type": "string", "format": "uuid" } },
+ { "name": "from", "in": "query", "schema": { "type": "string", "format": "date" }, "description": "Start date (YYYY-MM-DD)" },
+ { "name": "to", "in": "query", "schema": { "type": "string", "format": "date" }, "description": "End date (YYYY-MM-DD)" }
+ ],
+ "responses": {
+ "200": {
+ "description": "Audit log list or detail",
+ "content": {
+ "application/json": {
+ "schema": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "logs": { "type": "array", "items": { "$ref": "#/components/schemas/AuditLog" } },
+ "total": { "type": "integer" },
+ "page": { "type": "integer" },
+ "limit": { "type": "integer" }
+ }
+ },
+ {
+ "type": "object",
+ "properties": {
+ "log": { "$ref": "#/components/schemas/AuditLog" },
+ "admin": { "$ref": "#/components/schemas/Profile" }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 7124681..a35741a 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -23,7 +23,8 @@
"@supabase/supabase-js": "^2.103.0",
"next": "^15.0.0",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "recharts": "^2.15.0"
},
"devDependencies": {
"@types/node": "^22.13.0",
diff --git a/apps/admin/src/app/(admin)/audit-log/[id]/page.tsx b/apps/admin/src/app/(admin)/audit-log/[id]/page.tsx
new file mode 100644
index 0000000..0151cd9
--- /dev/null
+++ b/apps/admin/src/app/(admin)/audit-log/[id]/page.tsx
@@ -0,0 +1,96 @@
+// apps/admin/src/app/(admin)/audit-log/[id]/page.tsx
+// 감사로그 상세 — before/after diff 뷰
+
+import { Box, Grid } from '@mui/material'
+import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
+import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
+import { getSupabaseServerClient } from '@/lib/supabase-server'
+import { requireAdmin } from '@/lib/admin-guard'
+import { notFound } from 'next/navigation'
+import Link from 'next/link'
+import { AuditDiffViewer } from '@/components/audit-diff-viewer'
+
+interface PageProps {
+ params: Promise<{ id: string }>
+}
+
+export default async function AuditLogDetailPage({ params }: PageProps): Promise {
+ await requireAdmin()
+ const { id } = await params
+ const supabase = await getSupabaseServerClient()
+
+ const { data: log } = await supabase
+ .from('audit_log')
+ .select('*')
+ .eq('id', parseInt(id, 10))
+ .maybeSingle()
+
+ if (!log) notFound()
+ const typedLog = log as Record
+
+ // Admin profile
+ const { data: adminProfile } = await supabase
+ .from('profiles')
+ .select('id, name')
+ .eq('id', typedLog.admin_id as string)
+ .maybeSingle()
+
+ const adminName = (adminProfile as { name: string | null } | null)?.name ?? 'Unknown'
+
+ return (
+
+
+ AUDIT LOG #{id}
+
+ {'<'} Back
+
+
+
+
+
+
+
+ DETAILS
+
+
+
+
+
+
+
+
+
+
+
+
+
+ MEMO
+
+ {typedLog.memo as string}
+
+
+
+
+
+
+
+
+ CHANGES (DIFF)
+ | null}
+ afterData={typedLog.after_data as Record | null}
+ />
+
+
+
+ )
+}
+
+function Row({ label, value }: { label: string; value: string }): React.ReactElement {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
diff --git a/apps/admin/src/app/(admin)/audit-log/page.tsx b/apps/admin/src/app/(admin)/audit-log/page.tsx
new file mode 100644
index 0000000..a37ae62
--- /dev/null
+++ b/apps/admin/src/app/(admin)/audit-log/page.tsx
@@ -0,0 +1,137 @@
+// apps/admin/src/app/(admin)/audit-log/page.tsx
+// 감사로그 목록
+
+import { Box } from '@mui/material'
+import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
+import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
+import { getSupabaseServerClient } from '@/lib/supabase-server'
+import { requireAdmin } from '@/lib/admin-guard'
+import Link from 'next/link'
+
+interface PageProps {
+ searchParams: Promise<{ target_type?: string; page?: string }>
+}
+
+export default async function AuditLogPage({ searchParams }: PageProps): Promise {
+ await requireAdmin()
+ const params = await searchParams
+ const targetTypeFilter = params.target_type ?? 'all'
+ const page = parseInt(params.page ?? '1', 10)
+ const limit = 20
+ const from = (page - 1) * limit
+ const to = from + limit - 1
+
+ const supabase = await getSupabaseServerClient()
+
+ let query = supabase
+ .from('audit_log')
+ .select('*', { count: 'exact' })
+
+ if (targetTypeFilter !== 'all') {
+ query = query.eq('target_type', targetTypeFilter)
+ }
+
+ const { data: rawLogs, count } = await query
+ .order('created_at', { ascending: false })
+ .range(from, to)
+
+ const logs = (rawLogs ?? []) as Array>
+ const totalPages = Math.ceil((count ?? 0) / limit)
+
+ // Admin names
+ const adminIds = [...new Set(logs.map(l => l.admin_id as string))]
+ let adminMap: Record = {}
+ if (adminIds.length > 0) {
+ const { data: admins } = await supabase
+ .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'])
+ )
+ }
+ }
+
+ return (
+
+ AUDIT LOG
+
+
+ {['all', 'subscription', 'profile'].map((t) => (
+
+
+ {t.toUpperCase()}
+
+
+ ))}
+
+
+
+
+ | DATE | ADMIN | ACTION | TARGET | MEMO | DETAIL |
+
+ {logs.length === 0 ? (
+ | No audit logs |
+ ) : (
+ logs.map((log) => (
+
+ |
+ {new Date(log.created_at as string).toLocaleString()}
+ |
+ {adminMap[log.admin_id as string] ?? (log.admin_id as string).substring(0, 8)} |
+ {log.action as string} |
+
+
+ {(log.target_id as string).substring(0, 8)}...
+
+ |
+
+ {log.memo as string}
+ |
+
+
+ View
+
+ |
+
+ ))
+ )}
+
+
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+ {Array.from({ length: totalPages }, (_, i) => i + 1).slice(0, 10).map((p) => (
+
+
+ {p}
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx b/apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx
new file mode 100644
index 0000000..d107f52
--- /dev/null
+++ b/apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx
@@ -0,0 +1,95 @@
+'use client'
+
+// apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx
+// 구독 수정/삭제 클라이언트 컴포넌트
+
+import { useState } from 'react'
+import { Box, Button } from '@mui/material'
+import { d3roFontMono } from '@d3ro/ui/theme'
+import { SubscriptionForm } from '@/components/subscription-form'
+import { MemoDialog } from '@/components/memo-dialog'
+import { callAdminApi } from '@/lib/admin-api'
+import { useRouter } from 'next/navigation'
+
+type Tier = 'free' | 'pro' | 'pro_plus'
+type SubStatus = 'active' | 'canceled' | 'past_due' | 'expired'
+
+interface SubscriptionDetailClientProps {
+ userId: string
+ hasSub: boolean
+ isSuperAdmin: boolean
+ initialSub?: {
+ tier: Tier
+ status: SubStatus
+ currentPeriodEnd: string | null
+ overageCredits: number
+ adminNote: string | null
+ }
+}
+
+export function SubscriptionDetailClient({
+ userId, hasSub, isSuperAdmin, initialSub,
+}: SubscriptionDetailClientProps): React.ReactElement {
+ const router = useRouter()
+ const [deleteOpen, setDeleteOpen] = useState(false)
+ const [deleteLoading, setDeleteLoading] = useState(false)
+
+ const handleDelete = async (memo: string): Promise => {
+ setDeleteLoading(true)
+ try {
+ await callAdminApi(`admin-subscriptions?userId=${userId}`, {
+ method: 'DELETE',
+ body: JSON.stringify({ memo }),
+ })
+ setDeleteOpen(false)
+ router.refresh()
+ } catch {
+ // error handled in dialog
+ } finally {
+ setDeleteLoading(false)
+ }
+ }
+
+ if (!isSuperAdmin) {
+ return
+ }
+
+ return (
+
+ {hasSub && initialSub ? (
+ <>
+ router.refresh()}
+ />
+
+
+
+ void handleDelete(memo)}
+ onCancel={() => setDeleteOpen(false)}
+ loading={deleteLoading}
+ />
+ >
+ ) : (
+ router.refresh()}
+ />
+ )}
+
+ )
+}
diff --git a/apps/admin/src/app/(admin)/subscriptions/[id]/page.tsx b/apps/admin/src/app/(admin)/subscriptions/[id]/page.tsx
new file mode 100644
index 0000000..f013588
--- /dev/null
+++ b/apps/admin/src/app/(admin)/subscriptions/[id]/page.tsx
@@ -0,0 +1,140 @@
+// apps/admin/src/app/(admin)/subscriptions/[id]/page.tsx
+// 구독 상세 + 수정/삭제 (super_admin) — [id]는 user_id
+
+import { Box, Grid } from '@mui/material'
+import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
+import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
+import { getSupabaseServerClient } from '@/lib/supabase-server'
+import { requireAdmin } from '@/lib/admin-guard'
+import { notFound } from 'next/navigation'
+import Link from 'next/link'
+import { SubscriptionDetailClient } from './client'
+
+interface PageProps {
+ params: Promise<{ id: string }>
+}
+
+export default async function SubscriptionDetailPage({ params }: PageProps): Promise {
+ const { id: userId } = await params
+ const admin = await requireAdmin()
+ const supabase = await getSupabaseServerClient()
+
+ const [subRes, profileRes, auditRes] = await Promise.all([
+ supabase.from('subscriptions').select('*').eq('user_id', userId).maybeSingle(),
+ supabase.from('profiles').select('id, name, tier, role').eq('id', userId).maybeSingle(),
+ supabase.from('audit_log').select('*')
+ .eq('target_id', userId)
+ .eq('target_type', 'subscription')
+ .order('created_at', { ascending: false })
+ .limit(20),
+ ])
+
+ const sub = subRes.data as Record | null
+ const profile = profileRes.data as Record | null
+
+ if (!profile) notFound()
+
+ const auditLogs = (auditRes.data ?? []) as Array>
+
+ return (
+
+
+ SUBSCRIPTION DETAIL
+
+ {'<'} Back
+
+
+
+
+
+
+
+ USER
+
+
+
+
+
+
+
+
+
+
+
+ CURRENT SUBSCRIPTION
+ {sub ? (
+
+
+
+
+
+
+
+
+ ) : (
+ No subscription record
+ )}
+
+
+
+
+
+ {/* Client component for CRUD actions */}
+
+
+ {/* Audit trail */}
+
+ AUDIT TRAIL
+ {auditLogs.length === 0 ? (
+ No audit records
+ ) : (
+
+
+ | DATE | ACTION | MEMO | DETAIL |
+
+ {auditLogs.map((log) => (
+
+ |
+ {new Date(log.created_at as string).toLocaleString()}
+ |
+ {log.action as string} |
+ {(log.memo as string).substring(0, 50)} |
+
+
+ View
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ )
+}
+
+function Row({ label, value }: { label: string; value: string }): React.ReactElement {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
diff --git a/apps/admin/src/app/(admin)/subscriptions/new/client.tsx b/apps/admin/src/app/(admin)/subscriptions/new/client.tsx
new file mode 100644
index 0000000..5339eed
--- /dev/null
+++ b/apps/admin/src/app/(admin)/subscriptions/new/client.tsx
@@ -0,0 +1,48 @@
+'use client'
+
+// apps/admin/src/app/(admin)/subscriptions/new/client.tsx
+
+import { useState } from 'react'
+import { Box, TextField } from '@mui/material'
+import { PhosphorText } from '@d3ro/ui/components/ds'
+import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
+import { SubscriptionForm } from '@/components/subscription-form'
+import { useRouter } from 'next/navigation'
+
+interface NewSubscriptionClientProps {
+ initialUserId: string
+}
+
+export function NewSubscriptionClient({ initialUserId }: NewSubscriptionClientProps): React.ReactElement {
+ const [userId, setUserId] = useState(initialUserId)
+ const router = useRouter()
+
+ return (
+
+
+ TARGET USER ID
+ setUserId(e.target.value)}
+ placeholder="UUID of the user..."
+ sx={{
+ '& .MuiInputBase-root': {
+ fontFamily: d3roFontMono,
+ fontSize: 13,
+ color: d3roPalette.text.primary,
+ bgcolor: d3roPalette.bg.inset,
+ },
+ }}
+ />
+
+ {userId && (
+ router.push(`/subscriptions/${userId}`)}
+ />
+ )}
+
+ )
+}
diff --git a/apps/admin/src/app/(admin)/subscriptions/new/page.tsx b/apps/admin/src/app/(admin)/subscriptions/new/page.tsx
new file mode 100644
index 0000000..59d35eb
--- /dev/null
+++ b/apps/admin/src/app/(admin)/subscriptions/new/page.tsx
@@ -0,0 +1,30 @@
+// apps/admin/src/app/(admin)/subscriptions/new/page.tsx
+// 새 구독 생성 (VIP 부여) — super_admin 전용
+
+import { Box } from '@mui/material'
+import { PhosphorText } from '@d3ro/ui/components/ds'
+import { requireSuperAdmin } from '@/lib/admin-guard'
+import Link from 'next/link'
+import { NewSubscriptionClient } from './client'
+
+interface PageProps {
+ searchParams: Promise<{ userId?: string }>
+}
+
+export default async function NewSubscriptionPage({ searchParams }: PageProps): Promise {
+ await requireSuperAdmin()
+ const params = await searchParams
+ const userId = params.userId ?? ''
+
+ return (
+
+
+ NEW SUBSCRIPTION
+
+ {'<'} Back
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/app/(admin)/subscriptions/page.tsx b/apps/admin/src/app/(admin)/subscriptions/page.tsx
index c22a049..cd42137 100644
--- a/apps/admin/src/app/(admin)/subscriptions/page.tsx
+++ b/apps/admin/src/app/(admin)/subscriptions/page.tsx
@@ -63,7 +63,18 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
return (
- SUBSCRIPTIONS
+
+ SUBSCRIPTIONS
+
+
+ + NEW
+
+
+
{['all', 'active', 'canceled', 'past_due', 'expired'].map((s) => (
@@ -86,7 +97,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
}}>
- | USER | TIER | STATUS | PROVIDER | EXPIRES | CANCEL | FAILS |
+ | USER | TIER | STATUS | PROVIDER | EXPIRES | CANCEL | FAILS | EDIT |
{subs.map((s) => (
@@ -106,6 +117,11 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
{s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : '-'} |
{s.cancel_at ? new Date(s.cancel_at).toLocaleDateString() : '-'} |
0 ? d3roPalette.tag.red : d3roPalette.text.muted }}>{s.renewal_failures} |
+
+
+ Edit
+
+ |
))}
diff --git a/apps/admin/src/app/(admin)/usage/page.tsx b/apps/admin/src/app/(admin)/usage/page.tsx
index d40f0d8..f07b4a7 100644
--- a/apps/admin/src/app/(admin)/usage/page.tsx
+++ b/apps/admin/src/app/(admin)/usage/page.tsx
@@ -1,10 +1,14 @@
// apps/admin/src/app/(admin)/usage/page.tsx
-// 사용량 집계 — feature별, 날짜 범위
+// 사용량 — feature별 차트 + DAU + Top users + 테이블
import { Box, Grid } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { getSupabaseServerClient } from '@/lib/supabase-server'
+import Link from 'next/link'
+import { FeatureUsageChart } from '@/components/charts/feature-usage-chart'
+import { DauChart } from '@/components/charts/dau-chart'
+import { TopUsersChart } from '@/components/charts/top-users-chart'
interface PageProps {
searchParams: Promise<{ days?: string }>
@@ -14,16 +18,32 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
const params = await searchParams
const days = parseInt(params.days ?? '7', 10)
const since = new Date(Date.now() - days * 86400000).toISOString().split('T')[0]
+ const today = new Date().toISOString().split('T')[0]
const supabase = await getSupabaseServerClient()
- const { data: rawData } = await supabase
- .from('daily_usage')
- .select('date, feature, count, user_id')
- .gte('date', since)
- .order('date', { ascending: false })
- const rows = (rawData ?? []) as Array<{ date: string; feature: string; count: number; user_id: string }>
+ // Fetch all data in parallel
+ // RPC functions are defined in migration but not in Database types — cast via unknown
+ const rpcClient = supabase as unknown as {
+ rpc: (fn: string, params: Record) => Promise<{ data: unknown[]; error: unknown }>
+ }
+ const [featureRes, dauRes, topUsersRes, rawDataRes] = await Promise.all([
+ rpcClient.rpc('admin_usage_by_feature', { p_from: since, p_to: today }),
+ rpcClient.rpc('admin_dau', { p_from: since, p_to: today }),
+ rpcClient.rpc('admin_top_users', { p_from: since, p_to: today, p_limit: 20 }),
+ supabase.from('daily_usage')
+ .select('date, feature, count, user_id')
+ .gte('date', since)
+ .order('date', { ascending: false }),
+ ])
+
+ const featureData = (featureRes.data ?? []) as Array<{ date: string; feature: string; total_count: number; unique_users: number }>
+ const dauData = (dauRes.data ?? []) as Array<{ date: string; active_users: number }>
+ const topUsersData = (topUsersRes.data ?? []) as Array<{ user_id: string; name: string | null; total_count: number; feature_count: number }>
+
+ // Summary cards from raw data
+ const rows = (rawDataRes.data ?? []) as Array<{ date: string; feature: string; count: number; user_id: string }>
const featureMap = new Map }>()
for (const r of rows) {
const entry = featureMap.get(r.feature) ?? { total: 0, users: new Set() }
@@ -31,41 +51,27 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
entry.users.add(r.user_id)
featureMap.set(r.feature, entry)
}
-
const summaries = Array.from(featureMap.entries())
.map(([feature, { total, users }]) => ({ feature, total, uniqueUsers: users.size }))
.sort((a, b) => b.total - a.total)
- const dailyMap = new Map>()
- for (const r of rows) {
- const dayEntry = dailyMap.get(r.date) ?? new Map()
- dayEntry.set(r.feature, (dayEntry.get(r.feature) ?? 0) + r.count)
- dailyMap.set(r.date, dayEntry)
- }
-
- const dailyRows: Array<{ date: string; feature: string; total: number }> = []
- for (const [date, features] of dailyMap) {
- for (const [feature, total] of features) {
- dailyRows.push({ date, feature, total })
- }
- }
-
return (
USAGE
{[7, 14, 30].map((d) => (
-
+
{d}D
-
+
))}
+ {/* Summary cards */}
{summaries.map((s) => (
@@ -80,6 +86,49 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
))}
+ {/* Feature usage stacked bar chart */}
+
+
+ FEATURE USAGE (DAILY)
+ {featureData.length > 0 ? (
+
+ ) : (
+ No data
+ )}
+
+
+
+
+ {/* DAU chart */}
+
+
+
+ DAILY ACTIVE USERS
+ {dauData.length > 0 ? (
+
+ ) : (
+ No data
+ )}
+
+
+
+
+ {/* Top users chart */}
+
+
+
+ TOP USERS
+ {topUsersData.length > 0 ? (
+
+ ) : (
+ No data
+ )}
+
+
+
+
+
+ {/* Daily breakdown table */}
DAILY BREAKDOWN
@@ -88,17 +137,19 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
'& th, & td': { py: 0.5, px: 1.5, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
}}>
- | DATE | FEATURE | CALLS |
+ | DATE | FEATURE | CALLS | UNIQUE USERS |
- {dailyRows.map((r, i) => (
-
- | {r.date} |
- {r.feature} |
- {r.total.toLocaleString()} |
-
- ))}
- {dailyRows.length === 0 && (
- | No usage data |
+ {featureData.length > 0 ? (
+ [...featureData].reverse().map((r, i) => (
+
+ | {r.date} |
+ {r.feature} |
+ {r.total_count.toLocaleString()} |
+ {r.unique_users} |
+
+ ))
+ ) : (
+ | No usage data |
)}
diff --git a/apps/admin/src/app/(admin)/users/[id]/page.tsx b/apps/admin/src/app/(admin)/users/[id]/page.tsx
index f169730..bae7d94 100644
--- a/apps/admin/src/app/(admin)/users/[id]/page.tsx
+++ b/apps/admin/src/app/(admin)/users/[id]/page.tsx
@@ -5,7 +5,11 @@ import { Box, Grid } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { getSupabaseServerClient } from '@/lib/supabase-server'
+import { requireAdmin } from '@/lib/admin-guard'
import { notFound } from 'next/navigation'
+import Link from 'next/link'
+import { RoleChangeButton } from './role-change-button'
+import { PaymentHistory } from '@/components/payment-history'
interface PageProps {
params: Promise<{ id: string }>
@@ -13,6 +17,7 @@ interface PageProps {
export default async function AdminUserDetailPage({ params }: PageProps): Promise {
const { id } = await params
+ const admin = await requireAdmin()
const supabase = await getSupabaseServerClient()
const [profileRes, subRes, usageRes] = await Promise.all([
@@ -32,9 +37,19 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
const tier = (profile.tier as string) ?? 'free'
const tierColor = tier === 'pro_plus' ? d3roPalette.tag.purple : tier === 'pro' ? d3roPalette.tag.green : d3roPalette.accent.amber
+ const userRole = ((profile.role as string) ?? 'user') as 'user' | 'admin' | 'super_admin'
+
return (
- USER DETAIL
+
+ USER DETAIL
+
+
@@ -45,6 +60,7 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
+
@@ -61,6 +77,11 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
+
+
+ Edit Subscription {'->'}
+
+
) : (
No subscription
@@ -95,6 +116,12 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
)}
+
+ {/* Payment History */}
+
+ PAYMENT HISTORY
+
+
)
}
diff --git a/apps/admin/src/app/(admin)/users/[id]/role-change-button.tsx b/apps/admin/src/app/(admin)/users/[id]/role-change-button.tsx
new file mode 100644
index 0000000..f99d589
--- /dev/null
+++ b/apps/admin/src/app/(admin)/users/[id]/role-change-button.tsx
@@ -0,0 +1,50 @@
+'use client'
+
+// apps/admin/src/app/(admin)/users/[id]/role-change-button.tsx
+// super_admin만 볼 수 있는 role 변경 버튼
+
+import { useState } from 'react'
+import { Button } from '@mui/material'
+import { d3roFontMono } from '@d3ro/ui/theme'
+import { RoleChangeDialog } from '@/components/role-change-dialog'
+import { useRouter } from 'next/navigation'
+
+interface RoleChangeButtonProps {
+ userId: string
+ userName: string | null
+ currentRole: 'user' | 'admin' | 'super_admin'
+ isSuperAdmin: boolean
+}
+
+export function RoleChangeButton({
+ userId, userName, currentRole, isSuperAdmin,
+}: RoleChangeButtonProps): React.ReactElement | null {
+ const [open, setOpen] = useState(false)
+ const router = useRouter()
+
+ if (!isSuperAdmin) return null
+
+ return (
+ <>
+
+ setOpen(false)}
+ onSuccess={() => {
+ setOpen(false)
+ router.refresh()
+ }}
+ />
+ >
+ )
+}
diff --git a/apps/admin/src/components/admin-sidebar.tsx b/apps/admin/src/components/admin-sidebar.tsx
index 2967c5e..df261fd 100644
--- a/apps/admin/src/components/admin-sidebar.tsx
+++ b/apps/admin/src/components/admin-sidebar.tsx
@@ -9,6 +9,8 @@ import DashboardIcon from '@mui/icons-material/Dashboard'
import PeopleIcon from '@mui/icons-material/People'
import SubscriptionsIcon from '@mui/icons-material/Subscriptions'
import BarChartIcon from '@mui/icons-material/BarChart'
+import HistoryIcon from '@mui/icons-material/History'
+import ApiIcon from '@mui/icons-material/Api'
import LogoutIcon from '@mui/icons-material/Logout'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
@@ -19,6 +21,11 @@ const NAV_ITEMS = [
{ key: 'users', path: '/users', label: 'Users', icon: },
{ key: 'subscriptions', path: '/subscriptions', label: 'Subscriptions', icon: },
{ key: 'usage', path: '/usage', label: 'Usage', icon: },
+ { key: 'audit-log', path: '/audit-log', label: 'Audit Log', icon: },
+]
+
+const EXTERNAL_LINKS = [
+ { key: 'swagger', href: '/admin-swagger/', label: 'API Docs', icon: },
]
export function AdminSidebar(): React.ReactElement {
@@ -78,6 +85,41 @@ export function AdminSidebar(): React.ReactElement {
)
})}
+
+
+
+ {EXTERNAL_LINKS.map((item) => (
+
+
+
+ {item.icon}
+
+
+
+
+ ))}
diff --git a/apps/admin/src/components/audit-diff-viewer.tsx b/apps/admin/src/components/audit-diff-viewer.tsx
new file mode 100644
index 0000000..29b446a
--- /dev/null
+++ b/apps/admin/src/components/audit-diff-viewer.tsx
@@ -0,0 +1,128 @@
+'use client'
+
+// apps/admin/src/components/audit-diff-viewer.tsx
+// before/after JSON diff 뷰어
+
+import { Box } from '@mui/material'
+import { PhosphorText } from '@d3ro/ui/components/ds'
+import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
+
+interface AuditDiffViewerProps {
+ beforeData: Record | null
+ afterData: Record | null
+}
+
+interface DiffEntry {
+ key: string
+ before: unknown
+ after: unknown
+ type: 'added' | 'removed' | 'changed' | 'unchanged'
+}
+
+function computeDiff(
+ before: Record | null,
+ after: Record | null
+): DiffEntry[] {
+ const allKeys = new Set([
+ ...Object.keys(before ?? {}),
+ ...Object.keys(after ?? {}),
+ ])
+
+ const entries: DiffEntry[] = []
+ for (const key of allKeys) {
+ const bVal = before?.[key]
+ const aVal = after?.[key]
+ const bStr = JSON.stringify(bVal)
+ const aStr = JSON.stringify(aVal)
+
+ if (bVal === undefined) {
+ entries.push({ key, before: undefined, after: aVal, type: 'added' })
+ } else if (aVal === undefined) {
+ entries.push({ key, before: bVal, after: undefined, type: 'removed' })
+ } else if (bStr !== aStr) {
+ entries.push({ key, before: bVal, after: aVal, type: 'changed' })
+ } else {
+ entries.push({ key, before: bVal, after: aVal, type: 'unchanged' })
+ }
+ }
+
+ // changed/added/removed first
+ return entries.sort((a, b) => {
+ const order = { changed: 0, added: 1, removed: 2, unchanged: 3 }
+ return order[a.type] - order[b.type]
+ })
+}
+
+const typeColors: Record = {
+ added: d3roPalette.tag.green,
+ removed: d3roPalette.tag.red,
+ changed: d3roPalette.accent.amber,
+ unchanged: d3roPalette.text.muted,
+}
+
+const typeLabels: Record = {
+ added: '+',
+ removed: '-',
+ changed: '~',
+ unchanged: ' ',
+}
+
+function formatValue(val: unknown): string {
+ if (val === undefined) return '(none)'
+ if (val === null) return 'null'
+ if (typeof val === 'string') return `"${val}"`
+ return JSON.stringify(val)
+}
+
+export function AuditDiffViewer({ beforeData, afterData }: AuditDiffViewerProps): React.ReactElement {
+ if (!beforeData && !afterData) {
+ return No diff data
+ }
+
+ const diff = computeDiff(beforeData, afterData)
+
+ return (
+
+ {diff.map((entry) => (
+
+
+ {typeLabels[entry.type]}
+
+
+ {entry.key}
+
+ {entry.type === 'changed' ? (
+
+
+ {formatValue(entry.before)}
+
+ {'->'}
+
+ {formatValue(entry.after)}
+
+
+ ) : (
+
+ {entry.type === 'removed' ? formatValue(entry.before) : formatValue(entry.after)}
+
+ )}
+
+ ))}
+
+ )
+}
diff --git a/apps/admin/src/components/charts/dau-chart.tsx b/apps/admin/src/components/charts/dau-chart.tsx
new file mode 100644
index 0000000..8cfc73f
--- /dev/null
+++ b/apps/admin/src/components/charts/dau-chart.tsx
@@ -0,0 +1,60 @@
+'use client'
+
+// apps/admin/src/components/charts/dau-chart.tsx
+// DAU/활성 유저 추이 — Line chart
+
+import {
+ ResponsiveContainer,
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+} from 'recharts'
+import { Box } from '@mui/material'
+import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
+
+interface DauRow {
+ date: string
+ active_users: number
+}
+
+interface DauChartProps {
+ data: DauRow[]
+}
+
+export function DauChart({ data }: DauChartProps): React.ReactElement {
+ return (
+
+
+
+
+ val.slice(5)}
+ />
+
+
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/components/charts/feature-usage-chart.tsx b/apps/admin/src/components/charts/feature-usage-chart.tsx
new file mode 100644
index 0000000..64a97ff
--- /dev/null
+++ b/apps/admin/src/components/charts/feature-usage-chart.tsx
@@ -0,0 +1,88 @@
+'use client'
+
+// apps/admin/src/components/charts/feature-usage-chart.tsx
+// feature별 일간 API 호출 — StackedBar
+
+import {
+ ResponsiveContainer,
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend,
+} from 'recharts'
+import { Box } from '@mui/material'
+import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
+
+interface FeatureUsageRow {
+ date: string
+ feature: string
+ total_count: number
+ unique_users: number
+}
+
+interface FeatureUsageChartProps {
+ data: FeatureUsageRow[]
+}
+
+const FEATURE_COLORS: Record = {
+ llm_haiku: d3roPalette.accent.amber,
+ llm_sonnet: d3roPalette.tag.green,
+ llm_opus: d3roPalette.tag.purple,
+ stt_transcribe: d3roPalette.tag.blue,
+ translate: d3roPalette.tag.blue,
+}
+
+const DEFAULT_COLOR = d3roPalette.text.secondary
+
+export function FeatureUsageChart({ data }: FeatureUsageChartProps): React.ReactElement {
+ // Pivot: group by date, features as columns
+ const features = [...new Set(data.map(d => d.feature))]
+ const dateMap = new Map>()
+
+ for (const row of data) {
+ const existing = dateMap.get(row.date) ?? { date: row.date }
+ existing[row.feature] = row.total_count
+ dateMap.set(row.date, existing)
+ }
+
+ const chartData = [...dateMap.values()].sort((a, b) =>
+ String(a.date).localeCompare(String(b.date))
+ )
+
+ return (
+
+
+
+
+ val.slice(5)}
+ />
+
+
+
+ {features.map((feature) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/admin/src/components/charts/top-users-chart.tsx b/apps/admin/src/components/charts/top-users-chart.tsx
new file mode 100644
index 0000000..9d5f899
--- /dev/null
+++ b/apps/admin/src/components/charts/top-users-chart.tsx
@@ -0,0 +1,65 @@
+'use client'
+
+// apps/admin/src/components/charts/top-users-chart.tsx
+// 유저별 사용량 Top 20 — Horizontal bar
+
+import {
+ ResponsiveContainer,
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+} from 'recharts'
+import { Box } from '@mui/material'
+import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
+
+interface TopUserRow {
+ user_id: string
+ name: string | null
+ total_count: number
+ feature_count: number
+}
+
+interface TopUsersChartProps {
+ data: TopUserRow[]
+}
+
+export function TopUsersChart({ data }: TopUsersChartProps): React.ReactElement {
+ const chartData = data.map(row => ({
+ name: row.name ?? row.user_id.substring(0, 8),
+ total: row.total_count,
+ features: row.feature_count,
+ }))
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/components/memo-dialog.tsx b/apps/admin/src/components/memo-dialog.tsx
new file mode 100644
index 0000000..ea4ff8c
--- /dev/null
+++ b/apps/admin/src/components/memo-dialog.tsx
@@ -0,0 +1,93 @@
+'use client'
+
+// apps/admin/src/components/memo-dialog.tsx
+// 메모 입력 다이얼로그 — 감사로그 기록 시 필수
+
+import { useState } from 'react'
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ TextField,
+ Button,
+} from '@mui/material'
+import { PhosphorText } from '@d3ro/ui/components/ds'
+import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
+
+interface MemoDialogProps {
+ open: boolean
+ title: string
+ description?: string
+ onConfirm: (memo: string) => void
+ onCancel: () => void
+ loading?: boolean
+}
+
+export function MemoDialog({ open, title, description, onConfirm, onCancel, loading }: MemoDialogProps): React.ReactElement {
+ const [memo, setMemo] = useState('')
+
+ const handleConfirm = (): void => {
+ if (memo.trim()) {
+ onConfirm(memo.trim())
+ setMemo('')
+ }
+ }
+
+ const handleCancel = (): void => {
+ setMemo('')
+ onCancel()
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/admin/src/components/payment-history.tsx b/apps/admin/src/components/payment-history.tsx
new file mode 100644
index 0000000..b537dae
--- /dev/null
+++ b/apps/admin/src/components/payment-history.tsx
@@ -0,0 +1,169 @@
+'use client'
+
+// apps/admin/src/components/payment-history.tsx
+// 결제 이력 패널 — DB + Payple 조회
+
+import { useState, useEffect } from 'react'
+import { Box, Button, CircularProgress } from '@mui/material'
+import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
+import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
+import { callAdminApi } from '@/lib/admin-api'
+
+interface PaymentHistoryProps {
+ userId: string
+}
+
+interface AuditLogEntry {
+ id: number
+ action: string
+ memo: string
+ created_at: string
+ before_data: Record | null
+ after_data: Record | null
+}
+
+interface PaymentData {
+ subscription: Record | null
+ auditLogs: AuditLogEntry[]
+ paypleHistory?: Record
+ paypleError?: string
+}
+
+export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElement {
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [paypleLoading, setPaypleLoading] = useState(false)
+
+ useEffect(() => {
+ const load = async (): Promise => {
+ try {
+ const result = await callAdminApi(`admin-payments?userId=${userId}`)
+ setData(result)
+ } catch {
+ // ignore
+ } finally {
+ setLoading(false)
+ }
+ }
+ void load()
+ }, [userId])
+
+ const loadPayple = async (): Promise => {
+ setPaypleLoading(true)
+ try {
+ const result = await callAdminApi(`admin-payments?userId=${userId}&source=payple`)
+ setData(result)
+ } catch {
+ // ignore
+ } finally {
+ setPaypleLoading(false)
+ }
+ }
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ if (!data) {
+ return Failed to load payment data
+ }
+
+ const sub = data.subscription
+
+ return (
+
+ {/* Subscription summary */}
+ {sub && (
+
+
+ PAYMENT INFO
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Payple direct query */}
+
+
+
+ PAYPLE HISTORY
+
+
+ {data.paypleHistory ? (
+
+ {JSON.stringify(data.paypleHistory, null, 2)}
+
+ ) : data.paypleError ? (
+ {data.paypleError}
+ ) : (
+ Click "Fetch from Payple" to query payment history
+ )}
+
+
+
+ {/* Audit log timeline */}
+
+
+ SUBSCRIPTION TIMELINE
+ {data.auditLogs.length === 0 ? (
+ No subscription changes recorded
+ ) : (
+
+ {data.auditLogs.map((log) => (
+
+
+
+ {new Date(log.created_at).toLocaleString()}
+
+
+ {log.action}
+
+
+ {log.memo}
+
+ ))}
+
+ )}
+
+
+
+ )
+}
+
+function Row({ label, value }: { label: string; value: string }): React.ReactElement {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
diff --git a/apps/admin/src/components/role-change-dialog.tsx b/apps/admin/src/components/role-change-dialog.tsx
new file mode 100644
index 0000000..d140bc7
--- /dev/null
+++ b/apps/admin/src/components/role-change-dialog.tsx
@@ -0,0 +1,130 @@
+'use client'
+
+// apps/admin/src/components/role-change-dialog.tsx
+// role 변경 확인 다이얼로그 — super_admin 전용
+
+import { useState } from 'react'
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ TextField,
+ Button,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+} from '@mui/material'
+import { PhosphorText } from '@d3ro/ui/components/ds'
+import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
+import { callAdminApi } from '@/lib/admin-api'
+
+type Role = 'user' | 'admin' | 'super_admin'
+
+interface RoleChangeDialogProps {
+ open: boolean
+ userId: string
+ userName: string | null
+ currentRole: Role
+ onClose: () => void
+ onSuccess: () => void
+}
+
+export function RoleChangeDialog({
+ open, userId, userName, currentRole, onClose, onSuccess,
+}: RoleChangeDialogProps): React.ReactElement {
+ const [newRole, setNewRole] = useState(currentRole)
+ const [memo, setMemo] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const handleConfirm = async (): Promise => {
+ if (!memo.trim() || newRole === currentRole) return
+
+ setLoading(true)
+ setError(null)
+ try {
+ await callAdminApi('admin-users', {
+ method: 'PATCH',
+ body: JSON.stringify({ userId, newRole, memo: memo.trim() }),
+ })
+ setMemo('')
+ onSuccess()
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to change role')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/admin/src/components/subscription-form.tsx b/apps/admin/src/components/subscription-form.tsx
new file mode 100644
index 0000000..85e5fab
--- /dev/null
+++ b/apps/admin/src/components/subscription-form.tsx
@@ -0,0 +1,187 @@
+'use client'
+
+// apps/admin/src/components/subscription-form.tsx
+// 구독 생성/수정 공용 폼
+
+import { useState } from 'react'
+import {
+ Box,
+ TextField,
+ Button,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ Alert,
+} from '@mui/material'
+import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
+import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
+import { callAdminApi } from '@/lib/admin-api'
+
+type Tier = 'free' | 'pro' | 'pro_plus'
+type SubStatus = 'active' | 'canceled' | 'past_due' | 'expired'
+
+interface SubscriptionFormProps {
+ mode: 'create' | 'edit'
+ userId: string
+ initial?: {
+ tier: Tier
+ status: SubStatus
+ currentPeriodEnd: string | null
+ overageCredits: number
+ adminNote: string | null
+ }
+ onSuccess: () => void
+}
+
+export function SubscriptionForm({ mode, userId, initial, onSuccess }: SubscriptionFormProps): React.ReactElement {
+ const [tier, setTier] = useState(initial?.tier ?? 'free')
+ const [status, setStatus] = useState(initial?.status ?? 'active')
+ const [periodEnd, setPeriodEnd] = useState(initial?.currentPeriodEnd?.split('T')[0] ?? '')
+ const [overageCredits, setOverageCredits] = useState(initial?.overageCredits ?? 0)
+ const [adminNote, setAdminNote] = useState(initial?.adminNote ?? '')
+ const [memo, setMemo] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [success, setSuccess] = useState(false)
+
+ const handleSubmit = async (): Promise => {
+ if (!memo.trim()) return
+
+ setLoading(true)
+ setError(null)
+ setSuccess(false)
+
+ try {
+ if (mode === 'create') {
+ await callAdminApi('admin-subscriptions', {
+ method: 'POST',
+ body: JSON.stringify({
+ userId,
+ tier,
+ status,
+ currentPeriodEnd: periodEnd ? new Date(periodEnd).toISOString() : undefined,
+ adminNote: adminNote || undefined,
+ memo: memo.trim(),
+ }),
+ })
+ } else {
+ await callAdminApi(`admin-subscriptions?userId=${userId}`, {
+ method: 'PATCH',
+ body: JSON.stringify({
+ tier,
+ status,
+ currentPeriodEnd: periodEnd ? new Date(periodEnd).toISOString() : undefined,
+ overageCredits,
+ adminNote: adminNote || undefined,
+ memo: memo.trim(),
+ }),
+ })
+ }
+ setSuccess(true)
+ setMemo('')
+ onSuccess()
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Operation failed')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const inputSx = {
+ '& .MuiInputBase-root': {
+ fontFamily: d3roFontMono,
+ fontSize: 13,
+ color: d3roPalette.text.primary,
+ },
+ }
+
+ return (
+
+
+
+ {mode === 'create' ? 'CREATE SUBSCRIPTION' : 'EDIT SUBSCRIPTION'}
+
+
+
+ Tier
+
+
+
+
+ Status
+
+
+
+ setPeriodEnd(e.target.value)}
+ slotProps={{ inputLabel: { shrink: true } }}
+ sx={inputSx}
+ />
+
+ {mode === 'edit' && (
+ setOverageCredits(parseInt(e.target.value, 10) || 0)}
+ sx={inputSx}
+ />
+ )}
+
+ setAdminNote(e.target.value)}
+ sx={inputSx}
+ />
+
+ setMemo(e.target.value)}
+ placeholder="Reason for this action..."
+ sx={{
+ ...inputSx,
+ '& .MuiInputBase-root': {
+ ...inputSx['& .MuiInputBase-root'],
+ bgcolor: d3roPalette.bg.inset,
+ },
+ }}
+ />
+
+ {error && {error}}
+ {success && Operation successful}
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/lib/admin-api.ts b/apps/admin/src/lib/admin-api.ts
new file mode 100644
index 0000000..6cf224c
--- /dev/null
+++ b/apps/admin/src/lib/admin-api.ts
@@ -0,0 +1,39 @@
+// apps/admin/src/lib/admin-api.ts
+// Edge Function 호출 헬퍼 — 클라이언트 컴포넌트용
+
+import { getSupabaseBrowserClient } from './supabase-browser'
+
+const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
+
+interface AdminApiOptions extends Omit {
+ headers?: Record
+}
+
+export async function callAdminApi>(
+ path: string,
+ options: AdminApiOptions = {}
+): Promise {
+ const supabase = getSupabaseBrowserClient()
+ const { data: { session } } = await supabase.auth.getSession()
+
+ if (!session?.access_token) {
+ throw new Error('Not authenticated')
+ }
+
+ const response = await fetch(`${SUPABASE_URL}/functions/v1/${path}`, {
+ ...options,
+ headers: {
+ Authorization: `Bearer ${session.access_token}`,
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ })
+
+ const data = await response.json() as T & { error?: string }
+
+ if (!response.ok) {
+ throw new Error(data.error ?? `API error: ${response.status}`)
+ }
+
+ return data
+}
diff --git a/apps/admin/src/lib/admin-guard.ts b/apps/admin/src/lib/admin-guard.ts
index bc88d9d..d107b42 100644
--- a/apps/admin/src/lib/admin-guard.ts
+++ b/apps/admin/src/lib/admin-guard.ts
@@ -1,15 +1,19 @@
// apps/admin/src/lib/admin-guard.ts
-// RSC용 admin 가드 — app_metadata.role='admin' 체크
+// RSC용 admin 가드 — app_metadata.role = 'admin' | 'super_admin'
import { redirect } from 'next/navigation'
import { getSupabaseServerClient } from './supabase-server'
+export type AdminRole = 'admin' | 'super_admin'
+
export interface AdminUser {
id: string
email: string | null
name: string | null
+ role: AdminRole
}
+/** admin 이상 (admin, super_admin) */
export async function requireAdmin(): Promise {
const supabase = await getSupabaseServerClient()
const { data: { user } } = await supabase.auth.getUser()
@@ -18,13 +22,11 @@ export async function requireAdmin(): Promise {
redirect('/login')
}
- // app_metadata.role 체크 (JWT에 포함, RLS 재귀 없음)
const role = (user.app_metadata as Record)?.role as string | undefined
- if (role !== 'admin') {
+ if (role !== 'admin' && role !== 'super_admin') {
redirect('/unauthorized')
}
- // profile 이름 조회 (자기 자신은 기존 RLS로 접근 가능)
const { data: profile } = await supabase
.from('profiles')
.select('name')
@@ -35,5 +37,20 @@ export async function requireAdmin(): Promise {
id: user.id,
email: user.email ?? null,
name: (profile as { name: string | null } | null)?.name ?? null,
+ role: role as AdminRole,
}
}
+
+/** super_admin 전용 */
+export async function requireSuperAdmin(): Promise {
+ const adminUser = await requireAdmin()
+ if (adminUser.role !== 'super_admin') {
+ redirect('/unauthorized')
+ }
+ return adminUser
+}
+
+/** role이 super_admin인지 체크 */
+export function isSuperAdmin(user: AdminUser): boolean {
+ return user.role === 'super_admin'
+}
diff --git a/package-lock.json b/package-lock.json
index 9da05c5..a742d49 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -40,7 +40,8 @@
"@supabase/supabase-js": "^2.103.0",
"next": "^15.0.0",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "recharts": "^2.15.0"
},
"devDependencies": {
"@types/node": "^22.13.0",
@@ -48,6 +49,63 @@
"@types/react-dom": "^19.0.0"
}
},
+ "apps/admin/node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
+ "apps/admin/node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
+ "apps/admin/node_modules/recharts": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+ "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.21",
+ "react-is": "^18.3.1",
+ "react-smooth": "^4.0.4",
+ "recharts-scale": "^0.4.4",
+ "tiny-invariant": "^1.3.1",
+ "victory-vendor": "^36.6.8"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "apps/admin/node_modules/victory-vendor": {
+ "version": "36.9.2",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+ "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
"apps/desktop": {
"name": "@d3ro/desktop",
"version": "1.0.0",
@@ -9879,6 +9937,15 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
+ "node_modules/fast-equals": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
+ "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -11903,7 +11970,6 @@
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/lodash-es": {
@@ -15260,6 +15326,21 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-smooth": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+ "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -15419,6 +15500,15 @@
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
diff --git a/server/supabase/config.toml b/server/supabase/config.toml
index be6a5c1..9f63eaa 100644
--- a/server/supabase/config.toml
+++ b/server/supabase/config.toml
@@ -123,5 +123,17 @@ verify_jwt = true
[functions.search-knowledge]
verify_jwt = true
+[functions.admin-users]
+verify_jwt = true
+
+[functions.admin-subscriptions]
+verify_jwt = true
+
+[functions.admin-payments]
+verify_jwt = true
+
+[functions.admin-audit-log]
+verify_jwt = true
+
[analytics]
enabled = false
diff --git a/server/supabase/functions/_shared/admin-auth.ts b/server/supabase/functions/_shared/admin-auth.ts
new file mode 100644
index 0000000..e789326
--- /dev/null
+++ b/server/supabase/functions/_shared/admin-auth.ts
@@ -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 {
+ const user = await requireUser(req)
+ const role = (user.app_metadata as Record)?.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 {
+ const user = await requireUser(req)
+ const role = (user.app_metadata as Record)?.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)?.role as string | undefined
+ if (role === 'admin' || role === 'super_admin') return role
+ return null
+}
diff --git a/server/supabase/functions/_shared/audit.ts b/server/supabase/functions/_shared/audit.ts
new file mode 100644
index 0000000..820f65b
--- /dev/null
+++ b/server/supabase/functions/_shared/audit.ts
@@ -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 | null
+ afterData: Record | null
+ memo: string
+}
+
+/**
+ * audit_log 테이블에 감사 기록을 삽입한다.
+ * service_role 클라이언트를 사용해야 RLS를 우회한다.
+ */
+export async function writeAuditLog(
+ supabase: SupabaseClient,
+ entry: AuditLogEntry
+): Promise {
+ 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}`)
+ }
+}
diff --git a/server/supabase/functions/_shared/cors.ts b/server/supabase/functions/_shared/cors.ts
index a00c7c1..5c41126 100644
--- a/server/supabase/functions/_shared/cors.ts
+++ b/server/supabase/functions/_shared/cors.ts
@@ -5,7 +5,7 @@ export const corsHeaders: Record = {
'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 {
diff --git a/server/supabase/functions/admin-audit-log/index.ts b/server/supabase/functions/admin-audit-log/index.ts
new file mode 100644
index 0000000..dc2da69
--- /dev/null
+++ b/server/supabase/functions/admin-audit-log/index.ts
@@ -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 | Record[], 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).admin_id)
+ .maybeSingle()
+
+ return jsonResponse({
+ log: log as unknown as Record,
+ admin: adminProfile as unknown as Record,
+ })
+ }
+
+ // 목록 조회
+ 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[]
+ const adminIds = [...new Set(logs.map(l => l.admin_id as string))]
+
+ let adminMap: Record = {}
+ 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)
+ }
+})
diff --git a/server/supabase/functions/admin-payments/index.ts b/server/supabase/functions/admin-payments/index.ts
new file mode 100644
index 0000000..b6965cd
--- /dev/null
+++ b/server/supabase/functions/admin-payments/index.ts
@@ -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 | Record[], 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 = {
+ 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)
+ }
+})
diff --git a/server/supabase/functions/admin-subscriptions/index.ts b/server/supabase/functions/admin-subscriptions/index.ts
new file mode 100644
index 0000000..46c6ae2
--- /dev/null
+++ b/server/supabase/functions/admin-subscriptions/index.ts
@@ -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 | Record[], 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,
+ memo: body.memo,
+ })
+
+ return jsonResponse({ success: true, subscription: created as unknown as Record }, 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 = { 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,
+ afterData: after as unknown as Record,
+ memo: body.memo,
+ })
+
+ return jsonResponse({ success: true, subscription: after as unknown as Record })
+ }
+
+ // ── 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,
+ 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)
+ }
+})
diff --git a/server/supabase/functions/admin-users/index.ts b/server/supabase/functions/admin-users/index.ts
new file mode 100644
index 0000000..7fdc031
--- /dev/null
+++ b/server/supabase/functions/admin-users/index.ts
@@ -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 | Record[], 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)
+ }
+})
diff --git a/server/supabase/migrations/20260413000004_admin_enhancement.sql b/server/supabase/migrations/20260413000004_admin_enhancement.sql
new file mode 100644
index 0000000..5de8e90
--- /dev/null
+++ b/server/supabase/migrations/20260413000004_admin_enhancement.sql
@@ -0,0 +1,138 @@
+-- ============================================================================
+-- Phase V2-6: Admin CRM 고도화 — 권한 확장 + 감사로그 + admin_note
+-- ============================================================================
+
+-- ----------------------------------------------------------------------------
+-- 1. profiles.role CHECK 확장: super_admin 추가
+-- ----------------------------------------------------------------------------
+ALTER TABLE public.profiles DROP CONSTRAINT IF EXISTS profiles_role_check;
+ALTER TABLE public.profiles
+ ADD CONSTRAINT profiles_role_check
+ CHECK (role IN ('user', 'admin', 'super_admin'));
+
+-- ----------------------------------------------------------------------------
+-- 2. audit_log 테이블 — 관리자 작업 감사 기록 (before/after diff 포함)
+-- ----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS public.audit_log (
+ id bigserial PRIMARY KEY,
+ admin_id uuid NOT NULL REFERENCES auth.users(id),
+ action text NOT NULL,
+ target_type text NOT NULL,
+ target_id uuid NOT NULL,
+ before_data jsonb,
+ after_data jsonb,
+ memo text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_audit_log_target ON public.audit_log(target_type, target_id);
+CREATE INDEX IF NOT EXISTS idx_audit_log_admin ON public.audit_log(admin_id);
+CREATE INDEX IF NOT EXISTS idx_audit_log_date ON public.audit_log(created_at DESC);
+
+-- audit_log RLS
+ALTER TABLE public.audit_log ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "admin_read_audit_log" ON public.audit_log
+ FOR SELECT TO authenticated
+ USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'));
+
+-- INSERT는 service_role만 (Edge Function에서 기록)
+
+-- ----------------------------------------------------------------------------
+-- 3. subscriptions.admin_note 컬럼
+-- ----------------------------------------------------------------------------
+ALTER TABLE public.subscriptions
+ ADD COLUMN IF NOT EXISTS admin_note text;
+
+-- ----------------------------------------------------------------------------
+-- 4. 기존 RLS 정책 업데이트 — admin OR super_admin
+-- ----------------------------------------------------------------------------
+DROP POLICY IF EXISTS "admin_read_all_profiles" ON public.profiles;
+CREATE POLICY "admin_read_all_profiles" ON public.profiles
+ FOR SELECT TO authenticated
+ USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'));
+
+DROP POLICY IF EXISTS "admin_read_all_subscriptions" ON public.subscriptions;
+CREATE POLICY "admin_read_all_subscriptions" ON public.subscriptions
+ FOR SELECT TO authenticated
+ USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'));
+
+DROP POLICY IF EXISTS "admin_read_all_daily_usage" ON public.daily_usage;
+CREATE POLICY "admin_read_all_daily_usage" ON public.daily_usage
+ FOR SELECT TO authenticated
+ USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'));
+
+-- subscriptions: super_admin 쓰기 정책
+DROP POLICY IF EXISTS "super_admin_write_subscriptions" ON public.subscriptions;
+CREATE POLICY "super_admin_write_subscriptions" ON public.subscriptions
+ FOR ALL TO authenticated
+ USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'super_admin')
+ WITH CHECK ((auth.jwt() -> 'app_metadata' ->> 'role') = 'super_admin');
+
+-- profiles: super_admin이 role 컬럼 수정 가능
+DROP POLICY IF EXISTS "super_admin_update_profiles" ON public.profiles;
+CREATE POLICY "super_admin_update_profiles" ON public.profiles
+ FOR UPDATE TO authenticated
+ USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'super_admin')
+ WITH CHECK ((auth.jwt() -> 'app_metadata' ->> 'role') = 'super_admin');
+
+-- ----------------------------------------------------------------------------
+-- 5. 통계 RPC 함수 — admin 전용
+-- ----------------------------------------------------------------------------
+
+-- 5.1 일별 feature 집계
+CREATE OR REPLACE FUNCTION public.admin_usage_by_feature(
+ p_from date, p_to date
+) RETURNS TABLE(date date, feature text, total_count bigint, unique_users bigint)
+LANGUAGE sql SECURITY DEFINER STABLE
+SET search_path = public
+AS $$
+ SELECT du.date, du.feature,
+ SUM(du.count)::bigint AS total_count,
+ COUNT(DISTINCT du.user_id)::bigint AS unique_users
+ FROM public.daily_usage du
+ WHERE du.date BETWEEN p_from AND p_to
+ GROUP BY du.date, du.feature
+ ORDER BY du.date, du.feature;
+$$;
+
+REVOKE ALL ON FUNCTION public.admin_usage_by_feature(date, date) FROM public;
+GRANT EXECUTE ON FUNCTION public.admin_usage_by_feature(date, date) TO authenticated;
+
+-- 5.2 유저별 사용량 랭킹
+CREATE OR REPLACE FUNCTION public.admin_top_users(
+ p_from date, p_to date, p_limit integer DEFAULT 20
+) RETURNS TABLE(user_id uuid, name text, total_count bigint, feature_count bigint)
+LANGUAGE sql SECURITY DEFINER STABLE
+SET search_path = public
+AS $$
+ SELECT du.user_id, p.name,
+ SUM(du.count)::bigint AS total_count,
+ COUNT(DISTINCT du.feature)::bigint AS feature_count
+ FROM public.daily_usage du
+ JOIN public.profiles p ON p.id = du.user_id
+ WHERE du.date BETWEEN p_from AND p_to
+ GROUP BY du.user_id, p.name
+ ORDER BY total_count DESC
+ LIMIT p_limit;
+$$;
+
+REVOKE ALL ON FUNCTION public.admin_top_users(date, date, integer) FROM public;
+GRANT EXECUTE ON FUNCTION public.admin_top_users(date, date, integer) TO authenticated;
+
+-- 5.3 DAU 추이
+CREATE OR REPLACE FUNCTION public.admin_dau(
+ p_from date, p_to date
+) RETURNS TABLE(date date, active_users bigint)
+LANGUAGE sql SECURITY DEFINER STABLE
+SET search_path = public
+AS $$
+ SELECT du.date, COUNT(DISTINCT du.user_id)::bigint AS active_users
+ FROM public.daily_usage du
+ WHERE du.date BETWEEN p_from AND p_to
+ GROUP BY du.date
+ ORDER BY du.date;
+$$;
+
+REVOKE ALL ON FUNCTION public.admin_dau(date, date) FROM public;
+GRANT EXECUTE ON FUNCTION public.admin_dau(date, date) TO authenticated;