diff --git a/apps/remix/app/components/filters/date-range-filter.tsx b/apps/remix/app/components/filters/date-range-filter.tsx new file mode 100644 index 000000000..aa79774bd --- /dev/null +++ b/apps/remix/app/components/filters/date-range-filter.tsx @@ -0,0 +1,49 @@ +import { useTransition } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import type { DateRange } from '@documenso/lib/types/search-params'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; + +type DateRangeFilterProps = { + currentRange: DateRange; +}; + +export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => { + const { _ } = useLingui(); + const [isPending, startTransition] = useTransition(); + const updateSearchParams = useUpdateSearchParams(); + + const handleRangeChange = (value: string) => { + startTransition(() => { + updateSearchParams({ + dateRange: value as DateRange, + page: 1, + }); + }); + }; + + return ( +
+ +
+ ); +}; diff --git a/apps/remix/app/components/tables/admin-leaderboard-table.tsx b/apps/remix/app/components/tables/admin-organisation-overview-table.tsx similarity index 74% rename from apps/remix/app/components/tables/admin-leaderboard-table.tsx rename to apps/remix/app/components/tables/admin-organisation-overview-table.tsx index eca409eb9..eb38e04f4 100644 --- a/apps/remix/app/components/tables/admin-leaderboard-table.tsx +++ b/apps/remix/app/components/tables/admin-organisation-overview-table.tsx @@ -2,40 +2,49 @@ import { useEffect, useMemo, useState, useTransition } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react'; +import { Link } from 'react-router'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import type { DateRange } from '@documenso/lib/types/search-params'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { Input } from '@documenso/ui/primitives/input'; -export type SigningVolume = { - id: number; +export type OrganisationOverview = { + id: string; name: string; signingVolume: number; createdAt: Date; - planId: string; + customerId: string; + subscriptionStatus?: string; + isActive?: boolean; + teamCount?: number; + memberCount?: number; }; -type LeaderboardTableProps = { - signingVolume: SigningVolume[]; +type OrganisationOverviewTableProps = { + organisations: OrganisationOverview[]; totalPages: number; perPage: number; page: number; sortBy: 'name' | 'createdAt' | 'signingVolume'; sortOrder: 'asc' | 'desc'; + dateRange: DateRange; }; -export const AdminLeaderboardTable = ({ - signingVolume, +export const AdminOrganisationOverviewTable = ({ + organisations, totalPages, perPage, page, sortBy, sortOrder, -}: LeaderboardTableProps) => { + dateRange, +}: OrganisationOverviewTableProps) => { const { _, i18n } = useLingui(); const [isPending, startTransition] = useTransition(); @@ -67,17 +76,16 @@ export const AdminLeaderboardTable = ({ cell: ({ row }) => { return (
- {row.getValue('name')} - +
); }, - size: 250, + size: 240, }, { header: () => ( @@ -85,7 +93,9 @@ export const AdminLeaderboardTable = ({ className="flex cursor-pointer items-center" onClick={() => handleColumnSort('signingVolume')} > - {_(msg`Signing Volume`)} + + Document Volume + {sortBy === 'signingVolume' ? ( sortOrder === 'asc' ? ( @@ -99,6 +109,23 @@ export const AdminLeaderboardTable = ({ ), accessorKey: 'signingVolume', cell: ({ row }) =>
{Number(row.getValue('signingVolume'))}
, + size: 160, + }, + { + header: () => { + return Teams; + }, + accessorKey: 'teamCount', + cell: ({ row }) =>
{Number(row.original.teamCount) || 0}
, + size: 120, + }, + { + header: () => { + return Members; + }, + accessorKey: 'memberCount', + cell: ({ row }) =>
{Number(row.original.memberCount) || 0}
, + size: 160, }, { header: () => { @@ -107,7 +134,9 @@ export const AdminLeaderboardTable = ({ className="flex cursor-pointer items-center" onClick={() => handleColumnSort('createdAt')} > - {_(msg`Created`)} + + Created + {sortBy === 'createdAt' ? ( sortOrder === 'asc' ? ( @@ -121,10 +150,11 @@ export const AdminLeaderboardTable = ({ ); }, accessorKey: 'createdAt', - cell: ({ row }) => i18n.date(row.original.createdAt), + cell: ({ row }) => i18n.date(new Date(row.original.createdAt)), + size: 120, }, - ] satisfies DataTableColumnDef[]; - }, [sortOrder, sortBy]); + ] satisfies DataTableColumnDef[]; + }, [sortOrder, sortBy, dateRange]); useEffect(() => { startTransition(() => { @@ -169,13 +199,13 @@ export const AdminLeaderboardTable = ({ ( {row.original.owner.id === memberUserId ? t`Owner` : t`Member`} ), }, + { + id: 'billingStatus', + header: t`Status`, + cell: ({ row }) => { + const subscription = row.original.subscription; + const isPaid = subscription && subscription.status === 'ACTIVE'; + return ( +
+ {isPaid ? t`Paid` : t`Free`} +
+ ); + }, + }, { header: t`Subscription`, cell: ({ row }) => @@ -168,7 +186,7 @@ export const AdminOrganisationsTable = ({ onPaginationChange={onPaginationChange} columnVisibility={{ owner: showOwnerColumn, - status: memberUserId !== undefined, + role: memberUserId !== undefined, }} error={{ enable: isLoadingError, diff --git a/apps/remix/app/components/tables/organisation-insights-table.tsx b/apps/remix/app/components/tables/organisation-insights-table.tsx new file mode 100644 index 000000000..6304e0b50 --- /dev/null +++ b/apps/remix/app/components/tables/organisation-insights-table.tsx @@ -0,0 +1,287 @@ +import { useTransition } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Building2, Loader, TrendingUp, Users } from 'lucide-react'; +import { Link } from 'react-router'; +import { useNavigation } from 'react-router'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import type { OrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights'; +import type { DateRange } from '@documenso/lib/types/search-params'; +import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; +import { Button } from '@documenso/ui/primitives/button'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; + +import { DateRangeFilter } from '~/components/filters/date-range-filter'; +import { DocumentStatus } from '~/components/general/document/document-status'; + +type OrganisationInsightsTableProps = { + insights: OrganisationDetailedInsights; + page: number; + perPage: number; + dateRange: DateRange; + view: 'teams' | 'users' | 'documents'; +}; + +export const OrganisationInsightsTable = ({ + insights, + page, + perPage, + dateRange, + view, +}: OrganisationInsightsTableProps) => { + const { _, i18n } = useLingui(); + const [isPending, startTransition] = useTransition(); + const navigation = useNavigation(); + const updateSearchParams = useUpdateSearchParams(); + + const isLoading = isPending || navigation.state === 'loading'; + + const onPaginationChange = (newPage: number, newPerPage: number) => { + startTransition(() => { + updateSearchParams({ + page: newPage, + perPage: newPerPage, + }); + }); + }; + + const handleViewChange = (newView: 'teams' | 'users' | 'documents') => { + startTransition(() => { + updateSearchParams({ + view: newView, + page: 1, + }); + }); + }; + + const teamsColumns = [ + { + header: _(msg`Team Name`), + accessorKey: 'name', + cell: ({ row }) => {row.getValue('name')}, + size: 240, + }, + { + header: _(msg`Members`), + accessorKey: 'memberCount', + cell: ({ row }) => Number(row.getValue('memberCount')), + size: 120, + }, + { + header: _(msg`Documents`), + accessorKey: 'documentCount', + cell: ({ row }) => Number(row.getValue('documentCount')), + size: 140, + }, + { + header: _(msg`Created`), + accessorKey: 'createdAt', + cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))), + size: 160, + }, + ] satisfies DataTableColumnDef<(typeof insights.teams)[number]>[]; + + const usersColumns = [ + { + header: () => {_(msg`Name`)}, + accessorKey: 'name', + cell: ({ row }) => ( + + {(row.getValue('name') as string) || (row.getValue('email') as string)} + + ), + size: 220, + }, + { + header: () => {_(msg`Email`)}, + accessorKey: 'email', + cell: ({ row }) => {row.getValue('email')}, + size: 260, + }, + { + header: () => {_(msg`Documents Created`)}, + accessorKey: 'documentCount', + cell: ({ row }) => Number(row.getValue('documentCount')), + size: 180, + }, + { + header: () => {_(msg`Documents Completed`)}, + accessorKey: 'signedDocumentCount', + cell: ({ row }) => Number(row.getValue('signedDocumentCount')), + size: 180, + }, + { + header: () => {_(msg`Joined`)}, + accessorKey: 'createdAt', + cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))), + size: 160, + }, + ] satisfies DataTableColumnDef<(typeof insights.users)[number]>[]; + + const documentsColumns = [ + { + header: () => {_(msg`Title`)}, + accessorKey: 'title', + cell: ({ row }) => ( + + {row.getValue('title')} + + ), + size: 200, + }, + { + header: () => {_(msg`Status`)}, + accessorKey: 'status', + cell: ({ row }) => ( + + ), + size: 120, + }, + { + header: () => {_(msg`Team`)}, + accessorKey: 'teamName', + cell: ({ row }) => ( + + {row.getValue('teamName')} + + ), + size: 150, + }, + { + header: () => {_(msg`Created`)}, + accessorKey: 'createdAt', + cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))), + size: 140, + }, + { + header: () => {_(msg`Completed`)}, + accessorKey: 'completedAt', + cell: ({ row }) => { + const completedAt = row.getValue('completedAt') as Date | null; + + return completedAt ? i18n.date(new Date(completedAt)) : '-'; + }, + size: 140, + }, + ] satisfies DataTableColumnDef<(typeof insights.documents)[number]>[]; + + const getCurrentData = (): unknown[] => { + switch (view) { + case 'teams': + return insights.teams; + case 'users': + return insights.users; + case 'documents': + return insights.documents; + default: + return []; + } + }; + + const getCurrentColumns = (): DataTableColumnDef[] => { + switch (view) { + case 'teams': + return teamsColumns as unknown as DataTableColumnDef[]; + case 'users': + return usersColumns as unknown as DataTableColumnDef[]; + case 'documents': + return documentsColumns as unknown as DataTableColumnDef[]; + default: + return []; + } + }; + + return ( +
+ {insights.summary && ( +
+ + + +
+ )} + +
+
+ + + +
+ +
+ +
+ + columns={getCurrentColumns()} + data={getCurrentData()} + perPage={perPage} + currentPage={page} + totalPages={insights.totalPages} + onPaginationChange={onPaginationChange} + > + {(table) => } + +
+ + {isLoading && ( +
+ +
+ )} +
+ ); +}; + +const SummaryCard = ({ + icon: Icon, + title, + value, + subtitle, +}: { + icon: React.ComponentType<{ className?: string }>; + title: string; + value: number; + subtitle?: string; +}) => ( +
+ +
+

{title}

+

{value}

+ {subtitle &&

{subtitle}

} +
+
+); diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx index 18de42b01..33ef772f5 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx @@ -114,13 +114,13 @@ export default function AdminLayout() { variant="ghost" className={cn( 'justify-start md:w-full', - pathname?.startsWith('/admin/leaderboard') && 'bg-secondary', + pathname?.startsWith('/admin/organisation-insights') && 'bg-secondary', )} asChild > - + - Leaderboard + Organisation Insights @@ -128,7 +128,7 @@ export default function AdminLayout() { variant="ghost" className={cn( 'justify-start md:w-full', - pathname?.startsWith('/admin/banner') && 'bg-secondary', + pathname?.startsWith('/admin/site-settings') && 'bg-secondary', )} asChild > diff --git a/apps/remix/app/routes/_authenticated+/admin+/leaderboard.tsx b/apps/remix/app/routes/_authenticated+/admin+/leaderboard.tsx deleted file mode 100644 index 2a6857e5e..000000000 --- a/apps/remix/app/routes/_authenticated+/admin+/leaderboard.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Trans } from '@lingui/react/macro'; - -import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume'; - -import { - AdminLeaderboardTable, - type SigningVolume, -} from '~/components/tables/admin-leaderboard-table'; - -import type { Route } from './+types/leaderboard'; - -export async function loader({ request }: Route.LoaderArgs) { - const url = new URL(request.url); - - const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume'; - const rawSortOrder = url.searchParams.get('sortOrder') || 'desc'; - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const sortOrder = (['asc', 'desc'].includes(rawSortOrder) ? rawSortOrder : 'desc') as - | 'asc' - | 'desc'; - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const sortBy = ( - ['name', 'createdAt', 'signingVolume'].includes(rawSortBy) ? rawSortBy : 'signingVolume' - ) as 'name' | 'createdAt' | 'signingVolume'; - - const page = Number(url.searchParams.get('page')) || 1; - const perPage = Number(url.searchParams.get('perPage')) || 10; - const search = url.searchParams.get('search') || ''; - - const { leaderboard, totalPages } = await getSigningVolume({ - search, - page, - perPage, - sortBy, - sortOrder, - }); - - const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({ - ...item, - name: item.name || '', - createdAt: item.createdAt || new Date(), - })); - - return { - signingVolume: typedSigningVolume, - totalPages, - page, - perPage, - sortBy, - sortOrder, - }; -} - -export default function Leaderboard({ loaderData }: Route.ComponentProps) { - const { signingVolume, totalPages, page, perPage, sortBy, sortOrder } = loaderData; - - return ( -
-
-

- Signing Volume -

-
-
- -
-
- ); -} diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisation-insights.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisation-insights.$id.tsx new file mode 100644 index 000000000..560c25c3b --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/admin+/organisation-insights.$id.tsx @@ -0,0 +1,59 @@ +import { getOrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights'; +import type { DateRange } from '@documenso/lib/types/search-params'; +import { getAdminOrganisation } from '@documenso/trpc/server/admin-router/get-admin-organisation'; + +import { OrganisationInsightsTable } from '~/components/tables/organisation-insights-table'; + +import type { Route } from './+types/organisation-insights.$id'; + +export async function loader({ params, request }: Route.LoaderArgs) { + const { id } = params; + const url = new URL(request.url); + + const page = Number(url.searchParams.get('page')) || 1; + const perPage = Number(url.searchParams.get('perPage')) || 10; + const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange; + const view = (url.searchParams.get('view') || 'teams') as 'teams' | 'users' | 'documents'; + + const [insights, organisation] = await Promise.all([ + getOrganisationDetailedInsights({ + organisationId: id, + page, + perPage, + dateRange, + view, + }), + getAdminOrganisation({ organisationId: id }), + ]); + + return { + organisationId: id, + organisationName: organisation.name, + insights, + page, + perPage, + dateRange, + view, + }; +} + +export default function OrganisationInsights({ loaderData }: Route.ComponentProps) { + const { insights, page, perPage, dateRange, view, organisationName } = loaderData; + + return ( +
+
+

{organisationName}

+
+
+ +
+
+ ); +} diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisation-insights._index.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisation-insights._index.tsx new file mode 100644 index 000000000..ed2908be6 --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/admin+/organisation-insights._index.tsx @@ -0,0 +1,91 @@ +import { Trans } from '@lingui/react/macro'; + +import { getOrganisationInsights } from '@documenso/lib/server-only/admin/get-signing-volume'; +import type { DateRange } from '@documenso/lib/types/search-params'; + +import { DateRangeFilter } from '~/components/filters/date-range-filter'; +import { + AdminOrganisationOverviewTable, + type OrganisationOverview, +} from '~/components/tables/admin-organisation-overview-table'; + +import type { Route } from './+types/organisation-insights._index'; + +export async function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url); + + const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume'; + const rawSortOrder = url.searchParams.get('sortOrder') || 'desc'; + + const isSortOrder = (value: string): value is 'asc' | 'desc' => + value === 'asc' || value === 'desc'; + const isSortBy = (value: string): value is 'name' | 'createdAt' | 'signingVolume' => + value === 'name' || value === 'createdAt' || value === 'signingVolume'; + + const sortOrder: 'asc' | 'desc' = isSortOrder(rawSortOrder) ? rawSortOrder : 'desc'; + const sortBy: 'name' | 'createdAt' | 'signingVolume' = isSortBy(rawSortBy) + ? rawSortBy + : 'signingVolume'; + + const page = Number(url.searchParams.get('page')) || 1; + const perPage = Number(url.searchParams.get('perPage')) || 10; + const search = url.searchParams.get('search') || ''; + const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange; + + const { organisations, totalPages } = await getOrganisationInsights({ + search, + page, + perPage, + sortBy, + sortOrder, + dateRange, + }); + + const typedOrganisations: OrganisationOverview[] = organisations.map((item) => ({ + id: String(item.id), + name: item.name || '', + signingVolume: item.signingVolume, + createdAt: item.createdAt || new Date(), + customerId: item.customerId || '', + subscriptionStatus: item.subscriptionStatus, + teamCount: item.teamCount || 0, + memberCount: item.memberCount || 0, + })); + + return { + organisations: typedOrganisations, + totalPages, + page, + perPage, + sortBy, + sortOrder, + dateRange, + }; +} + +export default function Organisations({ loaderData }: Route.ComponentProps) { + const { organisations, totalPages, page, perPage, sortBy, sortOrder, dateRange } = loaderData; + + return ( +
+
+

+ Organisation Insights +

+ +
+ +
+ +
+
+ ); +} diff --git a/packages/lib/server-only/admin/get-organisation-detailed-insights.ts b/packages/lib/server-only/admin/get-organisation-detailed-insights.ts new file mode 100644 index 000000000..342fa6b8e --- /dev/null +++ b/packages/lib/server-only/admin/get-organisation-detailed-insights.ts @@ -0,0 +1,363 @@ +import type { DocumentStatus } from '@prisma/client'; +import { EnvelopeType } from '@prisma/client'; + +import type { DateRange } from '@documenso/lib/types/search-params'; +import { kyselyPrisma, sql } from '@documenso/prisma'; + +export type OrganisationSummary = { + totalTeams: number; + totalMembers: number; + totalDocuments: number; + activeDocuments: number; + completedDocuments: number; + volumeThisPeriod: number; + volumeAllTime: number; +}; + +export type OrganisationDetailedInsights = { + teams: TeamInsights[]; + users: UserInsights[]; + documents: DocumentInsights[]; + totalPages: number; + summary?: OrganisationSummary; +}; + +export type TeamInsights = { + id: number; + name: string; + memberCount: number; + documentCount: number; + createdAt: Date; +}; + +export type UserInsights = { + id: number; + name: string; + email: string; + documentCount: number; + signedDocumentCount: number; + createdAt: Date; +}; + +export type DocumentInsights = { + id: string; + title: string; + status: DocumentStatus; + teamName: string; + createdAt: Date; + completedAt: Date | null; +}; + +export type GetOrganisationDetailedInsightsOptions = { + organisationId: string; + page?: number; + perPage?: number; + dateRange?: DateRange; + view: 'teams' | 'users' | 'documents'; +}; + +export async function getOrganisationDetailedInsights({ + organisationId, + page = 1, + perPage = 10, + dateRange = 'last30days', + view, +}: GetOrganisationDetailedInsightsOptions): Promise { + const offset = Math.max(page - 1, 0) * perPage; + + const now = new Date(); + let createdAtFrom: Date | null = null; + + switch (dateRange) { + case 'last30days': { + createdAtFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + break; + } + case 'last90days': { + createdAtFrom = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); + break; + } + case 'lastYear': { + createdAtFrom = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); + break; + } + case 'allTime': + default: + createdAtFrom = null; + break; + } + + const summaryData = await getOrganisationSummary(organisationId, createdAtFrom); + + const viewData = await (async () => { + switch (view) { + case 'teams': + return await getTeamInsights(organisationId, offset, perPage, createdAtFrom); + case 'users': + return await getUserInsights(organisationId, offset, perPage, createdAtFrom); + case 'documents': + return await getDocumentInsights(organisationId, offset, perPage, createdAtFrom); + default: + throw new Error(`Invalid view: ${view}`); + } + })(); + + return { + ...viewData, + summary: summaryData, + }; +} + +async function getTeamInsights( + organisationId: string, + offset: number, + perPage: number, + createdAtFrom: Date | null, +): Promise { + const teamsQuery = kyselyPrisma.$kysely + .selectFrom('Team as t') + .leftJoin('Envelope as e', (join) => + join + .onRef('t.id', '=', 'e.teamId') + .on('e.deletedAt', 'is', null) + .on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)), + ) + .leftJoin('TeamGroup as tg', 'tg.teamId', 't.id') + .leftJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId') + .leftJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id') + .leftJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId') + .where('t.organisationId', '=', organisationId) + .select([ + 't.id as id', + 't.name as name', + 't.createdAt as createdAt', + sql`COUNT(DISTINCT om."userId")`.as('memberCount'), + (createdAtFrom + ? sql`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)` + : sql`COUNT(DISTINCT e.id)` + ).as('documentCount'), + ]) + .groupBy(['t.id', 't.name', 't.createdAt']) + .orderBy('documentCount', 'desc') + .limit(perPage) + .offset(offset); + + const countQuery = kyselyPrisma.$kysely + .selectFrom('Team as t') + .where('t.organisationId', '=', organisationId) + .select(({ fn }) => [fn.countAll().as('count')]); + + const [teams, countResult] = await Promise.all([teamsQuery.execute(), countQuery.execute()]); + const count = Number(countResult[0]?.count || 0); + + return { + teams: teams as TeamInsights[], + users: [], + documents: [], + totalPages: Math.ceil(Number(count) / perPage), + }; +} + +async function getUserInsights( + organisationId: string, + offset: number, + perPage: number, + createdAtFrom: Date | null, +): Promise { + const usersBase = kyselyPrisma.$kysely + .selectFrom('OrganisationMember as om') + .innerJoin('User as u', 'u.id', 'om.userId') + .where('om.organisationId', '=', organisationId) + .leftJoin('Envelope as e', (join) => + join + .onRef('e.userId', '=', 'u.id') + .on('e.deletedAt', 'is', null) + .on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)), + ) + .leftJoin('Team as td', (join) => + join.onRef('td.id', '=', 'e.teamId').on('td.organisationId', '=', organisationId), + ) + .leftJoin('Recipient as r', (join) => + join.onRef('r.email', '=', 'u.email').on('r.signedAt', 'is not', null), + ) + .leftJoin('Envelope as se', (join) => + join + .onRef('se.id', '=', 'r.envelopeId') + .on('se.deletedAt', 'is', null) + .on('se.type', '=', sql.lit(EnvelopeType.DOCUMENT)), + ) + .leftJoin('Team as ts', (join) => + join.onRef('ts.id', '=', 'se.teamId').on('ts.organisationId', '=', organisationId), + ); + + const usersQuery = usersBase + .select([ + 'u.id as id', + 'u.name as name', + 'u.email as email', + 'u.createdAt as createdAt', + (createdAtFrom + ? sql`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)` + : sql`COUNT(DISTINCT CASE WHEN td.id IS NOT NULL THEN e.id END)` + ).as('documentCount'), + (createdAtFrom + ? sql`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' AND e."createdAt" >= ${createdAtFrom} THEN e.id END)` + : sql`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' THEN e.id END)` + ).as('signedDocumentCount'), + ]) + .groupBy(['u.id', 'u.name', 'u.email', 'u.createdAt']) + .orderBy('u.createdAt', 'desc') + .limit(perPage) + .offset(offset); + + const countQuery = kyselyPrisma.$kysely + .selectFrom('OrganisationMember as om') + .innerJoin('User as u', 'u.id', 'om.userId') + .where('om.organisationId', '=', organisationId) + .select(({ fn }) => [fn.countAll().as('count')]); + + const [users, countResult] = await Promise.all([usersQuery.execute(), countQuery.execute()]); + const count = Number(countResult[0]?.count || 0); + + return { + teams: [], + users: users as UserInsights[], + documents: [], + totalPages: Math.ceil(Number(count) / perPage), + }; +} + +async function getDocumentInsights( + organisationId: string, + offset: number, + perPage: number, + createdAtFrom: Date | null, +): Promise { + let documentsQuery = kyselyPrisma.$kysely + .selectFrom('Envelope as e') + .innerJoin('Team as t', 'e.teamId', 't.id') + .where('t.organisationId', '=', organisationId) + .where('e.deletedAt', 'is', null) + .where(() => sql`e.type = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`); + + if (createdAtFrom) { + documentsQuery = documentsQuery.where('e.createdAt', '>=', createdAtFrom); + } + + documentsQuery = documentsQuery + .select([ + 'e.id as id', + 'e.title as title', + 'e.status as status', + 'e.createdAt as createdAt', + 'e.completedAt as completedAt', + 't.name as teamName', + ]) + .orderBy('e.createdAt', 'desc') + .limit(perPage) + .offset(offset); + + let countQuery = kyselyPrisma.$kysely + .selectFrom('Envelope as e') + .innerJoin('Team as t', 'e.teamId', 't.id') + .where('t.organisationId', '=', organisationId) + .where('e.deletedAt', 'is', null) + .where(() => sql`e.type = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`); + + if (createdAtFrom) { + countQuery = countQuery.where('e.createdAt', '>=', createdAtFrom); + } + + countQuery = countQuery.select(({ fn }) => [fn.countAll().as('count')]); + + const [documents, countResult] = await Promise.all([ + documentsQuery.execute(), + countQuery.execute(), + ]); + + const count = Number((countResult[0] as { count: number })?.count || 0); + + return { + teams: [], + users: [], + documents: documents.map((doc) => ({ + ...doc, + id: String((doc as { id: number }).id), + })) as DocumentInsights[], + totalPages: Math.ceil(Number(count) / perPage), + }; +} + +async function getOrganisationSummary( + organisationId: string, + createdAtFrom: Date | null, +): Promise { + const summaryQuery = kyselyPrisma.$kysely + .selectFrom('Organisation as o') + .where('o.id', '=', organisationId) + .select([ + sql`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as( + 'totalTeams', + ), + sql`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as( + 'totalMembers', + ), + sql`( + SELECT COUNT(DISTINCT e2.id) + FROM "Envelope" AS e2 + INNER JOIN "Team" AS t2 ON t2.id = e2."teamId" + WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' + )`.as('totalDocuments'), + sql`( + SELECT COUNT(DISTINCT e2.id) + FROM "Envelope" AS e2 + INNER JOIN "Team" AS t2 ON t2.id = e2."teamId" + WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status IN ('DRAFT', 'PENDING') + )`.as('activeDocuments'), + sql`( + SELECT COUNT(DISTINCT e2.id) + FROM "Envelope" AS e2 + INNER JOIN "Team" AS t2 ON t2.id = e2."teamId" + WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED' + )`.as('completedDocuments'), + (createdAtFrom + ? sql`( + SELECT COUNT(DISTINCT e2.id) + FROM "Envelope" AS e2 + INNER JOIN "Team" AS t2 ON t2.id = e2."teamId" + WHERE t2."organisationId" = o.id + AND e2."deletedAt" IS NULL + AND e2.type = 'DOCUMENT' + AND e2.status = 'COMPLETED' + AND e2."createdAt" >= ${createdAtFrom} + )` + : sql`( + SELECT COUNT(DISTINCT e2.id) + FROM "Envelope" AS e2 + INNER JOIN "Team" AS t2 ON t2.id = e2."teamId" + WHERE t2."organisationId" = o.id + AND e2."deletedAt" IS NULL + AND e2.type = 'DOCUMENT' + AND e2.status = 'COMPLETED' + )` + ).as('volumeThisPeriod'), + sql`( + SELECT COUNT(DISTINCT e2.id) + FROM "Envelope" AS e2 + INNER JOIN "Team" AS t2 ON t2.id = e2."teamId" + WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED' + )`.as('volumeAllTime'), + ]); + + const result = await summaryQuery.executeTakeFirst(); + + return { + totalTeams: Number(result?.totalTeams || 0), + totalMembers: Number(result?.totalMembers || 0), + totalDocuments: Number(result?.totalDocuments || 0), + activeDocuments: Number(result?.activeDocuments || 0), + completedDocuments: Number(result?.completedDocuments || 0), + volumeThisPeriod: Number(result?.volumeThisPeriod || 0), + volumeAllTime: Number(result?.volumeAllTime || 0), + }; +} diff --git a/packages/lib/server-only/admin/get-signing-volume.ts b/packages/lib/server-only/admin/get-signing-volume.ts index ce9352a92..71edce8b5 100644 --- a/packages/lib/server-only/admin/get-signing-volume.ts +++ b/packages/lib/server-only/admin/get-signing-volume.ts @@ -1,13 +1,17 @@ -import { DocumentStatus, EnvelopeType, SubscriptionStatus } from '@prisma/client'; +import { DocumentStatus, EnvelopeType } from '@prisma/client'; +import type { DateRange } from '@documenso/lib/types/search-params'; import { kyselyPrisma, sql } from '@documenso/prisma'; -export type SigningVolume = { +export type OrganisationInsights = { id: number; name: string; signingVolume: number; createdAt: Date; - planId: string; + customerId: string | null; + subscriptionStatus?: string; + teamCount?: number; + memberCount?: number; }; export type GetSigningVolumeOptions = { @@ -28,28 +32,26 @@ export async function getSigningVolume({ const offset = Math.max(page - 1, 0) * perPage; let findQuery = kyselyPrisma.$kysely - .selectFrom('Subscription as s') - .innerJoin('Organisation as o', 's.organisationId', 'o.id') + .selectFrom('Organisation as o') .leftJoin('Team as t', 'o.id', 't.organisationId') .leftJoin('Envelope as e', (join) => join .onRef('t.id', '=', 'e.teamId') .on('e.status', '=', sql.lit(DocumentStatus.COMPLETED)) - .on('e.deletedAt', 'is', null), + .on('e.deletedAt', 'is', null) + .on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)), ) - .where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`) .where((eb) => eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]), ) - .where('e.type', '=', EnvelopeType.DOCUMENT) .select([ - 's.id as id', - 's.createdAt as createdAt', - 's.planId as planId', + 'o.id as id', + 'o.createdAt as createdAt', + 'o.customerId as customerId', sql`COALESCE(o.name, 'Unknown')`.as('name'), sql`COUNT(DISTINCT e.id)`.as('signingVolume'), ]) - .groupBy(['s.id', 'o.name']); + .groupBy(['o.id', 'o.name', 'o.customerId']); switch (sortBy) { case 'name': @@ -68,19 +70,127 @@ export async function getSigningVolume({ findQuery = findQuery.limit(perPage).offset(offset); const countQuery = kyselyPrisma.$kysely - .selectFrom('Subscription as s') - .innerJoin('Organisation as o', 's.organisationId', 'o.id') + .selectFrom('Organisation as o') .leftJoin('Team as t', 'o.id', 't.organisationId') - .where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`) .where((eb) => eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]), ) - .select(({ fn }) => [fn.countAll().as('count')]); + .select(() => [sql`COUNT(DISTINCT o.id)`.as('count')]); const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]); return { - leaderboard: results, + organisations: results, + totalPages: Math.ceil(Number(count) / perPage), + }; +} + +export type GetOrganisationInsightsOptions = GetSigningVolumeOptions & { + dateRange?: DateRange; + startDate?: Date; + endDate?: Date; +}; + +export async function getOrganisationInsights({ + search = '', + page = 1, + perPage = 10, + sortBy = 'signingVolume', + sortOrder = 'desc', + dateRange = 'last30days', + startDate, + endDate, +}: GetOrganisationInsightsOptions) { + const offset = Math.max(page - 1, 0) * perPage; + + const now = new Date(); + let dateCondition = sql`1=1`; + + if (startDate && endDate) { + dateCondition = sql`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`; + } else { + switch (dateRange) { + case 'last30days': { + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + dateCondition = sql`e."createdAt" >= ${thirtyDaysAgo}`; + break; + } + case 'last90days': { + const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); + dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`; + break; + } + case 'lastYear': { + const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); + dateCondition = sql`e."createdAt" >= ${oneYearAgo}`; + break; + } + case 'allTime': + default: + dateCondition = sql`1=1`; + break; + } + } + + let findQuery = kyselyPrisma.$kysely + .selectFrom('Organisation as o') + .leftJoin('Team as t', 'o.id', 't.organisationId') + .leftJoin('Envelope as e', (join) => + join + .onRef('t.id', '=', 'e.teamId') + .on('e.status', '=', sql.lit(DocumentStatus.COMPLETED)) + .on('e.deletedAt', 'is', null) + .on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)), + ) + .leftJoin('OrganisationMember as om', 'o.id', 'om.organisationId') + .leftJoin('Subscription as s', 'o.id', 's.organisationId') + .where((eb) => + eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]), + ) + .select([ + 'o.id as id', + 'o.createdAt as createdAt', + 'o.customerId as customerId', + sql`COALESCE(o.name, 'Unknown')`.as('name'), + sql`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND ${dateCondition} THEN e.id END)`.as( + 'signingVolume', + ), + sql`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'), + sql`COUNT(DISTINCT om."userId")`.as('memberCount'), + sql`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as( + 'subscriptionStatus', + ), + ]) + .groupBy(['o.id', 'o.name', 'o.customerId', 's.status']); + + switch (sortBy) { + case 'name': + findQuery = findQuery.orderBy('name', sortOrder); + break; + case 'createdAt': + findQuery = findQuery.orderBy('createdAt', sortOrder); + break; + case 'signingVolume': + findQuery = findQuery.orderBy('signingVolume', sortOrder); + break; + default: + findQuery = findQuery.orderBy('signingVolume', 'desc'); + } + + findQuery = findQuery.limit(perPage).offset(offset); + + const countQuery = kyselyPrisma.$kysely + .selectFrom('Organisation as o') + .leftJoin('Team as t', 'o.id', 't.organisationId') + .where((eb) => + eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]), + ) + .select(() => [sql`COUNT(DISTINCT o.id)`.as('count')]); + + const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]); + + return { + organisations: results, totalPages: Math.ceil(Number(count) / perPage), }; } diff --git a/packages/lib/types/search-params.ts b/packages/lib/types/search-params.ts index 35a166b95..1293c8a23 100644 --- a/packages/lib/types/search-params.ts +++ b/packages/lib/types/search-params.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +export type DateRange = 'last30days' | 'last90days' | 'lastYear' | 'allTime'; + /** * Backend only schema is used for find search params. *