From 667c09242bc14ef8fa3a8200b86838fe09f8ccad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=B0=AC?= Date: Sun, 12 Apr 2026 20:24:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(web):=20=EA=B4=80=EB=A6=AC=EC=9E=90=20CRM?= =?UTF-8?q?=20=EC=9B=B9=ED=8E=98=EC=9D=B4=EC=A7=80=20=E2=80=94=20admin=20r?= =?UTF-8?q?ole=20+=204=EA=B0=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration: profiles.role 컬럼 + admin RLS 정책 - admin-guard.ts: RSC용 admin 권한 체크 - /admin: CRM 대시보드 (총 유저, 유료 구독자, API 호출, 만료 예정) - /admin/users: 유저 목록 (검색/필터/페이지네이션) - /admin/users/[id]: 유저 상세 (프로필+구독+30일 사용량) - /admin/subscriptions: 구독 목록 (상태 필터) - /admin/usage: 사용량 집계 (7/14/30일) - Sidebar에 Admin 네비게이션 추가 --- apps/web/src/app/(app)/admin/admin-nav.tsx | 56 +++++ apps/web/src/app/(app)/admin/layout.tsx | 27 +++ apps/web/src/app/(app)/admin/page.tsx | 61 +++++ .../app/(app)/admin/subscriptions/page.tsx | 127 +++++++++++ apps/web/src/app/(app)/admin/usage/page.tsx | 145 ++++++++++++ .../src/app/(app)/admin/users/[id]/page.tsx | 120 ++++++++++ apps/web/src/app/(app)/admin/users/page.tsx | 210 ++++++++++++++++++ apps/web/src/components/layout/sidebar.tsx | 7 + apps/web/src/lib/admin-guard.ts | 32 +++ .../migrations/20260413000002_admin_role.sql | 31 +++ 10 files changed, 816 insertions(+) create mode 100644 apps/web/src/app/(app)/admin/admin-nav.tsx create mode 100644 apps/web/src/app/(app)/admin/layout.tsx create mode 100644 apps/web/src/app/(app)/admin/page.tsx create mode 100644 apps/web/src/app/(app)/admin/subscriptions/page.tsx create mode 100644 apps/web/src/app/(app)/admin/usage/page.tsx create mode 100644 apps/web/src/app/(app)/admin/users/[id]/page.tsx create mode 100644 apps/web/src/app/(app)/admin/users/page.tsx create mode 100644 apps/web/src/lib/admin-guard.ts create mode 100644 server/supabase/migrations/20260413000002_admin_role.sql diff --git a/apps/web/src/app/(app)/admin/admin-nav.tsx b/apps/web/src/app/(app)/admin/admin-nav.tsx new file mode 100644 index 0000000..058f21e --- /dev/null +++ b/apps/web/src/app/(app)/admin/admin-nav.tsx @@ -0,0 +1,56 @@ +'use client' + +// apps/web/src/app/(app)/admin/admin-nav.tsx +// Admin 하위 네비게이션 탭 + +import { usePathname, useRouter } from 'next/navigation' +import { Box } from '@mui/material' +import { PhosphorText } from '@d3ro/ui/components/ds' +import { d3roPalette } from '@d3ro/ui/theme' + +interface NavTab { + path: string + label: string +} + +const TABS: NavTab[] = [ + { path: '/admin', label: 'OVERVIEW' }, + { path: '/admin/users', label: 'USERS' }, + { path: '/admin/subscriptions', label: 'SUBSCRIPTIONS' }, + { path: '/admin/usage', label: 'USAGE' }, +] + +export function AdminNav(): React.ReactElement { + const pathname = usePathname() + const router = useRouter() + + return ( + + {TABS.map((tab) => { + const active = tab.path === '/admin' + ? pathname === '/admin' + : pathname.startsWith(tab.path) + return ( + router.push(tab.path)} + sx={{ + px: 2, + py: 0.75, + cursor: 'pointer', + borderRadius: 1, + bgcolor: active ? d3roPalette.bg.inset : 'transparent', + borderBottom: active ? `2px solid ${d3roPalette.accent.amber}` : '2px solid transparent', + transition: 'all 0.15s ease', + '&:hover': { bgcolor: d3roPalette.bg.inset }, + }} + > + + {tab.label} + + + ) + })} + + ) +} diff --git a/apps/web/src/app/(app)/admin/layout.tsx b/apps/web/src/app/(app)/admin/layout.tsx new file mode 100644 index 0000000..ad4e468 --- /dev/null +++ b/apps/web/src/app/(app)/admin/layout.tsx @@ -0,0 +1,27 @@ +// apps/web/src/app/(app)/admin/layout.tsx +// Admin 전용 레이아웃 — requireAdmin() 가드 적용 + +import { Box } from '@mui/material' +import { PhosphorText } from '@d3ro/ui/components/ds' +import { d3roPalette } from '@d3ro/ui/theme' +import { requireAdmin } from '@/lib/admin-guard' +import { AdminNav } from './admin-nav' + +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode +}): Promise { + await requireAdmin() + + return ( + + + ADMIN + + + + {children} + + ) +} diff --git a/apps/web/src/app/(app)/admin/page.tsx b/apps/web/src/app/(app)/admin/page.tsx new file mode 100644 index 0000000..c23f030 --- /dev/null +++ b/apps/web/src/app/(app)/admin/page.tsx @@ -0,0 +1,61 @@ +// apps/web/src/app/(app)/admin/page.tsx +// Admin CRM 대시보드 — 요약 카드 4개 + +import { Box, Grid } from '@mui/material' +import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' +import { d3roPalette } from '@d3ro/ui/theme' +import { getSupabaseServerClient } from '@/lib/supabase-server' + +interface StatCard { + label: string + value: string | number + color?: string +} + +async function loadStats(): Promise { + const supabase = await getSupabaseServerClient() + + const [profilesRes, paidRes, usageRes, expiringRes] = await Promise.all([ + supabase.from('profiles').select('id', { count: 'exact', head: true }), + supabase.from('subscriptions').select('id', { count: 'exact', head: true }) + .neq('tier', 'free').eq('status', 'active'), + supabase.from('daily_usage').select('count') + .eq('date', new Date().toISOString().split('T')[0]), + supabase.from('subscriptions').select('id', { count: 'exact', head: true }) + .eq('status', 'active').eq('payment_provider', 'payple') + .lte('current_period_end', new Date(Date.now() + 7 * 86400000).toISOString()), + ]) + + const todayUsage = (usageRes.data as Array<{ count: number }> | null) + ?.reduce((sum, r) => sum + (r.count ?? 0), 0) ?? 0 + + return [ + { label: 'TOTAL USERS', value: profilesRes.count ?? 0 }, + { label: 'PAID SUBSCRIBERS', value: paidRes.count ?? 0, color: d3roPalette.tag.green }, + { label: 'TODAY API CALLS', value: todayUsage, color: d3roPalette.accent.amber }, + { label: 'EXPIRING (7D)', value: expiringRes.count ?? 0, color: d3roPalette.tag.red }, + ] +} + +export default async function AdminPage(): Promise { + const stats = await loadStats() + + return ( + + {stats.map((stat) => ( + + + + + {stat.label} + + + {stat.value} + + + + + ))} + + ) +} diff --git a/apps/web/src/app/(app)/admin/subscriptions/page.tsx b/apps/web/src/app/(app)/admin/subscriptions/page.tsx new file mode 100644 index 0000000..a8e9d93 --- /dev/null +++ b/apps/web/src/app/(app)/admin/subscriptions/page.tsx @@ -0,0 +1,127 @@ +// apps/web/src/app/(app)/admin/subscriptions/page.tsx +// Admin 구독 목록 — active/canceled/past_due/expired 필터 + +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 Link from 'next/link' + +interface SubRow { + id: string + user_id: string + tier: string + status: string + payment_provider: string + current_period_end: string | null + cancel_at: string | null + renewal_failures: number + profile_name: string | null +} + +interface PageProps { + searchParams: Promise<{ status?: string }> +} + +export default async function AdminSubscriptionsPage({ searchParams }: PageProps): Promise { + const params = await searchParams + const statusFilter = params.status ?? 'all' + const supabase = await getSupabaseServerClient() + + const subQuery = supabase + .from('subscriptions') + .select('id, user_id, tier, status, payment_provider, current_period_end, cancel_at') + .order('current_period_end', { ascending: true }) + .limit(100) + + if (statusFilter !== 'all') { + subQuery.eq('status', statusFilter) + } + + const { data: rawSubs } = await subQuery + const rawSubsArr = (rawSubs ?? []) as Array> + + // profiles name 조회 + const userIds = rawSubsArr.map((s) => s.user_id as string) + const { data: rawProfiles } = userIds.length > 0 + ? await supabase.from('profiles').select('id, name').in('id', userIds) + : { data: [] } + const profileMap = new Map( + ((rawProfiles ?? []) as Array>).map((p) => [p.id as string, (p.name as string) ?? null]) + ) + + const subs: SubRow[] = rawSubsArr.map((row) => ({ + id: row.id as string, + user_id: row.user_id as string, + tier: (row.tier as string) ?? 'free', + status: (row.status as string) ?? 'unknown', + payment_provider: (row.payment_provider as string) ?? 'none', + current_period_end: row.current_period_end as string | null, + cancel_at: row.cancel_at as string | null, + renewal_failures: (row.renewal_failures as number | undefined) ?? 0, + profile_name: profileMap.get(row.user_id as string) ?? null, + })) + + return ( + + + {['all', 'active', 'canceled', 'past_due', 'expired'].map((s) => ( + + + {s.toUpperCase().replace('_', ' ')} + + + ))} + + + + + + USERTIERSTATUSPROVIDEREXPIRESCANCELFAILS + + + {subs.map((s) => ( + + + + {s.profile_name ?? s.user_id.substring(0, 8)} + + + + {s.tier === 'pro_plus' ? 'PRO+' : s.tier.toUpperCase()} + + + {s.status.toUpperCase()} + + {s.payment_provider} + + {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} + + + ))} + + + + + ) +} diff --git a/apps/web/src/app/(app)/admin/usage/page.tsx b/apps/web/src/app/(app)/admin/usage/page.tsx new file mode 100644 index 0000000..be13951 --- /dev/null +++ b/apps/web/src/app/(app)/admin/usage/page.tsx @@ -0,0 +1,145 @@ +// apps/web/src/app/(app)/admin/usage/page.tsx +// Admin 사용량 집계 — feature별, 날짜 범위 + +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' + +interface UsageSummary { + feature: string + total: number + uniqueUsers: number +} + +interface DailyRow { + date: string + feature: string + total: number +} + +interface PageProps { + searchParams: Promise<{ days?: string }> +} + +export default async function AdminUsagePage({ searchParams }: PageProps): Promise { + const params = await searchParams + const days = parseInt(params.days ?? '7', 10) + const since = new Date(Date.now() - days * 86400000).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 }> + + // Feature별 집계 + const featureMap = new Map }>() + for (const r of rows) { + const entry = featureMap.get(r.feature) ?? { total: 0, users: new Set() } + entry.total += r.count + entry.users.add(r.user_id) + featureMap.set(r.feature, entry) + } + + const summaries: UsageSummary[] = 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: DailyRow[] = [] + for (const [date, features] of dailyMap) { + for (const [feature, total] of features) { + dailyRows.push({ date, feature, total }) + } + } + + return ( + + {/* Period Filter */} + + {[7, 14, 30].map((d) => ( + + + {d}D + + + ))} + + + {/* Feature Summaries */} + + {summaries.map((s) => ( + + + + + {s.feature.toUpperCase()} + + + {s.total.toLocaleString()} + + + {s.uniqueUsers} users + + + + + ))} + + + {/* Daily Detail Table */} + + + DAILY BREAKDOWN + + + DATEFEATURECALLS + + + {dailyRows.map((r, i) => ( + + {r.date} + {r.feature} + {r.total.toLocaleString()} + + ))} + {dailyRows.length === 0 && ( + + + No usage data + + + )} + + + + + + ) +} diff --git a/apps/web/src/app/(app)/admin/users/[id]/page.tsx b/apps/web/src/app/(app)/admin/users/[id]/page.tsx new file mode 100644 index 0000000..510639a --- /dev/null +++ b/apps/web/src/app/(app)/admin/users/[id]/page.tsx @@ -0,0 +1,120 @@ +// apps/web/src/app/(app)/admin/users/[id]/page.tsx +// Admin 유저 상세 — 프로필 + 구독 + 30일 사용량 + +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 { notFound } from 'next/navigation' + +interface PageProps { + params: Promise<{ id: string }> +} + +export default async function AdminUserDetailPage({ params }: PageProps): Promise { + const { id } = await params + const supabase = await getSupabaseServerClient() + + const [profileRes, subRes, usageRes] = await Promise.all([ + supabase.from('profiles').select('*').eq('id', id).maybeSingle(), + supabase.from('subscriptions').select('*').eq('user_id', id).maybeSingle(), + supabase.from('daily_usage').select('*') + .eq('user_id', id) + .gte('date', new Date(Date.now() - 30 * 86400000).toISOString().split('T')[0]) + .order('date', { ascending: false }), + ]) + + const profile = profileRes.data as Record | null + if (!profile) notFound() + + const sub = subRes.data as Record | null + const usage = (usageRes.data ?? []) as Array> + + const tier = (profile.tier as string) ?? 'free' + const tierColor = tier === 'pro_plus' ? d3roPalette.tag.purple : tier === 'pro' ? d3roPalette.tag.green : d3roPalette.accent.amber + + return ( + + {/* Profile Card */} + + + + + PROFILE + + + + + + + + + + + + + + + + SUBSCRIPTION + {sub ? ( + + + + + + + + + ) : ( + No subscription + )} + + + + + + {/* Usage (30 days) */} + + + USAGE (30 DAYS) + {usage.length === 0 ? ( + No usage data + ) : ( + + + DATEFEATURECOUNT + + + {usage.map((row, i) => ( + + {row.date as string} + {row.feature as string} + {row.count as number} + + ))} + + + )} + + + + ) +} + +function Row({ label, value, valueColor }: { label: string; value: string; valueColor?: string }): React.ReactElement { + return ( + + {label} + {value} + + ) +} diff --git a/apps/web/src/app/(app)/admin/users/page.tsx b/apps/web/src/app/(app)/admin/users/page.tsx new file mode 100644 index 0000000..945f1b0 --- /dev/null +++ b/apps/web/src/app/(app)/admin/users/page.tsx @@ -0,0 +1,210 @@ +// apps/web/src/app/(app)/admin/users/page.tsx +// Admin 유저 목록 — profiles JOIN subscriptions, 검색/필터/페이지네이션 + +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 Link from 'next/link' + +const PAGE_SIZE = 20 + +interface UserRow { + id: string + name: string | null + email: string | null + tier: string + role: string + created_at: string + subscription_status: string | null + payment_provider: string | null +} + +async function loadUsers(page: number, search: string, tierFilter: string): Promise<{ users: UserRow[]; total: number }> { + const supabase = await getSupabaseServerClient() + + // profiles 조회 (DB 타입에 role/조인이 미정의이므로 raw cast) + const from = page * PAGE_SIZE + const to = from + PAGE_SIZE - 1 + + // 단순 profiles 조회 + 별도 subscriptions 조회 + const profileQuery = supabase + .from('profiles') + .select('id, name, tier, created_at', { count: 'exact' }) + .order('created_at', { ascending: false }) + .range(from, to) + + if (search) { + profileQuery.ilike('name', `%${search}%`) + } + + if (tierFilter && tierFilter !== 'all') { + profileQuery.eq('tier', tierFilter as 'free' | 'pro' | 'pro_plus') + } + + const { data: rawProfiles, count } = await profileQuery + const profiles = (rawProfiles ?? []) as Array> + + // 해당 유저들의 구독 정보 조회 + const userIds = profiles.map((p) => p.id as string) + const { data: rawSubs } = userIds.length > 0 + ? await supabase.from('subscriptions').select('user_id, status, payment_provider').in('user_id', userIds) + : { data: [] } + const subs = (rawSubs ?? []) as Array> + const subMap = new Map(subs.map((s) => [s.user_id as string, s])) + + // role 조회 (별도 raw query — DB 타입에 role 미정의) + const { data: rawRoles } = userIds.length > 0 + ? await supabase.from('profiles').select('id, role').in('id', userIds) + : { data: [] } + const roles = (rawRoles ?? []) as Array> + const roleMap = new Map(roles.map((r) => [r.id as string, (r.role as string) ?? 'user'])) + + const users: UserRow[] = profiles.map((row) => { + const sub = subMap.get(row.id as string) + return { + id: row.id as string, + name: row.name as string | null, + email: null, + tier: (row.tier as string) ?? 'free', + role: roleMap.get(row.id as string) ?? 'user', + created_at: row.created_at as string, + subscription_status: (sub?.status as string) ?? null, + payment_provider: (sub?.payment_provider as string) ?? null, + } + }) + + return { users, total: count ?? 0 } +} + +interface PageProps { + searchParams: Promise<{ page?: string; search?: string; tier?: string }> +} + +export default async function AdminUsersPage({ searchParams }: PageProps): Promise { + const params = await searchParams + const page = parseInt(params.page ?? '0', 10) + const search = params.search ?? '' + const tierFilter = params.tier ?? 'all' + const { users, total } = await loadUsers(page, search, tierFilter) + const totalPages = Math.ceil(total / PAGE_SIZE) + + return ( + + {/* Filters */} + + + {total} USERS + + + {['all', 'free', 'pro', 'pro_plus'].map((t) => ( + + + {t === 'all' ? 'ALL' : t === 'pro_plus' ? 'PRO+' : t.toUpperCase()} + + + ))} + + + {/* Table */} + + + + + NAME + TIER + ROLE + STATUS + PROVIDER + JOINED + + + + {users.map((u) => ( + + + + {u.name ?? u.id.substring(0, 8)} + + + + + + + {u.role.toUpperCase()} + + + {u.subscription_status?.toUpperCase() ?? '-'} + + + {u.payment_provider ?? '-'} + + + {new Date(u.created_at).toLocaleDateString()} + + + ))} + {users.length === 0 && ( + + + No users found + + + )} + + + + + {/* Pagination */} + {totalPages > 1 && ( + + {Array.from({ length: Math.min(totalPages, 10) }, (_, i) => ( + + + {i + 1} + + + ))} + + )} + + ) +} + +function TierBadge({ tier }: { tier: string }): React.ReactElement { + const color = tier === 'pro_plus' ? d3roPalette.tag.purple : tier === 'pro' ? d3roPalette.tag.green : d3roPalette.text.secondary + const label = tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase() + return {label} +} diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index dd67355..2b2e2bb 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -25,6 +25,7 @@ import LibraryBooksIcon from '@mui/icons-material/LibraryBooks' import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' import GroupsIcon from '@mui/icons-material/Groups' import PaymentIcon from '@mui/icons-material/Payment' +import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings' import LogoutIcon from '@mui/icons-material/Logout' import PaletteIcon from '@mui/icons-material/Palette' import type { ThemeMode } from '@d3ro/core/types' @@ -99,6 +100,12 @@ export function Sidebar(): React.ReactElement { path: '/billing', label: t('nav.billing') ?? 'Billing', icon: + }, + { + key: 'admin', + path: '/admin', + label: 'Admin', + icon: } ] diff --git a/apps/web/src/lib/admin-guard.ts b/apps/web/src/lib/admin-guard.ts new file mode 100644 index 0000000..4bb913e --- /dev/null +++ b/apps/web/src/lib/admin-guard.ts @@ -0,0 +1,32 @@ +// apps/web/src/lib/admin-guard.ts +// RSC용 admin 가드 — profile.role='admin' 체크, 실패 시 redirect + +import { redirect } from 'next/navigation' +import { getSupabaseServerClient } from './supabase-server' + +interface AdminProfile { + id: string + name: string | null + role: string +} + +export async function requireAdmin(): Promise { + const supabase = await getSupabaseServerClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + redirect('/login') + } + + const { data: profile } = await supabase + .from('profiles') + .select('id, name, role') + .eq('id', user.id) + .maybeSingle() + + if (!profile || (profile as { role: string }).role !== 'admin') { + redirect('/dashboard') + } + + return profile as AdminProfile +} diff --git a/server/supabase/migrations/20260413000002_admin_role.sql b/server/supabase/migrations/20260413000002_admin_role.sql new file mode 100644 index 0000000..a8ed943 --- /dev/null +++ b/server/supabase/migrations/20260413000002_admin_role.sql @@ -0,0 +1,31 @@ +-- Phase 3.3: 관리자 역할 + RLS 정책 +-- profiles.role 컬럼 추가 + admin 전용 SELECT 정책 + +-- 1. role 컬럼 +ALTER TABLE public.profiles + ADD COLUMN IF NOT EXISTS role text NOT NULL DEFAULT 'user'; + +ALTER TABLE public.profiles + ADD CONSTRAINT profiles_role_check + CHECK (role IN ('user', 'admin')); + +-- 2. Admin RLS: 모든 profiles 조회 +CREATE POLICY "admin_read_all_profiles" ON public.profiles + FOR SELECT TO authenticated + USING ( + EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role = 'admin') + ); + +-- 3. Admin RLS: 모든 subscriptions 조회 +CREATE POLICY "admin_read_all_subscriptions" ON public.subscriptions + FOR SELECT TO authenticated + USING ( + EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role = 'admin') + ); + +-- 4. Admin RLS: 모든 daily_usage 조회 +CREATE POLICY "admin_read_all_daily_usage" ON public.daily_usage + FOR SELECT TO authenticated + USING ( + EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role = 'admin') + );