feat: org insights

This commit is contained in:
Ephraim Atta-Duncan
2025-08-04 11:55:16 +00:00
parent 49c70fc8a8
commit 38f3a52233
9 changed files with 887 additions and 46 deletions

View File

@ -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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type DateRangeFilterProps = {
currentRange: string;
};
export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => {
const { _ } = useLingui();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const handleRangeChange = (value: string) => {
startTransition(() => {
updateSearchParams({
dateRange: value,
page: 1,
});
});
};
return (
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{_(msg`Time Range:`)}</span>
<Select value={currentRange} onValueChange={handleRangeChange} disabled={isPending}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="last30days">{_(msg`Last 30 Days`)}</SelectItem>
<SelectItem value="last90days">{_(msg`Last 90 Days`)}</SelectItem>
<SelectItem value="lastYear">{_(msg`Last Year`)}</SelectItem>
<SelectItem value="allTime">{_(msg`All Time`)}</SelectItem>
</SelectContent>
</Select>
</div>
);
};

View File

@ -11,16 +11,20 @@ import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
export type SigningVolume = { export type OrganisationOverview = {
id: number; id: string;
name: string; name: string;
signingVolume: number; signingVolume: number;
createdAt: Date; createdAt: Date;
planId: string; planId: string;
subscriptionStatus?: string;
isActive?: boolean;
teamCount?: number;
memberCount?: number;
}; };
type LeaderboardTableProps = { type OrganisationOverviewTableProps = {
signingVolume: SigningVolume[]; organisations: OrganisationOverview[];
totalPages: number; totalPages: number;
perPage: number; perPage: number;
page: number; page: number;
@ -28,14 +32,14 @@ type LeaderboardTableProps = {
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
}; };
export const AdminLeaderboardTable = ({ export const AdminOrganisationOverviewTable = ({
signingVolume, organisations,
totalPages, totalPages,
perPage, perPage,
page, page,
sortBy, sortBy,
sortOrder, sortOrder,
}: LeaderboardTableProps) => { }: OrganisationOverviewTableProps) => {
const { _, i18n } = useLingui(); const { _, i18n } = useLingui();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -69,15 +73,14 @@ export const AdminLeaderboardTable = ({
<div> <div>
<a <a
className="text-primary underline" className="text-primary underline"
href={`https://dashboard.stripe.com/subscriptions/${row.original.planId}`} href={`/admin/organisation-insights/${row.original.id}`}
target="_blank"
> >
{row.getValue('name')} {row.getValue('name')}
</a> </a>
</div> </div>
); );
}, },
size: 250, size: 200,
}, },
{ {
header: () => ( header: () => (
@ -85,7 +88,7 @@ export const AdminLeaderboardTable = ({
className="flex cursor-pointer items-center" className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('signingVolume')} onClick={() => handleColumnSort('signingVolume')}
> >
{_(msg`Signing Volume`)} {_(msg`Document Volume`)}
{sortBy === 'signingVolume' ? ( {sortBy === 'signingVolume' ? (
sortOrder === 'asc' ? ( sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" /> <ChevronUpIcon className="ml-2 h-4 w-4" />
@ -99,6 +102,42 @@ export const AdminLeaderboardTable = ({
), ),
accessorKey: 'signingVolume', accessorKey: 'signingVolume',
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>, cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
size: 120,
},
{
header: () => {
return <div>{_(msg`Status`)}</div>;
},
accessorKey: 'subscriptionStatus',
cell: ({ row }) => {
const status = row.original.subscriptionStatus;
return (
<div
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
status ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}
>
{status || 'Free'}
</div>
);
},
size: 100,
},
{
header: () => {
return <div>{_(msg`Teams`)}</div>;
},
accessorKey: 'teamCount',
cell: ({ row }) => <div>{Number(row.original.teamCount) || 0}</div>,
size: 80,
},
{
header: () => {
return <div>{_(msg`Members`)}</div>;
},
accessorKey: 'memberCount',
cell: ({ row }) => <div>{Number(row.original.memberCount) || 0}</div>,
size: 80,
}, },
{ {
header: () => { header: () => {
@ -122,8 +161,9 @@ export const AdminLeaderboardTable = ({
}, },
accessorKey: 'createdAt', accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt), cell: ({ row }) => i18n.date(row.original.createdAt),
size: 120,
}, },
] satisfies DataTableColumnDef<SigningVolume>[]; ] satisfies DataTableColumnDef<OrganisationOverview>[];
}, [sortOrder, sortBy]); }, [sortOrder, sortBy]);
useEffect(() => { useEffect(() => {
@ -169,13 +209,13 @@ export const AdminLeaderboardTable = ({
<Input <Input
className="my-6 flex flex-row gap-4" className="my-6 flex flex-row gap-4"
type="text" type="text"
placeholder={_(msg`Search by name or email`)} placeholder={_(msg`Search by organisation name`)}
value={searchString} value={searchString}
onChange={handleChange} onChange={handleChange}
/> />
<DataTable <DataTable
columns={columns} columns={columns}
data={signingVolume} data={organisations}
perPage={perPage} perPage={perPage}
currentPage={page} currentPage={page}
totalPages={totalPages} totalPages={totalPages}

View File

@ -100,6 +100,22 @@ export const AdminOrganisationsTable = ({
</Badge> </Badge>
), ),
}, },
{
header: t`Status`,
cell: ({ row }) => {
const subscription = row.original.subscription;
const isPaid = subscription && subscription.status === 'ACTIVE';
return (
<div
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
isPaid ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}
>
{isPaid ? 'Paid' : 'Free'}
</div>
);
},
},
{ {
header: t`Subscription`, header: t`Subscription`,
cell: ({ row }) => cell: ({ row }) =>

View File

@ -0,0 +1,251 @@
import { useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Archive, Building2, Loader, TrendingUp, Users } from 'lucide-react';
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 { 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';
type OrganisationInsightsTableProps = {
insights: OrganisationDetailedInsights;
page: number;
perPage: number;
dateRange: string;
view: 'teams' | 'users' | 'documents';
};
export const OrganisationInsightsTable = ({
insights,
page,
perPage,
dateRange,
view,
}: OrganisationInsightsTableProps) => {
const { _, i18n } = useLingui();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
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'),
},
{
header: _(msg`Members`),
accessorKey: 'memberCount',
cell: ({ row }) => row.getValue('memberCount'),
},
{
header: _(msg`Documents`),
accessorKey: 'documentCount',
cell: ({ row }) => row.getValue('documentCount'),
},
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.getValue('createdAt')),
},
] satisfies DataTableColumnDef<(typeof insights.teams)[number]>[];
const usersColumns = [
{
header: _(msg`Name`),
accessorKey: 'name',
cell: ({ row }) => row.getValue('name') || row.getValue('email'),
},
{
header: _(msg`Email`),
accessorKey: 'email',
cell: ({ row }) => row.getValue('email'),
},
{
header: _(msg`Documents Created`),
accessorKey: 'documentCount',
cell: ({ row }) => row.getValue('documentCount'),
},
{
header: _(msg`Documents Signed`),
accessorKey: 'signedDocumentCount',
cell: ({ row }) => row.getValue('signedDocumentCount'),
},
{
header: _(msg`Joined`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.getValue('createdAt')),
},
] satisfies DataTableColumnDef<(typeof insights.users)[number]>[];
const documentsColumns = [
{
header: _(msg`Title`),
accessorKey: 'title',
cell: ({ row }) => row.getValue('title'),
},
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => row.getValue('status'),
},
{
header: _(msg`Team`),
accessorKey: 'teamName',
cell: ({ row }) => row.getValue('teamName'),
},
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.getValue('createdAt')),
},
{
header: _(msg`Completed`),
accessorKey: 'completedAt',
cell: ({ row }) => {
const completedAt = row.getValue('completedAt') as Date | null;
return completedAt ? i18n.date(completedAt) : '-';
},
},
] 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<unknown>[] => {
switch (view) {
case 'teams':
return teamsColumns as unknown as DataTableColumnDef<unknown>[];
case 'users':
return usersColumns as unknown as DataTableColumnDef<unknown>[];
case 'documents':
return documentsColumns as unknown as DataTableColumnDef<unknown>[];
default:
return [];
}
};
const SummaryCard = ({
icon: Icon,
title,
value,
subtitle,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
value: number;
subtitle?: string;
}) => (
<div className="bg-card rounded-lg border p-4">
<div className="flex items-center space-x-2">
<Icon className="text-muted-foreground h-5 w-5" />
<div className="flex-1">
<p className="text-muted-foreground text-sm font-medium">{title}</p>
<p className="text-2xl font-bold">{value}</p>
{subtitle && <p className="text-muted-foreground text-xs">{subtitle}</p>}
</div>
</div>
</div>
);
return (
<div className="relative">
{insights.summary && (
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-4">
<SummaryCard icon={Building2} title={_(msg`Teams`)} value={insights.summary.totalTeams} />
<SummaryCard icon={Users} title={_(msg`Members`)} value={insights.summary.totalMembers} />
<SummaryCard
icon={TrendingUp}
title={_(msg`Volume (Period)`)}
value={insights.summary.volumeThisPeriod}
subtitle={_(msg`This time range`)}
/>
<SummaryCard
icon={Archive}
title={_(msg`Volume (All Time)`)}
value={insights.summary.volumeAllTime}
subtitle={_(msg`Total completed`)}
/>
</div>
)}
<div className="mb-6 flex items-center justify-between">
<div className="flex gap-2">
<Button
variant={view === 'teams' ? 'default' : 'outline'}
onClick={() => handleViewChange('teams')}
disabled={isPending}
>
{_(msg`Teams`)}
</Button>
<Button
variant={view === 'users' ? 'default' : 'outline'}
onClick={() => handleViewChange('users')}
disabled={isPending}
>
{_(msg`Users`)}
</Button>
<Button
variant={view === 'documents' ? 'default' : 'outline'}
onClick={() => handleViewChange('documents')}
disabled={isPending}
>
{_(msg`Documents`)}
</Button>
</div>
<DateRangeFilter currentRange={dateRange} />
</div>
<DataTable<unknown, unknown>
columns={getCurrentColumns()}
data={getCurrentData()}
perPage={perPage}
currentPage={page}
totalPages={insights.totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};

View File

@ -114,13 +114,13 @@ export default function AdminLayout() {
variant="ghost" variant="ghost"
className={cn( className={cn(
'justify-start md:w-full', 'justify-start md:w-full',
pathname?.startsWith('/admin/leaderboard') && 'bg-secondary', pathname?.startsWith('/admin/org-insights') && 'bg-secondary',
)} )}
asChild asChild
> >
<Link to="/admin/leaderboard"> <Link to="/admin/org-insights">
<Trophy className="mr-2 h-5 w-5" /> <Trophy className="mr-2 h-5 w-5" />
<Trans>Leaderboard</Trans> <Trans>Organisation Insights</Trans>
</Link> </Link>
</Button> </Button>

View File

@ -1,13 +1,14 @@
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume'; import { getOrganisationInsights } from '@documenso/lib/server-only/admin/get-signing-volume';
import { DateRangeFilter } from '~/components/filters/date-range-filter';
import { import {
AdminLeaderboardTable, AdminOrganisationOverviewTable,
type SigningVolume, type OrganisationOverview,
} from '~/components/tables/admin-leaderboard-table'; } from '~/components/tables/admin-organisation-overview-table';
import type { Route } from './+types/leaderboard'; import type { Route } from './+types/org-insights';
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url); const url = new URL(request.url);
@ -27,44 +28,58 @@ export async function loader({ request }: Route.LoaderArgs) {
const page = Number(url.searchParams.get('page')) || 1; const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10; const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || ''; const search = url.searchParams.get('search') || '';
const dateRange = (url.searchParams.get('dateRange') || 'last30days') as
| 'last30days'
| 'last90days'
| 'lastYear'
| 'allTime';
const { leaderboard, totalPages } = await getSigningVolume({ const { organisations, totalPages } = await getOrganisationInsights({
search, search,
page, page,
perPage, perPage,
sortBy, sortBy,
sortOrder, sortOrder,
dateRange,
}); });
const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({ const typedOrganisations: OrganisationOverview[] = organisations.map((item) => ({
...item, id: String(item.id),
name: item.name || '', name: item.name || '',
signingVolume: item.signingVolume,
createdAt: item.createdAt || new Date(), createdAt: item.createdAt || new Date(),
planId: item.customerId || '',
subscriptionStatus: item.subscriptionStatus,
teamCount: item.teamCount || 0,
memberCount: item.memberCount || 0,
})); }));
return { return {
signingVolume: typedSigningVolume, organisations: typedOrganisations,
totalPages, totalPages,
page, page,
perPage, perPage,
sortBy, sortBy,
sortOrder, sortOrder,
dateRange,
}; };
} }
export default function Leaderboard({ loaderData }: Route.ComponentProps) { export default function Organisations({ loaderData }: Route.ComponentProps) {
const { signingVolume, totalPages, page, perPage, sortBy, sortOrder } = loaderData; const { organisations, totalPages, page, perPage, sortBy, sortOrder, dateRange } = loaderData;
return ( return (
<div> <div>
<div className="flex items-center"> <div className="flex items-center justify-between">
<h2 className="text-4xl font-semibold"> <h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans> <Trans>Organisation Insights</Trans>
</h2> </h2>
<DateRangeFilter currentRange={dateRange} />
</div> </div>
<div className="mt-8"> <div className="mt-8">
<AdminLeaderboardTable <AdminOrganisationOverviewTable
signingVolume={signingVolume} organisations={organisations}
totalPages={totalPages} totalPages={totalPages}
page={page} page={page}
perPage={perPage} perPage={perPage}

View File

@ -0,0 +1,61 @@
import { Trans } from '@lingui/react/macro';
import { getOrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
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
| 'last30days'
| 'last90days'
| 'lastYear'
| 'allTime';
const view = (url.searchParams.get('view') || 'teams') as 'teams' | 'users' | 'documents';
const insights = await getOrganisationDetailedInsights({
organisationId: id,
page,
perPage,
dateRange,
view,
});
return {
organisationId: id,
insights,
page,
perPage,
dateRange,
view,
};
}
export default function OrganisationInsights({ loaderData }: Route.ComponentProps) {
const { organisationId, insights, page, perPage, dateRange, view } = loaderData;
return (
<div>
<div className="flex items-center justify-between">
<h2 className="text-4xl font-semibold">
<Trans>Organisation Insights</Trans>
</h2>
</div>
<div className="mt-8">
<OrganisationInsightsTable
insights={insights}
page={page}
perPage={perPage}
dateRange={dateRange}
view={view}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,301 @@
import type { DocumentStatus } from '@prisma/client';
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?: 'last30days' | 'last90days' | 'lastYear' | 'allTime';
view: 'teams' | 'users' | 'documents';
};
export async function getOrganisationDetailedInsights({
organisationId,
page = 1,
perPage = 10,
dateRange = 'last30days',
view,
}: GetOrganisationDetailedInsightsOptions): Promise<OrganisationDetailedInsights> {
const offset = Math.max(page - 1, 0) * perPage;
let dateFilter = sql``;
const now = new Date();
switch (dateRange) {
case 'last30days': {
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
dateFilter = sql`AND d."createdAt" >= ${thirtyDaysAgo}`;
break;
}
case 'last90days': {
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
dateFilter = sql`AND d."createdAt" >= ${ninetyDaysAgo}`;
break;
}
case 'lastYear': {
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
dateFilter = sql`AND d."createdAt" >= ${oneYearAgo}`;
break;
}
case 'allTime':
default:
dateFilter = sql``;
break;
}
// Get organisation summary metrics
const summaryData = await getOrganisationSummary(organisationId, dateFilter);
const viewData = await (async () => {
switch (view) {
case 'teams':
return await getTeamInsights(organisationId, offset, perPage, dateFilter);
case 'users':
return await getUserInsights(organisationId, offset, perPage, dateFilter);
case 'documents':
return await getDocumentInsights(organisationId, offset, perPage, dateFilter);
default:
throw new Error(`Invalid view: ${view}`);
}
})();
return {
...viewData,
summary: summaryData,
};
}
async function getTeamInsights(
organisationId: string,
offset: number,
perPage: number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dateFilter: any,
): Promise<OrganisationDetailedInsights> {
const teamsQuery = kyselyPrisma.$kysely
.selectFrom('Team as t')
.leftJoin('Document as d', (join) =>
join.onRef('t.id', '=', 'd.teamId').on('d.deletedAt', 'is', null),
)
.where('t.organisationId', '=', organisationId)
.select([
't.id as id',
't.name as name',
't.createdAt as createdAt',
sql<number>`0`.as('memberCount'),
sql<number>`COUNT(DISTINCT CASE WHEN d.id IS NOT NULL ${dateFilter} THEN d.id END)`.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,
// eslint-disable-next-line @typescript-eslint/no-explicit-any, unused-imports/no-unused-vars
_dateFilter: any,
): Promise<OrganisationDetailedInsights> {
const usersQuery = kyselyPrisma.$kysely
.selectFrom('OrganisationMember as om')
.innerJoin('User as u', 'u.id', 'om.userId')
.where('om.organisationId', '=', organisationId)
.select([
'u.id as id',
'u.name as name',
'u.email as email',
'u.createdAt as createdAt',
sql<number>`0`.as('documentCount'),
sql<number>`0`.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,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dateFilter: any,
): Promise<OrganisationDetailedInsights> {
let documentsQuery = kyselyPrisma.$kysely
.selectFrom('Document as d')
.innerJoin('Team as t', 'd.teamId', 't.id')
.where('t.organisationId', '=', organisationId)
.where('d.deletedAt', 'is', null);
// Apply date filter if it's not empty (which means all time)
if (dateFilter && dateFilter.sql && dateFilter.sql !== '') {
documentsQuery = documentsQuery.where(sql`${dateFilter}`);
}
documentsQuery = documentsQuery
.select([
'd.id as id',
'd.title as title',
'd.status as status',
'd.createdAt as createdAt',
'd.completedAt as completedAt',
't.name as teamName',
])
.orderBy('d.createdAt', 'desc')
.limit(perPage)
.offset(offset);
let countQuery = kyselyPrisma.$kysely
.selectFrom('Document as d')
.innerJoin('Team as t', 'd.teamId', 't.id')
.where('t.organisationId', '=', organisationId)
.where('d.deletedAt', 'is', null);
// Apply same date filter to count query
if (dateFilter && dateFilter.sql && dateFilter.sql !== '') {
countQuery = countQuery.where(sql`${dateFilter}`);
}
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,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dateFilter: any,
): Promise<OrganisationSummary> {
const summaryQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.leftJoin('OrganisationMember as om', 'o.id', 'om.organisationId')
.leftJoin('Document as d', (join) =>
join.onRef('t.id', '=', 'd.teamId').on('d.deletedAt', 'is', null),
)
.where('o.id', '=', organisationId)
.select([
sql<number>`COUNT(DISTINCT t.id)`.as('totalTeams'),
sql<number>`COUNT(DISTINCT om."userId")`.as('totalMembers'),
sql<number>`COUNT(DISTINCT d.id)`.as('totalDocuments'),
sql<number>`COUNT(DISTINCT CASE WHEN d.status IN ('DRAFT', 'PENDING') THEN d.id END)`.as(
'activeDocuments',
),
sql<number>`COUNT(DISTINCT CASE WHEN d.status = 'COMPLETED' THEN d.id END)`.as(
'completedDocuments',
),
sql<number>`COUNT(DISTINCT CASE WHEN d.id IS NOT NULL AND d.status = 'COMPLETED' ${dateFilter} THEN d.id END)`.as(
'volumeThisPeriod',
),
sql<number>`COUNT(DISTINCT CASE WHEN d.status = 'COMPLETED' THEN d.id END)`.as(
'volumeAllTime',
),
]);
const result = await summaryQuery.executeTakeFirst();
return {
totalTeams: Math.max(Number(result?.totalTeams || 0), 1),
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),
};
}

View File

@ -1,13 +1,16 @@
import { DocumentStatus, SubscriptionStatus } from '@prisma/client'; import { DocumentStatus } from '@prisma/client';
import { kyselyPrisma, sql } from '@documenso/prisma'; import { kyselyPrisma, sql } from '@documenso/prisma';
export type SigningVolume = { export type OrganisationInsights = {
id: number; id: number;
name: string; name: string;
signingVolume: number; signingVolume: number;
createdAt: Date; createdAt: Date;
planId: string; customerId: string | null;
subscriptionStatus?: string;
teamCount?: number;
memberCount?: number;
}; };
export type GetSigningVolumeOptions = { export type GetSigningVolumeOptions = {
@ -28,8 +31,7 @@ export async function getSigningVolume({
const offset = Math.max(page - 1, 0) * perPage; const offset = Math.max(page - 1, 0) * perPage;
let findQuery = kyselyPrisma.$kysely let findQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s') .selectFrom('Organisation as o')
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.leftJoin('Team as t', 'o.id', 't.organisationId') .leftJoin('Team as t', 'o.id', 't.organisationId')
.leftJoin('Document as d', (join) => .leftJoin('Document as d', (join) =>
join join
@ -37,18 +39,17 @@ export async function getSigningVolume({
.on('d.status', '=', sql.lit(DocumentStatus.COMPLETED)) .on('d.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('d.deletedAt', 'is', null), .on('d.deletedAt', 'is', null),
) )
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) => .where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]), eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
) )
.select([ .select([
's.id as id', 'o.id as id',
's.createdAt as createdAt', 'o.createdAt as createdAt',
's.planId as planId', 'o.customerId as customerId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'), sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT d.id)`.as('signingVolume'), sql<number>`COUNT(DISTINCT d.id)`.as('signingVolume'),
]) ])
.groupBy(['s.id', 'o.name']); .groupBy(['o.id', 'o.name', 'o.customerId']);
switch (sortBy) { switch (sortBy) {
case 'name': case 'name':
@ -67,10 +68,8 @@ export async function getSigningVolume({
findQuery = findQuery.limit(perPage).offset(offset); findQuery = findQuery.limit(perPage).offset(offset);
const countQuery = kyselyPrisma.$kysely const countQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s') .selectFrom('Organisation as o')
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.leftJoin('Team as t', 'o.id', 't.organisationId') .leftJoin('Team as t', 'o.id', 't.organisationId')
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) => .where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]), eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
) )
@ -79,7 +78,116 @@ export async function getSigningVolume({
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]); const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return { return {
leaderboard: results, organisations: results,
totalPages: Math.ceil(Number(count) / perPage),
};
}
export type GetOrganisationInsightsOptions = GetSigningVolumeOptions & {
dateRange?: 'last30days' | 'last90days' | 'lastYear' | 'allTime';
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`d."createdAt" >= ${startDate} AND d."createdAt" <= ${endDate}`;
} else {
switch (dateRange) {
case 'last30days': {
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
dateCondition = sql`d."createdAt" >= ${thirtyDaysAgo}`;
break;
}
case 'last90days': {
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
dateCondition = sql`d."createdAt" >= ${ninetyDaysAgo}`;
break;
}
case 'lastYear': {
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
dateCondition = sql`d."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('Document as d', (join) =>
join
.onRef('t.id', '=', 'd.teamId')
.on('d.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('d.deletedAt', 'is', null),
)
.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<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT CASE WHEN d.id IS NOT NULL AND ${dateCondition} THEN d.id END)`.as(
'signingVolume',
),
sql<number>`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'),
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
sql<string>`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(({ fn }) => [fn.countAll().as('count')]);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return {
organisations: results,
totalPages: Math.ceil(Number(count) / perPage), totalPages: Math.ceil(Number(count) / perPage),
}; };
} }