mirror of
https://github.com/documenso/documenso
synced 2026-04-21 21:37:18 +00:00
534 lines
19 KiB
TypeScript
534 lines
19 KiB
TypeScript
import type { DocumentSource, Envelope, Team, TeamEmail } from '@prisma/client';
|
|
import { DocumentVisibility } from '@prisma/client';
|
|
import { DocumentStatus } from '@prisma/client';
|
|
import { 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 { kyselyPrisma, prisma, sql } from '@documenso/prisma';
|
|
import type { DB } from '@documenso/prisma/generated/types';
|
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
|
|
|
import { type FindResultResponse } from '../../types/search-params';
|
|
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
|
import { getTeamById } from '../team/get-team';
|
|
|
|
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
|
|
|
export type FindDocumentsOptions = {
|
|
userId: number;
|
|
teamId?: number;
|
|
templateId?: number;
|
|
source?: DocumentSource;
|
|
status?: ExtendedDocumentStatus;
|
|
page?: number;
|
|
perPage?: number;
|
|
orderBy?: {
|
|
column: keyof Pick<Envelope, 'createdAt'>;
|
|
direction: 'asc' | 'desc';
|
|
};
|
|
period?: PeriodSelectorValue;
|
|
senderIds?: number[];
|
|
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. The offset ensures the count always reaches past
|
|
* the current page, and the window provides look-ahead for the pagination UI.
|
|
*/
|
|
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. This prevents
|
|
* pathological cases where a broad search (e.g. "gmail") matches millions of
|
|
* recipients and causes a heap scan.
|
|
*/
|
|
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')),
|
|
);
|
|
|
|
export const findDocuments = async ({
|
|
userId,
|
|
teamId,
|
|
templateId,
|
|
source,
|
|
status = ExtendedDocumentStatus.ALL,
|
|
page = 1,
|
|
perPage = 10,
|
|
orderBy,
|
|
period,
|
|
senderIds,
|
|
query = '',
|
|
folderId,
|
|
useWindowedCount = true,
|
|
}: FindDocumentsOptions) => {
|
|
const user = await prisma.user.findFirstOrThrow({
|
|
where: { id: userId },
|
|
select: { id: true, email: true, name: true },
|
|
});
|
|
|
|
let team = null;
|
|
|
|
if (teamId !== undefined) {
|
|
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}%`;
|
|
|
|
// ─── Base query with common filters ──────────────────────────────────
|
|
//
|
|
// Every code path starts from this base: Envelope rows filtered by type,
|
|
// folder, period, sender, source, template, and search.
|
|
|
|
const buildBaseQuery = () => {
|
|
let qb = kyselyPrisma.$kysely
|
|
.selectFrom('Envelope')
|
|
.select(['Envelope.id', 'Envelope.createdAt']);
|
|
|
|
// Type must be DOCUMENT (enum cast requires raw sql — this is the one escape hatch)
|
|
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);
|
|
}
|
|
|
|
// Source filter (enum cast)
|
|
if (source) {
|
|
qb = qb.where('Envelope.source', '=', sql.lit(source));
|
|
}
|
|
|
|
// Template filter
|
|
if (templateId) {
|
|
qb = qb.where('Envelope.templateId', '=', templateId);
|
|
}
|
|
|
|
// 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),
|
|
// Capped recipient search subquery (uses trigram indexes)
|
|
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),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
|
|
return qb;
|
|
};
|
|
|
|
// ─── Personal path filters ───────────────────────────────────────────
|
|
|
|
const applyPersonalFilters = (qb: EnvelopeQueryBuilder): EnvelopeQueryBuilder | null => {
|
|
// Deleted filter: owned → deletedAt IS NULL, received → documentDeletedAt IS NULL
|
|
const personalDeletedFilter = (eb: EnvelopeExpressionBuilder) =>
|
|
eb.or([
|
|
eb.and([eb('Envelope.userId', '=', user.id), eb('Envelope.deletedAt', 'is', null)]),
|
|
recipientExists(eb, user.email, (reb) => reb('Recipient.documentDeletedAt', 'is', null)),
|
|
]);
|
|
|
|
return match<ExtendedDocumentStatus, EnvelopeQueryBuilder | null>(status)
|
|
.with(ExtendedDocumentStatus.ALL, () =>
|
|
qb.where((eb) =>
|
|
eb.and([
|
|
personalDeletedFilter(eb),
|
|
eb.or([
|
|
eb('Envelope.userId', '=', user.id),
|
|
eb.and([
|
|
eb('Envelope.status', 'in', [
|
|
sql.lit(DocumentStatus.COMPLETED),
|
|
sql.lit(DocumentStatus.PENDING),
|
|
]),
|
|
recipientExists(eb, user.email),
|
|
]),
|
|
]),
|
|
]),
|
|
),
|
|
)
|
|
.with(ExtendedDocumentStatus.INBOX, () =>
|
|
qb.where('Envelope.status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) =>
|
|
// Single EXISTS check: the recipient must be NOT_SIGNED, non-CC, and
|
|
// not soft-deleted. This replaces the previous personalDeletedFilter +
|
|
// separate recipientExists pair, eliminating a hashed SubPlan that
|
|
// materialised all recipient rows for this email (~125k for heavy users).
|
|
recipientExists(eb, user.email, (reb) =>
|
|
reb.and([
|
|
reb('Recipient.documentDeletedAt', 'is', null),
|
|
reb('signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)),
|
|
reb('role', '!=', sql.lit(RecipientRole.CC)),
|
|
]),
|
|
),
|
|
),
|
|
)
|
|
.with(ExtendedDocumentStatus.DRAFT, () =>
|
|
qb
|
|
.where('Envelope.userId', '=', user.id)
|
|
.where('Envelope.deletedAt', 'is', null)
|
|
.where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT)),
|
|
)
|
|
.with(ExtendedDocumentStatus.PENDING, () =>
|
|
qb
|
|
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
|
|
.where((eb) =>
|
|
eb.and([
|
|
personalDeletedFilter(eb),
|
|
eb.or([
|
|
eb('Envelope.userId', '=', user.id),
|
|
recipientExists(eb, user.email, (reb) =>
|
|
reb.and([
|
|
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
|
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
|
]),
|
|
),
|
|
]),
|
|
]),
|
|
),
|
|
)
|
|
.with(ExtendedDocumentStatus.COMPLETED, () =>
|
|
qb
|
|
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.COMPLETED))
|
|
.where((eb) =>
|
|
eb.and([
|
|
personalDeletedFilter(eb),
|
|
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
|
]),
|
|
),
|
|
)
|
|
.with(ExtendedDocumentStatus.REJECTED, () =>
|
|
qb
|
|
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.REJECTED))
|
|
.where((eb) =>
|
|
eb.and([
|
|
personalDeletedFilter(eb),
|
|
eb.or([
|
|
eb('Envelope.userId', '=', user.id),
|
|
recipientExists(eb, user.email, (reb) =>
|
|
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
|
),
|
|
]),
|
|
]),
|
|
),
|
|
)
|
|
.exhaustive();
|
|
};
|
|
|
|
// ─── Team path filters ───────────────────────────────────────────────
|
|
|
|
const applyTeamFilters = (
|
|
qb: EnvelopeQueryBuilder,
|
|
teamData: Team & { teamEmail: TeamEmail | null; currentTeamRole: TeamMemberRole },
|
|
): EnvelopeQueryBuilder | null => {
|
|
const teamEmail = teamData.teamEmail?.email ?? null;
|
|
|
|
const allowedVisibilities = match(teamData.currentTeamRole)
|
|
.with(TeamMemberRole.ADMIN, () => [
|
|
DocumentVisibility.EVERYONE,
|
|
DocumentVisibility.MANAGER_AND_ABOVE,
|
|
DocumentVisibility.ADMIN,
|
|
])
|
|
.with(TeamMemberRole.MANAGER, () => [
|
|
DocumentVisibility.EVERYONE,
|
|
DocumentVisibility.MANAGER_AND_ABOVE,
|
|
])
|
|
.otherwise(() => [DocumentVisibility.EVERYONE]);
|
|
|
|
// Visibility: meets role threshold OR directly involved
|
|
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),
|
|
]);
|
|
|
|
// Deleted filter for team path
|
|
const teamDeletedFilter = (eb: EnvelopeExpressionBuilder) => {
|
|
const branches = [
|
|
eb.and([eb('Envelope.teamId', '=', teamData.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)),
|
|
);
|
|
}
|
|
|
|
return eb.or(branches);
|
|
};
|
|
|
|
return match<ExtendedDocumentStatus, EnvelopeQueryBuilder | null>(status)
|
|
.with(ExtendedDocumentStatus.ALL, () =>
|
|
qb.where((eb) => {
|
|
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
|
|
|
if (teamEmail) {
|
|
accessBranches.push(senderEmailIs(eb, teamEmail));
|
|
accessBranches.push(
|
|
eb.and([
|
|
eb('status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)),
|
|
recipientExists(eb, teamEmail),
|
|
]),
|
|
);
|
|
}
|
|
|
|
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
|
}),
|
|
)
|
|
.with(ExtendedDocumentStatus.INBOX, () => {
|
|
if (!teamEmail) {
|
|
return null;
|
|
}
|
|
|
|
return qb
|
|
.where('Envelope.status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT))
|
|
.where((eb) =>
|
|
eb.and([
|
|
visibilityFilter(eb),
|
|
// Single EXISTS check: the team-email recipient must be NOT_SIGNED,
|
|
// non-CC, and not soft-deleted. Replaces teamDeletedFilter + separate
|
|
// recipientExists, eliminating a hashed SubPlan (~79k rows).
|
|
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)),
|
|
]),
|
|
),
|
|
]),
|
|
);
|
|
})
|
|
.with(ExtendedDocumentStatus.DRAFT, () =>
|
|
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) => {
|
|
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
|
|
|
if (teamEmail) {
|
|
accessBranches.push(senderEmailIs(eb, teamEmail));
|
|
}
|
|
|
|
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
|
}),
|
|
)
|
|
.with(ExtendedDocumentStatus.PENDING, () =>
|
|
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.PENDING)).where((eb) => {
|
|
const accessBranches = [eb('Envelope.teamId', '=', teamData.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)]);
|
|
}),
|
|
)
|
|
.with(ExtendedDocumentStatus.COMPLETED, () =>
|
|
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.COMPLETED)).where((eb) => {
|
|
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
|
|
|
if (teamEmail) {
|
|
accessBranches.push(senderEmailIs(eb, teamEmail));
|
|
accessBranches.push(recipientExists(eb, teamEmail));
|
|
}
|
|
|
|
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
|
}),
|
|
)
|
|
.with(ExtendedDocumentStatus.REJECTED, () =>
|
|
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.REJECTED)).where((eb) => {
|
|
const accessBranches = [eb('Envelope.teamId', '=', teamData.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)]);
|
|
}),
|
|
)
|
|
.exhaustive();
|
|
};
|
|
|
|
// ─── Assemble and execute ────────────────────────────────────────────
|
|
|
|
const baseQuery = buildBaseQuery();
|
|
|
|
const filteredQuery = team ? applyTeamFilters(baseQuery, team) : applyPersonalFilters(baseQuery);
|
|
|
|
if (filteredQuery === null) {
|
|
return {
|
|
data: [],
|
|
count: 0,
|
|
currentPage: Math.max(page, 1),
|
|
perPage,
|
|
totalPages: 0,
|
|
};
|
|
}
|
|
|
|
const offset = Math.max(page - 1, 0) * perPage;
|
|
|
|
// Data query: paginated, executed directly via Kysely query builder
|
|
const dataQuery = filteredQuery
|
|
.orderBy(`Envelope.${orderByColumn}`, orderByDirection)
|
|
.limit(perPage)
|
|
.offset(offset);
|
|
|
|
// Count query: either windowed (fast, capped) or full (exact, for API consumers).
|
|
const baseCountQuery = filteredQuery.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),
|
|
};
|
|
}
|
|
|
|
const data = await prisma.envelope.findMany({
|
|
where: { id: { in: ids } },
|
|
orderBy: { [orderByColumn]: orderByDirection },
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true } },
|
|
recipients: true,
|
|
team: { select: { id: true, url: true } },
|
|
envelopeItems: {
|
|
select: { id: true, envelopeId: true, title: true, order: 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((document) => maskRecipientTokensForDocument({ document, user }));
|
|
|
|
return {
|
|
data: maskedData,
|
|
count: totalCount,
|
|
currentPage: Math.max(page, 1),
|
|
perPage,
|
|
totalPages: Math.ceil(totalCount / perPage),
|
|
} satisfies FindResultResponse<typeof data>;
|
|
};
|