mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
refactor: find envelopes (#2557)
This commit is contained in:
@@ -9,6 +9,7 @@ import { z } from 'zod';
|
||||
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
@@ -172,7 +173,11 @@ export default function DocumentsPage() {
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
|
||||
<span className="ml-1 inline-block opacity-50">
|
||||
{stats[value] >= STATS_COUNT_CAP
|
||||
? `${STATS_COUNT_CAP.toLocaleString()}+`
|
||||
: stats[value]}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,12 @@ import { DocumentSignatureType } from '@documenso/lib/utils/teams';
|
||||
|
||||
export { DocumentSignatureType };
|
||||
|
||||
/**
|
||||
* Maximum count returned per status bucket in document stats. The server clamps
|
||||
* each count to this value; the UI should display "10,000+" when it sees it.
|
||||
*/
|
||||
export const STATS_COUNT_CAP = 10_000;
|
||||
|
||||
export const DOCUMENT_STATUS: {
|
||||
[status in DocumentStatus]: { description: MessageDescriptor };
|
||||
} = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,368 +1,307 @@
|
||||
import type { Prisma, User } from '@prisma/client';
|
||||
import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client';
|
||||
import {
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
TeamMemberRole,
|
||||
} from '@prisma/client';
|
||||
import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
|
||||
import type { DB } from '@documenso/prisma/generated/types';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
import { STATS_COUNT_CAP } from '../../constants/document';
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
// Kysely query builder type for Envelope queries.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type EnvelopeQueryBuilder = SelectQueryBuilder<DB, 'Envelope', any>;
|
||||
|
||||
// Expression builder type scoped to Envelope table context.
|
||||
type EnvelopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
|
||||
type RecipientExpressionBuilder = ExpressionBuilder<DB, 'Recipient'>;
|
||||
|
||||
/**
|
||||
* Reusable EXISTS subquery: checks that a Recipient row exists for the given
|
||||
* envelope with the given email, plus optional extra conditions.
|
||||
*/
|
||||
const recipientExists = (
|
||||
eb: EnvelopeExpressionBuilder,
|
||||
email: string,
|
||||
extra?: (qb: RecipientExpressionBuilder) => Expression<SqlBool>,
|
||||
) => {
|
||||
let sub = eb
|
||||
.selectFrom('Recipient')
|
||||
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
|
||||
.where('Recipient.email', '=', email);
|
||||
|
||||
if (extra) {
|
||||
sub = sub.where(extra);
|
||||
}
|
||||
|
||||
return eb.exists(sub.select(sql.lit(1).as('one')));
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable EXISTS subquery: checks that the envelope's sender (User) has the given email.
|
||||
*/
|
||||
const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('User')
|
||||
.whereRef('User.id', '=', 'Envelope.userId')
|
||||
.where('User.email', '=', email)
|
||||
.select(sql.lit(1).as('one')),
|
||||
);
|
||||
|
||||
export type GetStatsInput = {
|
||||
user: Pick<User, 'id' | 'email'>;
|
||||
team?: Omit<GetTeamCountsOption, 'createdAt'>;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
period?: PeriodSelectorValue;
|
||||
search?: string;
|
||||
folderId?: string;
|
||||
senderIds?: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a capped count from a query builder: wraps it as
|
||||
* `SELECT COUNT(*) FROM (SELECT id FROM ... LIMIT cap+1) sub`
|
||||
* and clamps the result to STATS_COUNT_CAP.
|
||||
*/
|
||||
const cappedCount = async (qb: EnvelopeQueryBuilder): Promise<number> => {
|
||||
const result = await kyselyPrisma.$kysely
|
||||
.selectFrom(
|
||||
qb
|
||||
.clearSelect()
|
||||
.select('Envelope.id')
|
||||
.limit(STATS_COUNT_CAP + 1)
|
||||
.as('capped'),
|
||||
)
|
||||
.select(({ fn }) => fn.count<number>('id').as('total'))
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return Math.min(Number(result.total ?? 0), STATS_COUNT_CAP);
|
||||
};
|
||||
|
||||
export const getStats = async ({
|
||||
user,
|
||||
userId,
|
||||
teamId,
|
||||
period,
|
||||
search = '',
|
||||
folderId,
|
||||
...options
|
||||
senderIds,
|
||||
}: GetStatsInput) => {
|
||||
let createdAt: Prisma.EnvelopeWhereInput['createdAt'];
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
const teamEmail = team.teamEmail?.email ?? null;
|
||||
const currentTeamRole = team.currentTeamRole ?? TeamMemberRole.MEMBER;
|
||||
const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[currentTeamRole];
|
||||
|
||||
createdAt = {
|
||||
gte: startOfPeriod.toJSDate(),
|
||||
};
|
||||
}
|
||||
const searchQuery = search.trim();
|
||||
const hasSearch = searchQuery.length > 0;
|
||||
const searchPattern = `%${searchQuery}%`;
|
||||
|
||||
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
|
||||
? getTeamCounts({
|
||||
...options.team,
|
||||
createdAt,
|
||||
currentUserEmail: user.email,
|
||||
userId: user.id,
|
||||
search,
|
||||
folderId,
|
||||
})
|
||||
: getCounts({ user, createdAt, search, folderId }));
|
||||
// ─── Base query builder ──────────────────────────────────────────────
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.INBOX]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
const buildBaseQuery = (): EnvelopeQueryBuilder => {
|
||||
let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely
|
||||
.selectFrom('Envelope')
|
||||
.select('Envelope.id');
|
||||
|
||||
// Type = DOCUMENT
|
||||
qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT));
|
||||
|
||||
// Folder filter
|
||||
qb =
|
||||
folderId !== undefined
|
||||
? qb.where('Envelope.folderId', '=', folderId)
|
||||
: qb.where('Envelope.folderId', 'is', null);
|
||||
|
||||
// Period filter
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
qb = qb.where('Envelope.createdAt', '>=', startOfPeriod.toJSDate());
|
||||
}
|
||||
|
||||
// Sender filter
|
||||
if (senderIds && senderIds.length > 0) {
|
||||
qb = qb.where('Envelope.userId', 'in', senderIds);
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (hasSearch) {
|
||||
qb = qb.where(({ or, eb }) =>
|
||||
or([
|
||||
eb('Envelope.title', 'ilike', searchPattern),
|
||||
eb('Envelope.externalId', 'ilike', searchPattern),
|
||||
eb(
|
||||
'Envelope.id',
|
||||
'in',
|
||||
eb
|
||||
.selectFrom('Recipient')
|
||||
.select('Recipient.envelopeId')
|
||||
.where(({ or: innerOr, eb: innerEb }) =>
|
||||
innerOr([
|
||||
innerEb('Recipient.email', 'ilike', searchPattern),
|
||||
innerEb('Recipient.name', 'ilike', searchPattern),
|
||||
]),
|
||||
)
|
||||
.distinct()
|
||||
.limit(1000),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return qb;
|
||||
};
|
||||
|
||||
ownerCounts.forEach((stat) => {
|
||||
stats[stat.status] = stat._count._all;
|
||||
});
|
||||
// ─── Shared filter helpers ───────────────────────────────────────────
|
||||
|
||||
notSignedCounts.forEach((stat) => {
|
||||
stats[ExtendedDocumentStatus.INBOX] += stat._count._all;
|
||||
});
|
||||
const visibilityFilter = (eb: EnvelopeExpressionBuilder) =>
|
||||
eb.or([
|
||||
eb(
|
||||
'Envelope.visibility',
|
||||
'in',
|
||||
allowedVisibilities.map((v) => sql.lit(v)),
|
||||
),
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email),
|
||||
]);
|
||||
|
||||
hasSignedCounts.forEach((stat) => {
|
||||
if (stat.status === ExtendedDocumentStatus.COMPLETED) {
|
||||
stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all;
|
||||
const teamDeletedFilter = (eb: EnvelopeExpressionBuilder) => {
|
||||
const branches = [
|
||||
eb.and([eb('Envelope.teamId', '=', team.id), eb('Envelope.deletedAt', 'is', null)]),
|
||||
];
|
||||
|
||||
if (teamEmail) {
|
||||
branches.push(eb.and([senderEmailIs(eb, teamEmail), eb('Envelope.deletedAt', 'is', null)]));
|
||||
branches.push(
|
||||
recipientExists(eb, teamEmail, (reb) => reb('Recipient.documentDeletedAt', 'is', null)),
|
||||
);
|
||||
}
|
||||
|
||||
if (stat.status === ExtendedDocumentStatus.PENDING) {
|
||||
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
|
||||
}
|
||||
return eb.or(branches);
|
||||
};
|
||||
|
||||
if (stat.status === ExtendedDocumentStatus.REJECTED) {
|
||||
stats[ExtendedDocumentStatus.REJECTED] += stat._count._all;
|
||||
}
|
||||
});
|
||||
// ─── Per-status query builders ───────────────────────────────────────
|
||||
|
||||
Object.keys(stats).forEach((key) => {
|
||||
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
|
||||
stats[ExtendedDocumentStatus.ALL] += stats[key];
|
||||
}
|
||||
});
|
||||
// DRAFT: team-owned drafts visible to the user
|
||||
const draftQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT))
|
||||
.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// PENDING: team-owned pending + team-email signed-pending docs
|
||||
const pendingQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
|
||||
.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// COMPLETED: team-owned completed + team-email received completed
|
||||
const completedQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// REJECTED: team-owned rejected + team-email rejected docs
|
||||
const rejectedQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED))
|
||||
.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient
|
||||
// Returns 0 if the team has no team email.
|
||||
const inboxQuery = teamEmail
|
||||
? buildBaseQuery()
|
||||
.where('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
visibilityFilter(eb),
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.documentDeletedAt', 'is', null),
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
)
|
||||
: null;
|
||||
|
||||
// ─── Execute all counts in parallel ──────────────────────────────────
|
||||
|
||||
const [draft, pending, completed, rejected, inbox] = await Promise.all([
|
||||
cappedCount(draftQuery),
|
||||
cappedCount(pendingQuery),
|
||||
cappedCount(completedQuery),
|
||||
cappedCount(rejectedQuery),
|
||||
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
|
||||
]);
|
||||
|
||||
const all = Math.min(draft + pending + completed + rejected + inbox, STATS_COUNT_CAP);
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: draft,
|
||||
[ExtendedDocumentStatus.PENDING]: pending,
|
||||
[ExtendedDocumentStatus.COMPLETED]: completed,
|
||||
[ExtendedDocumentStatus.REJECTED]: rejected,
|
||||
[ExtendedDocumentStatus.INBOX]: inbox,
|
||||
[ExtendedDocumentStatus.ALL]: all,
|
||||
};
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
type GetCountsOption = {
|
||||
user: Pick<User, 'id' | 'email'>;
|
||||
createdAt: Prisma.EnvelopeWhereInput['createdAt'];
|
||||
search?: string;
|
||||
folderId?: string | null;
|
||||
};
|
||||
|
||||
const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption) => {
|
||||
const searchFilter: Prisma.EnvelopeWhereInput = {
|
||||
OR: [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ recipients: { some: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
{ recipients: { some: { email: { contains: search, mode: 'insensitive' } } } },
|
||||
],
|
||||
};
|
||||
|
||||
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
|
||||
|
||||
return Promise.all([
|
||||
// Owner counts.
|
||||
prisma.envelope.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
createdAt,
|
||||
deletedAt: null,
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
}),
|
||||
// Not signed counts.
|
||||
prisma.envelope.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
createdAt,
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
}),
|
||||
// Has signed counts.
|
||||
prisma.envelope.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
createdAt,
|
||||
user: {
|
||||
email: {
|
||||
not: user.email,
|
||||
},
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
type GetTeamCountsOption = {
|
||||
teamId: number;
|
||||
teamEmail?: string;
|
||||
senderIds?: number[];
|
||||
currentUserEmail: string;
|
||||
userId: number;
|
||||
createdAt: Prisma.EnvelopeWhereInput['createdAt'];
|
||||
currentTeamMemberRole?: TeamMemberRole;
|
||||
search?: string;
|
||||
folderId?: string | null;
|
||||
};
|
||||
|
||||
const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
const { createdAt, teamId, teamEmail, folderId } = options;
|
||||
|
||||
const senderIds = options.senderIds ?? [];
|
||||
|
||||
const userIdWhereClause: Prisma.EnvelopeWhereInput['userId'] =
|
||||
senderIds.length > 0
|
||||
? {
|
||||
in: senderIds,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const searchFilter: Prisma.EnvelopeWhereInput = {
|
||||
OR: [
|
||||
{ title: { contains: options.search, mode: 'insensitive' } },
|
||||
{ recipients: { some: { name: { contains: options.search, mode: 'insensitive' } } } },
|
||||
{ recipients: { some: { email: { contains: options.search, mode: 'insensitive' } } } },
|
||||
],
|
||||
};
|
||||
|
||||
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
|
||||
|
||||
let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
teamId,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
let notSignedCountsGroupByArgs = null;
|
||||
let hasSignedCountsGroupByArgs = null;
|
||||
|
||||
const visibilityFiltersWhereInput: Prisma.EnvelopeWhereInput = {
|
||||
AND: [
|
||||
{ deletedAt: null },
|
||||
{
|
||||
OR: [
|
||||
match(options.currentTeamMemberRole)
|
||||
.with(TeamMemberRole.ADMIN, () => ({
|
||||
visibility: {
|
||||
in: [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
],
|
||||
},
|
||||
}))
|
||||
.with(TeamMemberRole.MANAGER, () => ({
|
||||
visibility: {
|
||||
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
|
||||
},
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
visibility: {
|
||||
equals: DocumentVisibility.EVERYONE,
|
||||
},
|
||||
})),
|
||||
{
|
||||
OR: [
|
||||
{ userId: options.userId },
|
||||
{ recipients: { some: { email: options.currentUserEmail } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
ownerCountsWhereInput = {
|
||||
...ownerCountsWhereInput,
|
||||
AND: [
|
||||
...(Array.isArray(visibilityFiltersWhereInput.AND)
|
||||
? visibilityFiltersWhereInput.AND
|
||||
: visibilityFiltersWhereInput.AND
|
||||
? [visibilityFiltersWhereInput.AND]
|
||||
: []),
|
||||
searchFilter,
|
||||
rootPageFilter,
|
||||
folderId ? { folderId } : {},
|
||||
],
|
||||
};
|
||||
|
||||
if (teamEmail) {
|
||||
ownerCountsWhereInput = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
OR: [
|
||||
{
|
||||
teamId,
|
||||
},
|
||||
{
|
||||
user: {
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
],
|
||||
deletedAt: null,
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
};
|
||||
|
||||
notSignedCountsGroupByArgs = {
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
} satisfies Prisma.EnvelopeGroupByArgs;
|
||||
|
||||
hasSignedCountsGroupByArgs = {
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
recipients: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
} satisfies Prisma.EnvelopeGroupByArgs;
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
prisma.envelope.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: ownerCountsWhereInput,
|
||||
}),
|
||||
notSignedCountsGroupByArgs ? prisma.envelope.groupBy(notSignedCountsGroupByArgs) : [],
|
||||
hasSignedCountsGroupByArgs ? prisma.envelope.groupBy(hasSignedCountsGroupByArgs) : [],
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type {
|
||||
DocumentSource,
|
||||
DocumentStatus,
|
||||
Envelope,
|
||||
EnvelopeType,
|
||||
Prisma,
|
||||
} from '@prisma/client';
|
||||
import type { DocumentSource, DocumentStatus, Envelope, EnvelopeType } from '@prisma/client';
|
||||
import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
|
||||
import type { DB } from '@documenso/prisma/generated/types';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
@@ -28,8 +24,77 @@ export type FindEnvelopesOptions = {
|
||||
};
|
||||
query?: string;
|
||||
folderId?: string;
|
||||
/**
|
||||
* When true (default), use a windowed count that caps early for faster pagination.
|
||||
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
|
||||
*/
|
||||
useWindowedCount?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The number of pages ahead of the current page we'll scan for pagination.
|
||||
*
|
||||
* Instead of COUNT(*) over the entire result set (which must scan all qualifying rows),
|
||||
* we fetch at most `offset + COUNT_WINDOW_SIZE * perPage + 1` IDs. This lets Postgres
|
||||
* stop early once it has enough rows.
|
||||
*/
|
||||
const COUNT_WINDOW_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Cap for the recipient search subquery. When searching by recipient email/name,
|
||||
* we pre-compute matching envelope IDs up to this limit to prevent pathological
|
||||
* heap scans on broad searches.
|
||||
*/
|
||||
const RECIPIENT_SEARCH_CAP = 1000;
|
||||
|
||||
// Kysely query builder type for Envelope queries.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type EnvelopeQueryBuilder = SelectQueryBuilder<DB, 'Envelope', any>;
|
||||
|
||||
// Expression builder type scoped to Envelope table context.
|
||||
type EnvelopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
|
||||
type RecipientExpressionBuilder = ExpressionBuilder<DB, 'Recipient'>;
|
||||
|
||||
/**
|
||||
* Reusable EXISTS subquery: checks that a Recipient row exists for the given
|
||||
* envelope with the given email, plus optional extra conditions.
|
||||
*/
|
||||
const recipientExists = (
|
||||
eb: EnvelopeExpressionBuilder,
|
||||
email: string,
|
||||
extra?: (qb: RecipientExpressionBuilder) => Expression<SqlBool>,
|
||||
) => {
|
||||
let sub = eb
|
||||
.selectFrom('Recipient')
|
||||
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
|
||||
.where('Recipient.email', '=', email);
|
||||
|
||||
if (extra) {
|
||||
sub = sub.where(extra);
|
||||
}
|
||||
|
||||
return eb.exists(sub.select(sql.lit(1).as('one')));
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable EXISTS subquery: checks that the envelope's sender (User) has the given email.
|
||||
*/
|
||||
const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('User')
|
||||
.whereRef('User.id', '=', 'Envelope.userId')
|
||||
.where('User.email', '=', email)
|
||||
.select(sql.lit(1).as('one')),
|
||||
);
|
||||
|
||||
/**
|
||||
* Find envelopes visible to the requesting user within a team.
|
||||
*
|
||||
* Unlike `findDocuments` (used by the UI), being a recipient does NOT override
|
||||
* document visibility. A user will only see an envelope if its visibility level
|
||||
* is within their role's threshold, or they are the document owner.
|
||||
*/
|
||||
export const findEnvelopes = async ({
|
||||
userId,
|
||||
teamId,
|
||||
@@ -42,134 +107,175 @@ export const findEnvelopes = async ({
|
||||
orderBy,
|
||||
query = '',
|
||||
folderId,
|
||||
useWindowedCount = true,
|
||||
}: FindEnvelopesOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, name: true },
|
||||
});
|
||||
|
||||
const team = await getTeamById({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
const searchQuery = query.trim();
|
||||
const hasSearch = searchQuery.length > 0;
|
||||
const searchPattern = `%${searchQuery}%`;
|
||||
|
||||
const searchFilter: Prisma.EnvelopeWhereInput = query
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ externalId: { contains: query, mode: 'insensitive' } },
|
||||
{ recipients: { some: { name: { contains: query, mode: 'insensitive' } } } },
|
||||
{ recipients: { some: { email: { contains: query, mode: 'insensitive' } } } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
const teamEmail = team.teamEmail?.email ?? null;
|
||||
const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole];
|
||||
|
||||
const visibilityFilter: Prisma.EnvelopeWhereInput = {
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
};
|
||||
// ─── Build Kysely query ──────────────────────────────────────────────
|
||||
|
||||
const teamEmailFilters: Prisma.EnvelopeWhereInput[] = [];
|
||||
let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely
|
||||
.selectFrom('Envelope')
|
||||
.select(['Envelope.id', 'Envelope.createdAt']);
|
||||
|
||||
if (team.teamEmail) {
|
||||
teamEmailFilters.push(
|
||||
{
|
||||
user: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Folder filter
|
||||
qb =
|
||||
folderId !== undefined
|
||||
? qb.where('Envelope.folderId', '=', folderId)
|
||||
: qb.where('Envelope.folderId', 'is', null);
|
||||
|
||||
// Exclude soft-deleted envelopes
|
||||
qb = qb.where('Envelope.deletedAt', 'is', null);
|
||||
|
||||
// Type filter (enum cast)
|
||||
if (type) {
|
||||
qb = qb.where('Envelope.type', '=', sql.lit(type));
|
||||
}
|
||||
|
||||
// Template filter
|
||||
if (templateId) {
|
||||
qb = qb.where('Envelope.templateId', '=', templateId);
|
||||
}
|
||||
|
||||
// Source filter (enum cast)
|
||||
if (source) {
|
||||
qb = qb.where('Envelope.source', '=', sql.lit(source));
|
||||
}
|
||||
|
||||
// Status filter (enum cast)
|
||||
if (status) {
|
||||
qb = qb.where('Envelope.status', '=', sql.lit(status));
|
||||
}
|
||||
|
||||
// Search filter: title, externalId, or recipient match via capped subquery
|
||||
if (hasSearch) {
|
||||
qb = qb.where(({ or, eb }) =>
|
||||
or([
|
||||
eb('Envelope.title', 'ilike', searchPattern),
|
||||
eb('Envelope.externalId', 'ilike', searchPattern),
|
||||
eb(
|
||||
'Envelope.id',
|
||||
'in',
|
||||
eb
|
||||
.selectFrom('Recipient')
|
||||
.select('Recipient.envelopeId')
|
||||
.where(({ or: innerOr, eb: innerEb }) =>
|
||||
innerOr([
|
||||
innerEb('Recipient.email', 'ilike', searchPattern),
|
||||
innerEb('Recipient.name', 'ilike', searchPattern),
|
||||
]),
|
||||
)
|
||||
.distinct()
|
||||
.limit(RECIPIENT_SEARCH_CAP),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const whereClause: Prisma.EnvelopeWhereInput = {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
...visibilityFilter,
|
||||
},
|
||||
{
|
||||
userId,
|
||||
},
|
||||
...teamEmailFilters,
|
||||
],
|
||||
},
|
||||
{
|
||||
folderId: folderId ?? null,
|
||||
deletedAt: null,
|
||||
},
|
||||
searchFilter,
|
||||
],
|
||||
};
|
||||
// ─── Access control ──────────────────────────────────────────────────
|
||||
//
|
||||
// An envelope is visible if ANY of:
|
||||
// 1. It belongs to this team AND (meets the visibility threshold OR the requesting user is the owner)
|
||||
// 2. (If team email) The sender's email matches the team email
|
||||
// 3. (If team email) A recipient's email matches the team email
|
||||
|
||||
if (type) {
|
||||
whereClause.type = type;
|
||||
}
|
||||
const visibilityFilter = (eb: EnvelopeExpressionBuilder) =>
|
||||
eb.or([
|
||||
eb(
|
||||
'Envelope.visibility',
|
||||
'in',
|
||||
allowedVisibilities.map((v) => sql.lit(v)),
|
||||
),
|
||||
// Owner always sees their own docs within this team
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
]);
|
||||
|
||||
if (templateId) {
|
||||
whereClause.templateId = templateId;
|
||||
}
|
||||
qb = qb.where((eb) => {
|
||||
const accessBranches: Expression<SqlBool>[] = [
|
||||
// Team docs that pass visibility (or are owned by the user)
|
||||
eb.and([eb('Envelope.teamId', '=', team.id), visibilityFilter(eb)]),
|
||||
];
|
||||
|
||||
if (source) {
|
||||
whereClause.source = source;
|
||||
}
|
||||
if (teamEmail) {
|
||||
// Docs sent by the team email user
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
// Docs received by the team email
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
if (status) {
|
||||
whereClause.status = status;
|
||||
}
|
||||
return eb.or(accessBranches);
|
||||
});
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.envelope.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
[orderByColumn]: orderByDirection,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
recipients: {
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
// ─── Execute: paginated data + count ──────────────────────────────────
|
||||
|
||||
const offset = Math.max(page - 1, 0) * perPage;
|
||||
|
||||
const dataQuery = qb
|
||||
.orderBy(`Envelope.${orderByColumn}`, orderByDirection)
|
||||
.limit(perPage)
|
||||
.offset(offset);
|
||||
|
||||
// Count query: either windowed (fast, capped) or full (exact, for API consumers).
|
||||
const baseCountQuery = qb.clearSelect().select('Envelope.id');
|
||||
|
||||
const countQuery = useWindowedCount
|
||||
? kyselyPrisma.$kysely
|
||||
.selectFrom(baseCountQuery.limit(offset + COUNT_WINDOW_SIZE * perPage + 1).as('windowed'))
|
||||
.select(({ fn }) => fn.count<number>('id').as('total'))
|
||||
: kyselyPrisma.$kysely
|
||||
.selectFrom(baseCountQuery.as('filtered'))
|
||||
.select(({ fn }) => fn.count<number>('id').as('total'));
|
||||
|
||||
const [dataResult, countResult] = await Promise.all([
|
||||
dataQuery.execute(),
|
||||
countQuery.executeTakeFirstOrThrow(),
|
||||
]);
|
||||
|
||||
const ids = dataResult.map((row) => row.id);
|
||||
|
||||
const totalCount = useWindowedCount
|
||||
? Math.min(Number(countResult.total ?? 0), offset + COUNT_WINDOW_SIZE * perPage)
|
||||
: Number(countResult.total ?? 0);
|
||||
|
||||
// ─── Hydrate with Prisma ─────────────────────────────────────────────
|
||||
|
||||
if (ids.length === 0) {
|
||||
return {
|
||||
data: [],
|
||||
count: totalCount,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(totalCount / perPage),
|
||||
} satisfies FindResultResponse<never[]>;
|
||||
}
|
||||
|
||||
const data = await prisma.envelope.findMany({
|
||||
where: { id: { in: ids } },
|
||||
orderBy: { [orderByColumn]: orderByDirection },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
recipients: { orderBy: { id: 'asc' } },
|
||||
team: { select: { id: true, url: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Preserve ordering from the Kysely query
|
||||
const idOrder = new Map(ids.map((id, index) => [id, index]));
|
||||
data.sort((a, b) => (idOrder.get(a.id) ?? 0) - (idOrder.get(b.id) ?? 0));
|
||||
|
||||
const maskedData = data.map((envelope) =>
|
||||
maskRecipientTokensForDocument({
|
||||
document: envelope,
|
||||
@@ -189,9 +295,9 @@ export const findEnvelopes = async ({
|
||||
|
||||
return {
|
||||
data: mappedData,
|
||||
count,
|
||||
count: totalCount,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
totalPages: Math.ceil(totalCount / perPage),
|
||||
} satisfies FindResultResponse<typeof mappedData>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- CreateExtension
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "Recipient_email_documentDeletedAt_envelopeId_idx" ON "Recipient"("email", "documentDeletedAt", "envelopeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "Recipient_email_envelopeId_idx" ON "Recipient"("email", "envelopeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "Recipient_email_signingStatus_envelopeId_role_idx" ON "Recipient"("email", "signingStatus", "envelopeId", "role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "Recipient_email_trgm_idx" ON "Recipient" USING GIN ("email" gin_trgm_ops);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "Recipient_name_trgm_idx" ON "Recipient" USING GIN ("name" gin_trgm_ops);
|
||||
@@ -598,6 +598,11 @@ model Recipient {
|
||||
@@index([envelopeId])
|
||||
@@index([signedAt])
|
||||
@@index([expiresAt])
|
||||
@@index([email, documentDeletedAt, envelopeId], map: "Recipient_email_documentDeletedAt_envelopeId_idx")
|
||||
@@index([email, envelopeId], map: "Recipient_email_envelopeId_idx")
|
||||
@@index([email, signingStatus, envelopeId, role], map: "Recipient_email_signingStatus_envelopeId_role_idx")
|
||||
@@index([email(ops: raw("gin_trgm_ops"))], map: "Recipient_email_trgm_idx", type: Gin)
|
||||
@@index([name(ops: raw("gin_trgm_ops"))], map: "Recipient_name_trgm_idx", type: Gin)
|
||||
}
|
||||
|
||||
enum FieldType {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { mapEnvelopesToDocumentMany } from '@documenso/lib/utils/document';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
@@ -30,28 +28,15 @@ export const findDocumentsInternalRoute = authenticatedProcedure
|
||||
folderId,
|
||||
} = input;
|
||||
|
||||
const getStatOptions: GetStatsInput = {
|
||||
user,
|
||||
period,
|
||||
search: query,
|
||||
folderId,
|
||||
};
|
||||
|
||||
if (teamId) {
|
||||
const team = await getTeamById({ userId: user.id, teamId });
|
||||
|
||||
getStatOptions.team = {
|
||||
teamId: team.id,
|
||||
teamEmail: team.teamEmail?.email,
|
||||
senderIds,
|
||||
currentTeamMemberRole: team.currentTeamRole,
|
||||
currentUserEmail: user.email,
|
||||
userId: user.id,
|
||||
};
|
||||
}
|
||||
|
||||
const [stats, documents] = await Promise.all([
|
||||
getStats(getStatOptions),
|
||||
getStats({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
period,
|
||||
search: query,
|
||||
folderId,
|
||||
senderIds,
|
||||
}),
|
||||
findDocuments({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
|
||||
@@ -38,6 +38,7 @@ export const findDocumentsRoute = authenticatedProcedure
|
||||
perPage,
|
||||
folderId,
|
||||
orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined,
|
||||
useWindowedCount: false,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -52,5 +52,6 @@ export const findEnvelopesRoute = authenticatedProcedure
|
||||
perPage,
|
||||
folderId,
|
||||
orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined,
|
||||
useWindowedCount: false,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user