mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 01:01:49 +10:00
fix: admin leaderboard query sorting (#1548)
This commit is contained in:
@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user