diff --git a/apps/web/src/app/(dashboard)/admin/layout.tsx b/apps/web/src/app/(dashboard)/admin/layout.tsx new file mode 100644 index 000000000..340605bc7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/layout.tsx @@ -0,0 +1,23 @@ +import { redirect } from 'next/navigation'; + +import { isAdmin } from '@documenso/lib/'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; + +export type AdminLayoutProps = { + children: React.ReactNode; +}; + +export default async function AdminLayout({ children }: AdminLayoutProps) { + const user = await getRequiredServerComponentSession(); + const isUserAdmin = isAdmin(user); + + if (!user) { + redirect('/signin'); + } + + if (!isUserAdmin) { + redirect('/dashboard'); + } + + return
{children}
; +} diff --git a/apps/web/src/app/(dashboard)/admin/page.tsx b/apps/web/src/app/(dashboard)/admin/page.tsx new file mode 100644 index 000000000..e4a62f725 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/page.tsx @@ -0,0 +1,107 @@ +import { + Archive, + File, + FileX2, + LucideIcon, + User as LucideUser, + Mail, + MailOpen, + PenTool, + Send, + UserPlus2, + UserSquare2, +} from 'lucide-react'; + +import { getDocsCount } from '@documenso/lib/server-only/admin/get-documents'; +import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients'; +import { + getUsersCount, + getUsersWithSubscriptionsCount, +} from '@documenso/lib/server-only/admin/get-users'; +import { + ReadStatus as InternalReadStatus, + SendStatus as InternalSendStatus, + SigningStatus as InternalSigningStatus, +} from '@documenso/prisma/client'; + +import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card'; + +type TCardData = { + icon: LucideIcon; + title: string; + status: + | 'TOTAL_RECIPIENTS' + | 'OPENED' + | 'NOT_OPENED' + | 'SIGNED' + | 'NOT_SIGNED' + | 'SENT' + | 'NOT_SENT'; +}[]; + +const CARD_DATA: TCardData = [ + { + icon: UserSquare2, + title: 'Total recipients in the database', + status: 'TOTAL_RECIPIENTS', + }, + { + icon: MailOpen, + title: 'Total recipients with opened count', + status: InternalReadStatus.OPENED, + }, + { + icon: Mail, + title: 'Total recipients with unopened count', + status: InternalReadStatus.NOT_OPENED, + }, + { + icon: Send, + title: 'Total recipients with sent count', + status: InternalSendStatus.SENT, + }, + { + icon: Archive, + title: 'Total recipients with unsent count', + status: InternalSendStatus.NOT_SENT, + }, + { + icon: PenTool, + title: 'Total recipients with signed count', + status: InternalSigningStatus.SIGNED, + }, + { + icon: FileX2, + title: 'Total recipients with unsigned count', + status: InternalSigningStatus.NOT_SIGNED, + }, +]; + +export default async function Admin() { + const [usersCount, usersWithSubscriptionsCount, docsCount, recipientsStats] = await Promise.all([ + getUsersCount(), + getUsersWithSubscriptionsCount(), + getDocsCount(), + getRecipientsStats(), + ]); + + return ( +
+

Documenso instance metrics

+
+ + + + {CARD_DATA.map((card) => ( +
+ +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx index 19a15564b..0bea64565 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -38,7 +38,7 @@ export type ProfileDropdownProps = { export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { const { theme, setTheme } = useTheme(); const { getFlag } = useFeatureFlags(); - const userIsAdmin = isAdmin(user); + const isUserAdmin = isAdmin(user); const isBillingEnabled = getFlag('app_billing'); @@ -69,7 +69,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { - {userIsAdmin && ( + {isUserAdmin && ( diff --git a/packages/lib/server-only/admin/get-documents.ts b/packages/lib/server-only/admin/get-documents.ts new file mode 100644 index 000000000..9100a886c --- /dev/null +++ b/packages/lib/server-only/admin/get-documents.ts @@ -0,0 +1,5 @@ +import { prisma } from '@documenso/prisma'; + +export const getDocsCount = async () => { + return await prisma.document.count(); +}; diff --git a/packages/lib/server-only/admin/get-recipients.ts b/packages/lib/server-only/admin/get-recipients.ts new file mode 100644 index 000000000..0be612e55 --- /dev/null +++ b/packages/lib/server-only/admin/get-recipients.ts @@ -0,0 +1,20 @@ +import { prisma } from '@documenso/prisma'; +import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client'; + +export const getRecipientsStats = async () => { + const results = await prisma.recipient.groupBy({ + by: ['readStatus', 'signingStatus', 'sendStatus'], + _count: true, + }); + + return { + TOTAL_RECIPIENTS: results.length, + [ReadStatus.OPENED]: results.filter((r) => r.readStatus === 'OPENED')?.[0]?._count ?? 0, + [ReadStatus.NOT_OPENED]: results.filter((r) => r.readStatus === 'NOT_OPENED')?.[0]?._count ?? 0, + [SigningStatus.SIGNED]: results.filter((r) => r.signingStatus === 'SIGNED')?.[0]?._count ?? 0, + [SigningStatus.NOT_SIGNED]: + results.filter((r) => r.signingStatus === 'NOT_SIGNED')?.[0]?._count ?? 0, + [SendStatus.SENT]: results.filter((r) => r.sendStatus === 'SENT')?.[0]?._count ?? 0, + [SendStatus.NOT_SENT]: results.filter((r) => r.sendStatus === 'NOT_SENT')?.[0]?._count ?? 0, + }; +}; diff --git a/packages/lib/server-only/admin/get-users.ts b/packages/lib/server-only/admin/get-users.ts new file mode 100644 index 000000000..09892171a --- /dev/null +++ b/packages/lib/server-only/admin/get-users.ts @@ -0,0 +1,18 @@ +import { prisma } from '@documenso/prisma'; +import { SubscriptionStatus } from '@documenso/prisma/client'; + +export const getUsersCount = async () => { + return await prisma.user.count(); +}; + +export const getUsersWithSubscriptionsCount = async () => { + return await prisma.user.count({ + where: { + Subscription: { + some: { + status: SubscriptionStatus.ACTIVE, + }, + }, + }, + }); +};