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