mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 01:45:08 +10:00
feat: add admin org stats (#2904)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user