d3ro-voice/server/supabase/migrations/20260413000004_admin_enhancement.sql
윤찬 dca1b90faa feat(admin): Phase V2-6 Admin CRM 고도화 — CRUD + 차트 + 결제 + 감사
- DB: audit_log 테이블(diff 포함) + subscriptions.admin_note + super_admin role
- Edge Functions 4개: admin-users, admin-subscriptions, admin-payments, admin-audit-log
- 공유 유틸: admin-auth.ts(권한 검증), audit.ts(감사로그 기록)
- Swagger UI: 독립 정적 페이지 + OpenAPI 3.0 spec
- CRUD 페이지: 구독 생성/수정/삭제, role 변경, 감사로그 목록/상세
- recharts: feature별 StackedBar + DAU Line + Top Users HorizontalBar
- 결제 이력: DB + Payple API 병행 조회
- 권한: super_admin만 위험 작업, admin은 조회 전용
- RLS: admin/super_admin IN 정책 + super_admin 쓰기 정책
- SQL RPC: admin_usage_by_feature, admin_top_users, admin_dau
2026-04-12 21:32:47 +09:00

138 lines
5.9 KiB
PL/PgSQL

-- ============================================================================
-- 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;