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
@@ -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,