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()} + + + ))} + + + + + DATEADMINACTIONTARGETMEMODETAIL + + {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 + ) : ( + + + DATEACTIONMEMODETAIL + + {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' }, }}> - USERTIERSTATUSPROVIDEREXPIRESCANCELFAILS + USERTIERSTATUSPROVIDEREXPIRESCANCELFAILSEDIT {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' }, }}> - DATEFEATURECALLS + DATEFEATURECALLSUNIQUE 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 ( + + + {title} + + + {description && ( + + {description} + + )} + setMemo(e.target.value)} + sx={{ + mt: 1, + '& .MuiInputBase-root': { + fontFamily: d3roFontMono, + fontSize: 13, + color: d3roPalette.text.primary, + bgcolor: d3roPalette.bg.inset, + }, + }} + /> + + + + + + + ) +} 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 ( + + + CHANGE USER ROLE + + + + {userName ?? userId.substring(0, 8)} : {currentRole} + + + + New Role + + + + setMemo(e.target.value)} + sx={{ + '& .MuiInputBase-root': { + fontFamily: d3roFontMono, + fontSize: 13, + color: d3roPalette.text.primary, + bgcolor: d3roPalette.bg.inset, + }, + }} + /> + + {error && ( + + {error} + + )} + + + + + + + ) +} 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;