fix: admin leaderboard query sorting (#1548)

This commit is contained in:
Ephraim Duncan
2025-01-28 02:05:40 +00:00
committed by GitHub
parent 2984af769c
commit c6fb101a99
2 changed files with 102 additions and 89 deletions

View File

@ -4,7 +4,7 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
import { msg } from '@lingui/macro'; import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { ChevronDownIcon as CaretSortIcon, Loader } from 'lucide-react'; import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
@ -54,7 +54,15 @@ export const LeaderboardTable = ({
onClick={() => handleColumnSort('name')} onClick={() => handleColumnSort('name')}
> >
{_(msg`Name`)} {_(msg`Name`)}
<CaretSortIcon className="ml-2 h-4 w-4" /> {sortBy === 'name' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</div> </div>
), ),
accessorKey: 'name', accessorKey: 'name',
@ -80,7 +88,15 @@ export const LeaderboardTable = ({
onClick={() => handleColumnSort('signingVolume')} onClick={() => handleColumnSort('signingVolume')}
> >
{_(msg`Signing Volume`)} {_(msg`Signing Volume`)}
<CaretSortIcon className="ml-2 h-4 w-4" /> {sortBy === 'signingVolume' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</div> </div>
), ),
accessorKey: 'signingVolume', accessorKey: 'signingVolume',
@ -94,7 +110,15 @@ export const LeaderboardTable = ({
onClick={() => handleColumnSort('createdAt')} onClick={() => handleColumnSort('createdAt')}
> >
{_(msg`Created`)} {_(msg`Created`)}
<CaretSortIcon className="ml-2 h-4 w-4" /> {sortBy === 'createdAt' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</div> </div>
); );
}, },
@ -102,7 +126,7 @@ export const LeaderboardTable = ({
cell: ({ row }) => i18n.date(row.original.createdAt), cell: ({ row }) => i18n.date(row.original.createdAt),
}, },
] satisfies DataTableColumnDef<SigningVolume>[]; ] satisfies DataTableColumnDef<SigningVolume>[];
}, [sortOrder]); }, [sortOrder, sortBy]);
useEffect(() => { useEffect(() => {
startTransition(() => { startTransition(() => {
@ -133,6 +157,9 @@ export const LeaderboardTable = ({
const handleColumnSort = (column: 'name' | 'createdAt' | 'signingVolume') => { const handleColumnSort = (column: 'name' | 'createdAt' | 'signingVolume') => {
startTransition(() => { startTransition(() => {
updateSearchParams({ updateSearchParams({
search: debouncedSearchString,
page,
perPage,
sortBy: column, sortBy: column,
sortOrder: sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc', sortOrder: sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc',
}); });

View File

@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma'; import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus, Prisma } from '@documenso/prisma/client'; import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
export type SigningVolume = { export type SigningVolume = {
id: number; id: number;
@ -24,92 +24,78 @@ export async function getSigningVolume({
sortBy = 'signingVolume', sortBy = 'signingVolume',
sortOrder = 'desc', sortOrder = 'desc',
}: GetSigningVolumeOptions) { }: GetSigningVolumeOptions) {
const whereClause = Prisma.validator<Prisma.SubscriptionWhereInput>()({ const offset = Math.max(page - 1, 0) * perPage;
status: 'ACTIVE',
OR: [
{
user: {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
],
},
},
{
team: {
name: { contains: search, mode: 'insensitive' },
},
},
],
});
const [subscriptions, totalCount] = await Promise.all([ let findQuery = kyselyPrisma.$kysely
prisma.subscription.findMany({ .selectFrom('Subscription as s')
where: whereClause, .leftJoin('User as u', 's.userId', 'u.id')
include: { .leftJoin('Team as t', 's.teamId', 't.id')
user: { .leftJoin('Document as ud', (join) =>
select: { join
name: true, .onRef('u.id', '=', 'ud.userId')
email: true, .on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
documents: { .on('ud.deletedAt', 'is', null)
where: { .on('ud.teamId', 'is', null),
status: DocumentStatus.COMPLETED, )
deletedAt: null, .leftJoin('Document as td', (join) =>
teamId: null, join
}, .onRef('t.id', '=', 'td.teamId')
}, .on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
}, .on('td.deletedAt', 'is', null),
}, )
team: { // @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
select: { .where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
name: true, .where((eb) =>
documents: { eb.or([
where: { eb('u.name', 'ilike', `%${search}%`),
status: DocumentStatus.COMPLETED, eb('u.email', 'ilike', `%${search}%`),
deletedAt: null, eb('t.name', 'ilike', `%${search}%`),
}, ]),
}, )
}, .select([
}, 's.id as id',
}, 's.createdAt as createdAt',
orderBy: 's.planId as planId',
sortBy === 'name' sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
? [{ user: { name: sortOrder } }, { team: { name: sortOrder } }, { createdAt: 'desc' }] sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
: sortBy === 'createdAt' ])
? [{ createdAt: sortOrder }] .groupBy(['s.id', 'u.name', 't.name', 'u.email']);
: undefined,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
}),
prisma.subscription.count({
where: whereClause,
}),
]);
const leaderboardWithVolume: SigningVolume[] = subscriptions.map((subscription) => { switch (sortBy) {
const name = case 'name':
subscription.user?.name || subscription.team?.name || subscription.user?.email || 'Unknown'; findQuery = findQuery.orderBy('name', sortOrder);
const userSignedDocs = subscription.user?.documents?.length || 0; break;
const teamSignedDocs = subscription.team?.documents?.length || 0; case 'createdAt':
return { findQuery = findQuery.orderBy('createdAt', sortOrder);
id: subscription.id, break;
name, case 'signingVolume':
signingVolume: userSignedDocs + teamSignedDocs, findQuery = findQuery.orderBy('signingVolume', sortOrder);
createdAt: subscription.createdAt, break;
planId: subscription.planId, default:
}; findQuery = findQuery.orderBy('signingVolume', 'desc');
});
if (sortBy === 'signingVolume') {
leaderboardWithVolume.sort((a, b) => {
return sortOrder === 'desc'
? b.signingVolume - a.signingVolume
: a.signingVolume - b.signingVolume;
});
} }
findQuery = findQuery.limit(perPage).offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([
eb('u.name', 'ilike', `%${search}%`),
eb('u.email', 'ilike', `%${search}%`),
eb('t.name', 'ilike', `%${search}%`),
]),
)
.select(({ fn }) => [fn.countAll().as('count')]);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return { return {
leaderboard: leaderboardWithVolume, leaderboard: results,
totalPages: Math.ceil(totalCount / perPage), totalPages: Math.ceil(Number(count) / perPage),
}; };
} }