feat: add admin org stats (#2904)

This commit is contained in:
David Nguyen
2026-06-01 17:26:51 +10:00
committed by GitHub
parent 44c4826e92
commit 536142be03
9 changed files with 554 additions and 2 deletions
@@ -0,0 +1,188 @@
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { trpc } from '@documenso/trpc/react';
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 { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { useLingui } from '@lingui/react/macro';
import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react';
import { useMemo } from 'react';
import { Link, useSearchParams } from 'react-router';
type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'totalCount';
type OrderByDirection = 'asc' | 'desc';
const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefined => {
if (value === 'documentCount' || value === 'emailCount' || value === 'apiCount' || value === 'totalCount') {
return value;
}
return undefined;
};
const parseOrderByDirection = (value: string | undefined): OrderByDirection => {
return value === 'asc' ? 'asc' : 'desc';
};
export const AdminOrganisationStatsTable = () => {
const { t } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
// Default to the current month.
const period = searchParams?.get('period') ?? currentMonthlyPeriod();
const claimId = searchParams?.get('claimId') || undefined;
const orderByColumn = parseOrderByColumn(searchParams?.get('orderByColumn') ?? undefined);
const orderByDirection = parseOrderByDirection(searchParams?.get('orderByDirection') ?? undefined);
const { data, isLoading, isLoadingError } = trpc.admin.organisation.findStats.useQuery({
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
period,
claimId,
orderByColumn,
orderByDirection,
});
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const handleColumnSort = (column: OrderByColumn) => {
const nextDirection = orderByColumn === column && orderByDirection === 'desc' ? 'asc' : 'desc';
updateSearchParams({
orderByColumn: column,
orderByDirection: nextDirection,
page: 1,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
const sortableHeader = (label: string, column: OrderByColumn) => (
<button
type="button"
className="flex cursor-pointer items-center whitespace-nowrap"
onClick={() => handleColumnSort(column)}
>
{label}
{orderByColumn === column ? (
orderByDirection === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDownIcon className="ml-2 h-4 w-4" />
)}
</button>
);
return [
{
header: t`Organisation`,
accessorKey: 'organisationName',
cell: ({ row }) => (
<Link to={`/admin/organisations/${row.original.organisationId}`} className="hover:underline">
{row.original.organisationName}
</Link>
),
},
{
header: t`Claim`,
accessorKey: 'originalClaimId',
cell: ({ row }) => <span className="text-muted-foreground">{row.original.originalClaimId ?? '—'}</span>,
},
{
header: t`Period`,
accessorKey: 'period',
cell: ({ row }) => row.original.period,
},
{
header: () => sortableHeader(t`Documents`, 'documentCount'),
accessorKey: 'documentCount',
cell: ({ row }) => row.original.documentCount,
},
{
header: () => sortableHeader(t`Emails`, 'emailCount'),
accessorKey: 'emailCount',
cell: ({ row }) => row.original.emailCount,
},
{
header: () => sortableHeader(t`API`, 'apiCount'),
accessorKey: 'apiCount',
cell: ({ row }) => row.original.apiCount,
},
{
header: () => sortableHeader(t`Total`, 'totalCount'),
accessorKey: 'totalCount',
cell: ({ row }) => <span className="font-medium">{row.original.totalCount}</span>,
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, orderByColumn, orderByDirection]);
return (
<div>
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 5,
component: (
<>
<TableCell className="py-4 pr-4">
<Skeleton className="h-4 w-32 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
</div>
);
};
@@ -10,6 +10,7 @@ import {
BarChart3,
Building2Icon,
FileStack,
LineChartIcon,
MailIcon,
Settings,
Trophy,
@@ -153,6 +154,20 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/organisation-stats') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/organisation-stats">
<LineChartIcon className="mr-2 h-5 w-5" />
<Trans>Organisation Stats</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/site-settings') && 'bg-secondary')}
@@ -0,0 +1,173 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useMemo, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { SettingsHeader } from '~/components/general/settings-header';
import { AdminOrganisationStatsTable } from '~/components/tables/admin-organisation-stats-table';
const ALL_CLAIMS_VALUE = 'all';
/**
* The earliest UTC calendar month for which stats exist (the month the feature launched).
* Months before this never have data, so there's no point offering them in the filter.
*/
const EARLIEST_PERIOD = { year: 2026, month: 5 };
/**
* Generate every UTC calendar month from `EARLIEST_PERIOD` up to the current month as
* `YYYY-MM` strings, newest first.
*/
const generatePeriodOptions = (): string[] => {
const periods: string[] = [];
const now = new Date();
let year = now.getUTCFullYear();
let month = now.getUTCMonth() + 1;
while (year > EARLIEST_PERIOD.year || (year === EARLIEST_PERIOD.year && month >= EARLIEST_PERIOD.month)) {
periods.push(`${year}-${String(month).padStart(2, '0')}`);
month -= 1;
if (month === 0) {
month = 12;
year -= 1;
}
}
return periods;
};
export default function OrganisationStats() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const periodOptions = useMemo(() => generatePeriodOptions(), []);
const selectedPeriod = searchParams?.get('period') ?? currentMonthlyPeriod();
const selectedClaim = searchParams?.get('claimId') ?? ALL_CLAIMS_VALUE;
const { data: claimsData, isLoading: isLoadingClaims } = trpc.admin.claims.find.useQuery({
perPage: 100,
});
const claimOptions = claimsData?.data ?? [];
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
if ((searchParams?.get('query') || '') !== debouncedSearchQuery) {
params.delete('page');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const onPeriodChange = (value: string) => {
const params = new URLSearchParams(searchParams?.toString());
params.set('period', value);
params.delete('page');
setSearchParams(params);
};
const onClaimChange = (value: string) => {
const params = new URLSearchParams(searchParams?.toString());
if (value === ALL_CLAIMS_VALUE) {
params.delete('claimId');
} else {
params.set('claimId', value);
}
params.delete('page');
setSearchParams(params);
};
return (
<div>
<SettingsHeader
hideDivider
title={t`Organisation Stats`}
subtitle={t`View, sort and filter monthly usage stats across organisations`}
/>
<div className="mt-4 flex flex-col gap-4 sm:flex-row">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by organisation name, URL or ID`}
className="flex-1"
/>
<Select value={selectedClaim} onValueChange={onClaimChange}>
<SelectTrigger className="w-full sm:w-48" loading={isLoadingClaims}>
<SelectValue placeholder={t`All claims`} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_CLAIMS_VALUE}>{t`All claims`}</SelectItem>
{claimOptions.map((claim) => (
<SelectItem key={claim.id} value={claim.id}>
{claim.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedPeriod} onValueChange={onPeriodChange}>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder={t`Period`} />
</SelectTrigger>
<SelectContent>
{periodOptions.map((period) => (
<SelectItem key={period} value={period}>
{period}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-4">
<AdminOrganisationStatsTable />
</div>
<Alert variant="neutral" className="mt-4">
<AlertDescription>
<Trans>
Documents, emails and api values may not be accurate since they record the amount of times the action was
attempted. Meaning these values may go over the actual quota, get rejected, and will still be recorded.
</Trans>
</AlertDescription>
</Alert>
</div>
);
}
@@ -2,7 +2,7 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobsClient } from '../../jobs/client';
import { generateDatabaseId } from '../../universal/id';
import { currentMonthlyPeriod } from './current-monthly-period';
import { currentMonthlyPeriod } from '../../universal/monthly-period';
import type { LimitCounter } from './types';
type CheckMonthlyQuotaOptions = {
@@ -0,0 +1,139 @@
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { match } from 'ts-pattern';
import { adminProcedure } from '../trpc';
import {
ZFindOrganisationStatsRequestSchema,
ZFindOrganisationStatsResponseSchema,
} from './find-organisation-stats.types';
export const findOrganisationStatsRoute = adminProcedure
.input(ZFindOrganisationStatsRequestSchema)
.output(ZFindOrganisationStatsResponseSchema)
.query(async ({ input }) => {
const { query, period, claimId, page, perPage, orderByColumn, orderByDirection } = input;
return await findOrganisationStats({
query,
period,
claimId,
page,
perPage,
orderByColumn,
orderByDirection,
});
});
type FindOrganisationStatsOptions = {
query?: string;
period?: string;
claimId?: string;
page?: number;
perPage?: number;
orderByColumn?: 'documentCount' | 'emailCount' | 'apiCount' | 'totalCount';
orderByDirection?: 'asc' | 'desc';
};
export const findOrganisationStats = async ({
query,
period,
claimId,
page = 1,
perPage = 10,
orderByColumn,
orderByDirection = 'desc',
}: FindOrganisationStatsOptions) => {
const offset = Math.max(page - 1, 0) * perPage;
// Stats are always scoped to a single month. Default to the current month when none is given.
const resolvedPeriod = period ?? currentMonthlyPeriod();
const totalCountExpression = sql<number>`(
"OrganisationMonthlyStat"."documentCount"
+ "OrganisationMonthlyStat"."emailCount"
+ "OrganisationMonthlyStat"."apiCount"
)`;
let baseQuery = kyselyPrisma.$kysely
.selectFrom('OrganisationMonthlyStat')
.innerJoin('Organisation', 'Organisation.id', 'OrganisationMonthlyStat.organisationId')
.leftJoin('OrganisationClaim', 'OrganisationClaim.id', 'Organisation.organisationClaimId')
.where('OrganisationMonthlyStat.period', '=', resolvedPeriod);
if (query) {
// Organisation IDs are prefixed with `org_`. When the query uses that prefix it is
// unambiguously an ID (or URL) lookup, so use indexed equality matches instead of
// scanning every column with `ILIKE`.
if (query.startsWith('org_')) {
baseQuery = baseQuery.where((eb) =>
eb.or([eb('Organisation.id', '=', query), eb('Organisation.url', '=', query)]),
);
} else {
baseQuery = baseQuery.where((eb) =>
eb.or([eb('Organisation.name', 'ilike', `%${query}%`), eb('Organisation.url', 'ilike', `%${query}%`)]),
);
}
}
if (claimId) {
baseQuery = baseQuery.where('OrganisationClaim.originalSubscriptionClaimId', '=', claimId);
}
const dataQuery = baseQuery
.select((eb) => [
'OrganisationMonthlyStat.id as id',
'OrganisationMonthlyStat.organisationId as organisationId',
'Organisation.name as organisationName',
'OrganisationClaim.originalSubscriptionClaimId as originalClaimId',
'OrganisationMonthlyStat.period as period',
'OrganisationMonthlyStat.documentCount as documentCount',
'OrganisationMonthlyStat.emailCount as emailCount',
'OrganisationMonthlyStat.apiCount as apiCount',
totalCountExpression.as('totalCount'),
eb.fn.countAll().over().as('totalRows'),
])
.$call((qb) =>
match(orderByColumn)
.with('documentCount', () => qb.orderBy('OrganisationMonthlyStat.documentCount', orderByDirection))
.with('emailCount', () => qb.orderBy('OrganisationMonthlyStat.emailCount', orderByDirection))
.with('apiCount', () => qb.orderBy('OrganisationMonthlyStat.apiCount', orderByDirection))
.with('totalCount', () => qb.orderBy(totalCountExpression, orderByDirection))
.with(undefined, () =>
// Default ordering mirrors the desired SQL: email, api, document descending.
qb
.orderBy('OrganisationMonthlyStat.emailCount', 'desc')
.orderBy('OrganisationMonthlyStat.apiCount', 'desc')
.orderBy('OrganisationMonthlyStat.documentCount', 'desc'),
)
.exhaustive(),
)
.orderBy('OrganisationMonthlyStat.id', 'asc')
.limit(perPage)
.offset(offset);
const rows = await dataQuery.execute();
const count = rows.length > 0 ? Number(rows[0].totalRows) : 0;
const data = rows.map((row) => ({
id: row.id,
organisationId: row.organisationId,
organisationName: row.organisationName,
originalClaimId: row.originalClaimId,
period: row.period,
documentCount: Number(row.documentCount),
emailCount: Number(row.emailCount),
apiCount: Number(row.apiCount),
totalCount: Number(row.totalCount),
}));
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};
@@ -0,0 +1,35 @@
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { z } from 'zod';
export const ZFindOrganisationStatsRequestSchema = ZFindSearchParamsSchema.extend({
period: z
.string()
.regex(/^\d{4}-\d{2}$/, 'Period must be in YYYY-MM format.')
.describe('Filter stats by UTC calendar month in `YYYY-MM` form, e.g. "2026-05".')
.optional(),
claimId: z.string().describe('Filter stats by the original subscription claim ID.').optional(),
orderByColumn: z
.enum(['documentCount', 'emailCount', 'apiCount', 'totalCount'])
.describe('The column to sort by.')
.optional(),
orderByDirection: z.enum(['asc', 'desc']).describe('Sort direction.').default('desc'),
});
export const ZFindOrganisationStatsResponseSchema = ZFindResultResponse.extend({
data: z
.object({
id: z.string(),
organisationId: z.string(),
organisationName: z.string(),
originalClaimId: z.string().nullable(),
period: z.string(),
documentCount: z.number(),
emailCount: z.number(),
apiCount: z.number(),
totalCount: z.number(),
})
.array(),
});
export type TFindOrganisationStatsRequest = z.infer<typeof ZFindOrganisationStatsRequestSchema>;
export type TFindOrganisationStatsResponse = z.infer<typeof ZFindOrganisationStatsResponseSchema>;
@@ -1,4 +1,4 @@
import { currentMonthlyPeriod } from '@documenso/lib/server-only/rate-limit/current-monthly-period';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@prisma/client';
import { match } from 'ts-pattern';
@@ -17,6 +17,7 @@ import { findDocumentAuditLogsRoute } from './find-document-audit-logs';
import { findDocumentJobsRoute } from './find-document-jobs';
import { findDocumentsRoute } from './find-documents';
import { findEmailDomainsRoute } from './find-email-domains';
import { findOrganisationStatsRoute } from './find-organisation-stats';
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { findUnsealedDocumentsRoute } from './find-unsealed-documents';
import { findUserTeamsRoute } from './find-user-teams';
@@ -47,6 +48,7 @@ export const adminRouter = router({
delete: deleteOrganisationRoute,
swapSubscription: swapOrganisationSubscriptionRoute,
resetMonthlyStat: resetOrganisationMonthlyStatRoute,
findStats: findOrganisationStatsRoute,
},
organisationMember: {
promoteToOwner: promoteMemberToOwnerRoute,