mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
feat: add admin org stats (#2904)
This commit is contained in:
@@ -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,
|
BarChart3,
|
||||||
Building2Icon,
|
Building2Icon,
|
||||||
FileStack,
|
FileStack,
|
||||||
|
LineChartIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
Settings,
|
Settings,
|
||||||
Trophy,
|
Trophy,
|
||||||
@@ -153,6 +154,20 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/site-settings') && 'bg-secondary')}
|
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 { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { jobsClient } from '../../jobs/client';
|
import { jobsClient } from '../../jobs/client';
|
||||||
import { generateDatabaseId } from '../../universal/id';
|
import { generateDatabaseId } from '../../universal/id';
|
||||||
import { currentMonthlyPeriod } from './current-monthly-period';
|
import { currentMonthlyPeriod } from '../../universal/monthly-period';
|
||||||
import type { LimitCounter } from './types';
|
import type { LimitCounter } from './types';
|
||||||
|
|
||||||
type CheckMonthlyQuotaOptions = {
|
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 { prisma } from '@documenso/prisma';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { findDocumentAuditLogsRoute } from './find-document-audit-logs';
|
|||||||
import { findDocumentJobsRoute } from './find-document-jobs';
|
import { findDocumentJobsRoute } from './find-document-jobs';
|
||||||
import { findDocumentsRoute } from './find-documents';
|
import { findDocumentsRoute } from './find-documents';
|
||||||
import { findEmailDomainsRoute } from './find-email-domains';
|
import { findEmailDomainsRoute } from './find-email-domains';
|
||||||
|
import { findOrganisationStatsRoute } from './find-organisation-stats';
|
||||||
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
|
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
|
||||||
import { findUnsealedDocumentsRoute } from './find-unsealed-documents';
|
import { findUnsealedDocumentsRoute } from './find-unsealed-documents';
|
||||||
import { findUserTeamsRoute } from './find-user-teams';
|
import { findUserTeamsRoute } from './find-user-teams';
|
||||||
@@ -47,6 +48,7 @@ export const adminRouter = router({
|
|||||||
delete: deleteOrganisationRoute,
|
delete: deleteOrganisationRoute,
|
||||||
swapSubscription: swapOrganisationSubscriptionRoute,
|
swapSubscription: swapOrganisationSubscriptionRoute,
|
||||||
resetMonthlyStat: resetOrganisationMonthlyStatRoute,
|
resetMonthlyStat: resetOrganisationMonthlyStatRoute,
|
||||||
|
findStats: findOrganisationStatsRoute,
|
||||||
},
|
},
|
||||||
organisationMember: {
|
organisationMember: {
|
||||||
promoteToOwner: promoteMemberToOwnerRoute,
|
promoteToOwner: promoteMemberToOwnerRoute,
|
||||||
|
|||||||
Reference in New Issue
Block a user