feat: add organisation template type (#2611)

This commit is contained in:
Ephraim Duncan
2026-03-16 14:29:34 +00:00
committed by GitHub
parent 943a0b50e3
commit 36bbd97514
41 changed files with 1702 additions and 279 deletions
@@ -36,6 +36,9 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
title: true,
userId: true,
internalVersion: true,
templateType: true,
publicTitle: true,
publicDescription: true,
envelopeItems: {
include: {
documentData: {
@@ -87,6 +90,11 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
}),
]);
const duplicatedTemplateType =
envelope.templateType === 'ORGANISATION' && envelope.teamId !== teamId
? 'PRIVATE'
: envelope.templateType ?? undefined;
const duplicatedEnvelope = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
@@ -99,6 +107,9 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
documentMetaId: createdDocumentMeta.id,
authOptions: envelope.authOptions || undefined,
visibility: envelope.visibility,
templateType: duplicatedTemplateType,
publicTitle: envelope.publicTitle ?? undefined,
publicDescription: envelope.publicDescription ?? undefined,
source:
envelope.type === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
},
@@ -1,5 +1,4 @@
import type { Prisma } from '@prisma/client';
import type { EnvelopeType } from '@prisma/client';
import type { EnvelopeType, Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@@ -61,6 +61,7 @@ import { incrementDocumentId } from '../envelope/increment-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id';
type FinalRecipient = Pick<
Recipient,
@@ -312,29 +313,43 @@ export const createDocumentFromTemplate = async ({
attachments,
formValues,
}: CreateDocumentFromTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
const templateInclude = {
recipients: {
include: {
fields: true,
},
},
envelopeItems: {
include: {
documentData: true,
},
},
documentMeta: true,
} as const;
const { envelopeWhereInput, team: callerTeam } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const template = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: {
include: {
fields: true,
},
},
envelopeItems: {
include: {
documentData: true,
},
},
documentMeta: true,
},
});
const [teamTemplate, organisationTemplate] = await Promise.all([
prisma.envelope.findFirst({
where: envelopeWhereInput,
include: templateInclude,
}),
prisma.envelope.findFirst({
where: getOrganisationTemplateWhereInput({
id,
organisationId: callerTeam.organisationId,
teamRole: callerTeam.currentTeamRole,
}),
include: templateInclude,
}),
]);
const template = teamTemplate ?? organisationTemplate;
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
@@ -541,7 +556,7 @@ export const createDocumentFromTemplate = async ({
templateId: legacyTemplateId, // The template this envelope was created from.
userId,
folderId,
teamId: template.teamId,
teamId,
title: finalEnvelopeTitle,
envelopeItems: {
createMany: {
@@ -0,0 +1,84 @@
import { EnvelopeType, type Prisma, TemplateType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { type FindResultResponse } from '../../types/search-params';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamById } from '../team/get-team';
export type FindOrganisationTemplatesOptions = {
userId: number;
teamId: number;
page?: number;
perPage?: number;
};
export const findOrganisationTemplates = async ({
userId,
teamId,
page = 1,
perPage = 10,
}: FindOrganisationTemplatesOptions) => {
const [team, { teamRole }] = await Promise.all([
getTeamById({ teamId, userId }),
getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
}),
]);
const where: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.TEMPLATE,
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId: team.organisationId,
},
};
const templateInclude = {
team: {
select: {
id: true,
url: true,
name: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
} as const;
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where,
include: templateInclude,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({ where }),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};
@@ -24,8 +24,6 @@ export const findTemplates = async ({
perPage = 10,
folderId,
}: FindTemplatesOptions) => {
const whereFilter: Prisma.EnvelopeWhereInput[] = [];
const { teamRole } = await getMemberRoles({
teamId,
reference: {
@@ -34,63 +32,55 @@ export const findTemplates = async ({
},
});
whereFilter.push(
{ teamId },
{
OR: [
{
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
const where: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: [
{ teamId },
{
OR: [
{
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
},
},
{ userId, teamId },
],
},
);
{ userId, teamId },
],
},
folderId ? { folderId } : { folderId: null },
],
};
if (folderId) {
whereFilter.push({ folderId });
} else {
whereFilter.push({ folderId: null });
}
const templateInclude = {
team: {
select: {
id: true,
url: true,
name: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
} as const;
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where: {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: whereFilter,
},
include: {
team: {
select: {
id: true,
url: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
},
where,
include: templateInclude,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: whereFilter,
},
}),
prisma.envelope.count({ where }),
]);
return {
@@ -0,0 +1,136 @@
import type { Prisma, TeamMemberRole } from '@prisma/client';
import { EnvelopeType, TemplateType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamById } from '../team/get-team';
export type GetOrganisationTemplateByIdOptions = {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
};
/**
* Get an organisation template by ID.
*
* This validates that the caller's team belongs to the same organisation as the template's team,
* that the template is of type ORGANISATION, and that the template's visibility is permitted
* for the caller's role on their own team.
*/
export const getOrganisationTemplateById = async ({
id,
userId,
teamId,
}: GetOrganisationTemplateByIdOptions) => {
const [callerTeam, { teamRole }] = await Promise.all([
getTeamById({ teamId, userId }),
getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
}),
]);
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.TEMPLATE),
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId: callerTeam.organisationId,
},
},
include: {
envelopeItems: {
include: {
documentData: true,
},
orderBy: {
order: 'asc',
},
},
folder: true,
documentMeta: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
orderBy: {
id: 'asc',
},
},
fields: true,
team: {
select: {
id: true,
url: true,
},
},
directLink: {
select: {
directTemplateRecipientId: true,
enabled: true,
id: true,
token: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation template not found',
});
}
return {
...envelope,
user: {
id: envelope.user.id,
name: envelope.user.name || '',
email: envelope.user.email,
},
};
};
/**
* Build a where input for querying an organisation template.
*
* Matches a TEMPLATE envelope with templateType ORGANISATION belonging to any team
* within the provided organisation, respecting the caller's team role visibility.
*/
export const getOrganisationTemplateWhereInput = ({
id,
organisationId,
teamRole,
}: {
id: EnvelopeIdOptions;
organisationId: string;
teamRole: TeamMemberRole;
}): Prisma.EnvelopeWhereInput => {
return {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.TEMPLATE),
type: EnvelopeType.TEMPLATE,
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId,
},
};
};
@@ -34,19 +34,23 @@ export const searchTemplatesWithKeyword = async ({
const teamIds = [...teamGroupsByTeamId.keys()];
const titleOrRecipientMatch: Prisma.EnvelopeWhereInput = {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
};
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' } },
},
},
],
...titleOrRecipientMatch,
},
];
@@ -55,14 +59,7 @@ export const searchTemplatesWithKeyword = async ({
filters.push({
teamId: { in: teamIds },
deletedAt: null,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
...titleOrRecipientMatch,
});
}
@@ -85,6 +82,7 @@ export const searchTemplatesWithKeyword = async ({
},
team: {
select: {
id: true,
url: true,
},
},
@@ -122,6 +120,7 @@ export const searchTemplatesWithKeyword = async ({
.slice(0, limit)
.map((envelope) => {
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
const path = `${formatTemplatesPath(envelope.team.url)}/${legacyTemplateId}`;
return {
+1
View File
@@ -146,6 +146,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
team: TeamSchema.pick({
id: true,
url: true,
name: true,
}).nullable(),
fields: ZFieldSchema.array(),
recipients: ZRecipientLiteSchema.array(),