feat: implement template search functionality (#2376)

- Added  function to handle template searches based on user input
- Introduced in the TRPC router to facilitate authenticated template
searches
- Updated to include template search results alongside document search
results
- Enhanced query handling by enabling searches only when the input is
valid
- Created corresponding Zod schemas for request and response validation
in
This commit is contained in:
Catalin Pit
2026-03-09 01:44:51 +02:00
committed by GitHub
parent 6c8726b58c
commit c4754553c9
16 changed files with 1292 additions and 927 deletions
@@ -1,16 +1,12 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import type { Envelope, Recipient, User } from '@prisma/client';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import type { Prisma } from '@prisma/client';
import { DocumentStatus, DocumentVisibility, EnvelopeType, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import {
buildTeamWhereQuery,
formatDocumentsPath,
getHighestTeamRoleInGroup,
} from '@documenso/lib/utils/teams';
import { formatDocumentsPath, getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { getUserTeamGroups } from '../team/get-user-team-groups';
export type SearchDocumentsWithKeywordOptions = {
query: string;
@@ -23,105 +19,83 @@ export const searchDocumentsWithKeyword = async ({
userId,
limit = 20,
}: SearchDocumentsWithKeywordOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
if (!query.trim()) {
return [];
}
const [user, teamGroupsByTeamId] = await Promise.all([
prisma.user.findFirstOrThrow({
where: {
id: userId,
},
}),
getUserTeamGroups({ userId }),
]);
const teamIds = [...teamGroupsByTeamId.keys()];
const filters: Prisma.EnvelopeWhereInput[] = [
// Documents owned by the user matching title, externalId, or recipient email.
{
userId,
deletedAt: null,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ externalId: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
},
});
// Documents where the user is a recipient (completed or pending).
{
status: { in: [DocumentStatus.COMPLETED, DocumentStatus.PENDING] },
recipients: { some: { email: user.email } },
title: { contains: query, mode: 'insensitive' },
deletedAt: null,
},
];
// Team documents the user has access to.
if (teamIds.length > 0) {
filters.push({
teamId: { in: teamIds },
deletedAt: null,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ externalId: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
});
}
const envelopes = await prisma.envelope.findMany({
where: {
type: EnvelopeType.DOCUMENT,
OR: [
{
title: {
contains: query,
mode: 'insensitive',
},
userId: userId,
deletedAt: null,
},
{
externalId: {
contains: query,
mode: 'insensitive',
},
userId: userId,
deletedAt: null,
},
{
recipients: {
some: {
email: {
contains: query,
mode: 'insensitive',
},
},
},
userId: userId,
deletedAt: null,
},
{
status: DocumentStatus.COMPLETED,
recipients: {
some: {
email: user.email,
},
},
title: {
contains: query,
mode: 'insensitive',
},
},
{
status: DocumentStatus.PENDING,
recipients: {
some: {
email: user.email,
},
},
title: {
contains: query,
mode: 'insensitive',
},
deletedAt: null,
},
{
title: {
contains: query,
mode: 'insensitive',
},
team: buildTeamWhereQuery({ teamId: undefined, userId }),
deletedAt: null,
},
{
externalId: {
contains: query,
mode: 'insensitive',
},
team: buildTeamWhereQuery({ teamId: undefined, userId }),
deletedAt: null,
},
],
OR: filters,
},
include: {
recipients: true,
select: {
id: true,
userId: true,
teamId: true,
title: true,
secondaryId: true,
visibility: true,
recipients: {
select: {
email: true,
token: true,
},
},
team: {
select: {
url: true,
teamGroups: {
where: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
},
},
@@ -129,29 +103,24 @@ export const searchDocumentsWithKeyword = async ({
orderBy: {
createdAt: 'desc',
},
take: limit,
// Over-fetch to compensate for post-query visibility filtering on team documents.
take: limit * 3,
});
const isOwner = (envelope: Envelope, user: User) => envelope.userId === user.id;
const getSigningLink = (recipients: Recipient[], user: User) =>
`/sign/${recipients.find((r) => r.email === user.email)?.token}`;
const maskedDocuments = envelopes
const results = envelopes
.filter((envelope) => {
if (!envelope.teamId || isOwner(envelope, user)) {
if (!envelope.teamId || envelope.userId === user.id) {
return true;
}
const teamMemberRole = getHighestTeamRoleInGroup(
envelope.team.teamGroups.filter((tg) => tg.teamId === envelope.teamId),
);
const teamGroups = teamGroupsByTeamId.get(envelope.teamId) ?? [];
const teamMemberRole = getHighestTeamRoleInGroup(teamGroups);
if (!teamMemberRole) {
return false;
}
const canAccessDocument = match([envelope.visibility, teamMemberRole])
return match([envelope.visibility, teamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
@@ -159,34 +128,29 @@ export const searchDocumentsWithKeyword = async ({
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
return canAccessDocument;
})
.slice(0, limit)
.map((envelope) => {
const { recipients, ...documentWithoutRecipient } = envelope;
let documentPath;
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (isOwner(envelope, user)) {
documentPath = `${formatDocumentsPath(envelope.team.url)}/${legacyDocumentId}`;
} else if (envelope.teamId && envelope.team.teamGroups.length > 0) {
documentPath = `${formatDocumentsPath(envelope.team.url)}/${legacyDocumentId}`;
let path: string;
if (
envelope.userId === user.id ||
(envelope.teamId && teamGroupsByTeamId.has(envelope.teamId))
) {
path = `${formatDocumentsPath(envelope.team.url)}/${legacyDocumentId}`;
} else {
documentPath = getSigningLink(recipients, user);
const signingToken = envelope.recipients.find((r) => r.email === user.email)?.token;
path = `/sign/${signingToken}`;
}
return {
...documentWithoutRecipient,
team: {
id: envelope.teamId,
url: envelope.team.url,
},
path: documentPath,
title: envelope.title,
path,
value: [envelope.id, envelope.title, ...envelope.recipients.map((r) => r.email)].join(' '),
};
});
return maskedDocuments;
return results;
};
@@ -0,0 +1,46 @@
import type { TeamGroup } from '@prisma/client';
import { prisma } from '@documenso/prisma';
export type GetUserTeamIdsOptions = {
userId: number;
};
/**
* Pre-resolve all team groups a user has access to via their organisation group memberships,
* keyed by team ID.
*
* This is significantly cheaper than joining team groups inline in a Prisma `findMany`
* because it avoids deep EXISTS subqueries and redundant LEFT JOINs per row.
*/
export const getUserTeamGroups = async ({
userId,
}: GetUserTeamIdsOptions): Promise<Map<number, TeamGroup[]>> => {
const teamGroups = await prisma.teamGroup.findMany({
where: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
});
const map = new Map<number, TeamGroup[]>();
for (const tg of teamGroups) {
const existing = map.get(tg.teamId);
if (existing) {
existing.push(tg);
} else {
map.set(tg.teamId, [tg]);
}
}
return map;
};
@@ -0,0 +1,135 @@
import type { Prisma } from '@prisma/client';
import { DocumentVisibility, EnvelopeType, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { formatTemplatesPath, getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getUserTeamGroups } from '../team/get-user-team-groups';
export type SearchTemplatesWithKeywordOptions = {
query: string;
userId: number;
limit?: number;
};
export const searchTemplatesWithKeyword = async ({
query,
userId,
limit = 20,
}: SearchTemplatesWithKeywordOptions) => {
if (!query.trim()) {
return [];
}
const [user, teamGroupsByTeamId] = await Promise.all([
prisma.user.findFirstOrThrow({
where: {
id: userId,
},
}),
getUserTeamGroups({ userId }),
]);
const teamIds = [...teamGroupsByTeamId.keys()];
const filters: Prisma.EnvelopeWhereInput[] = [
// Templates owned by the user matching title or recipient email.
{
userId,
deletedAt: null,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
},
];
// Team templates the user has access to.
if (teamIds.length > 0) {
filters.push({
teamId: { in: teamIds },
deletedAt: null,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
});
}
const envelopes = await prisma.envelope.findMany({
where: {
type: EnvelopeType.TEMPLATE,
OR: filters,
},
select: {
id: true,
userId: true,
teamId: true,
title: true,
secondaryId: true,
visibility: true,
recipients: {
select: {
email: true,
},
},
team: {
select: {
url: true,
},
},
},
distinct: ['id'],
orderBy: {
createdAt: 'desc',
},
// Over-fetch to compensate for post-query visibility filtering on team templates.
take: limit * 3,
});
const results = envelopes
.filter((envelope) => {
if (!envelope.teamId || envelope.userId === user.id) {
return true;
}
const teamGroups = teamGroupsByTeamId.get(envelope.teamId) ?? [];
const teamMemberRole = getHighestTeamRoleInGroup(teamGroups);
if (!teamMemberRole) {
return false;
}
return match([envelope.visibility, teamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
})
.slice(0, limit)
.map((envelope) => {
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
const path = `${formatTemplatesPath(envelope.team.url)}/${legacyTemplateId}`;
return {
title: envelope.title,
path,
value: [envelope.id, envelope.title, ...envelope.recipients.map((r) => r.email)].join(' '),
};
});
return results;
};