fix: merge conflicts

This commit is contained in:
Ephraim Atta-Duncan
2025-05-28 00:36:27 +00:00
237 changed files with 25580 additions and 15241 deletions

View File

@ -1,16 +1,7 @@
import { DocumentStatus, SubscriptionStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
import { kyselyPrisma, sql } from '@documenso/prisma';
export type SigningVolume = {
id: number;
name: string;
signingVolume: number;
createdAt: Date;
planId: string;
};
export type GetSigningVolumeOptions = {
type GetSigningVolumeOptions = {
search?: string;
page?: number;
perPage?: number;
@ -18,83 +9,187 @@ export type GetSigningVolumeOptions = {
sortOrder?: 'asc' | 'desc';
};
export async function getSigningVolume({
export const getSigningVolume = async ({
search = '',
page = 1,
perPage = 10,
sortBy = 'signingVolume',
sortOrder = 'desc',
}: GetSigningVolumeOptions) {
const offset = Math.max(page - 1, 0) * perPage;
}: GetSigningVolumeOptions) => {
const validPage = Math.max(1, page);
const validPerPage = Math.max(1, perPage);
const skip = (validPage - 1) * validPerPage;
let findQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
.leftJoin('Document as ud', (join) =>
join
.onRef('u.id', '=', 'ud.userId')
.on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('ud.deletedAt', 'is', null)
.on('ud.teamId', 'is', null),
)
.leftJoin('Document as td', (join) =>
join
.onRef('t.id', '=', 'td.teamId')
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('td.deletedAt', 'is', null),
)
.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([
's.id as id',
's.createdAt as createdAt',
's.planId as planId',
sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
])
.groupBy(['s.id', 'u.name', 't.name', 'u.email']);
const activeSubscriptions = await prisma.subscription.findMany({
where: {
status: SubscriptionStatus.ACTIVE,
},
select: {
id: true,
planId: true,
userId: true,
teamId: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
},
team: {
select: {
id: true,
name: true,
teamEmail: {
select: {
email: true,
},
},
createdAt: true,
},
},
},
});
switch (sortBy) {
case 'name':
findQuery = findQuery.orderBy('name', sortOrder);
break;
case 'createdAt':
findQuery = findQuery.orderBy('createdAt', sortOrder);
break;
case 'signingVolume':
findQuery = findQuery.orderBy('signingVolume', sortOrder);
break;
default:
findQuery = findQuery.orderBy('signingVolume', 'desc');
}
const userSubscriptionsMap = new Map();
const teamSubscriptionsMap = new Map();
findQuery = findQuery.limit(perPage).offset(offset);
activeSubscriptions.forEach((subscription) => {
const isTeam = !!subscription.teamId;
const countQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
.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')]);
if (isTeam && subscription.teamId) {
if (!teamSubscriptionsMap.has(subscription.teamId)) {
teamSubscriptionsMap.set(subscription.teamId, {
id: subscription.id,
planId: subscription.planId,
teamId: subscription.teamId,
name: subscription.team?.name || '',
email: subscription.team?.teamEmail?.email || `Team ${subscription.team?.id}`,
createdAt: subscription.team?.createdAt,
isTeam: true,
subscriptionIds: [subscription.id],
});
} else {
const existingTeam = teamSubscriptionsMap.get(subscription.teamId);
existingTeam.subscriptionIds.push(subscription.id);
}
} else if (subscription.userId) {
if (!userSubscriptionsMap.has(subscription.userId)) {
userSubscriptionsMap.set(subscription.userId, {
id: subscription.id,
planId: subscription.planId,
userId: subscription.userId,
name: subscription.user?.name || '',
email: subscription.user?.email || '',
createdAt: subscription.user?.createdAt,
isTeam: false,
subscriptionIds: [subscription.id],
});
} else {
const existingUser = userSubscriptionsMap.get(subscription.userId);
existingUser.subscriptionIds.push(subscription.id);
}
}
});
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
const subscriptions = [
...Array.from(userSubscriptionsMap.values()),
...Array.from(teamSubscriptionsMap.values()),
];
const filteredSubscriptions = search
? subscriptions.filter((sub) => {
const searchLower = search.toLowerCase();
return (
sub.name?.toLowerCase().includes(searchLower) ||
sub.email?.toLowerCase().includes(searchLower)
);
})
: subscriptions;
const signingVolume = await Promise.all(
filteredSubscriptions.map(async (subscription) => {
let signingVolume = 0;
if (subscription.userId && !subscription.isTeam) {
const personalCount = await prisma.document.count({
where: {
userId: subscription.userId,
status: DocumentStatus.COMPLETED,
teamId: null,
},
});
signingVolume += personalCount;
const userTeams = await prisma.teamMember.findMany({
where: {
userId: subscription.userId,
},
select: {
teamId: true,
},
});
if (userTeams.length > 0) {
const teamIds = userTeams.map((team) => team.teamId);
const teamCount = await prisma.document.count({
where: {
teamId: {
in: teamIds,
},
status: DocumentStatus.COMPLETED,
},
});
signingVolume += teamCount;
}
}
if (subscription.teamId) {
const teamCount = await prisma.document.count({
where: {
teamId: subscription.teamId,
status: DocumentStatus.COMPLETED,
},
});
signingVolume += teamCount;
}
return {
...subscription,
signingVolume,
};
}),
);
const sortedResults = [...signingVolume].sort((a, b) => {
if (sortBy === 'name') {
return sortOrder === 'asc'
? (a.name || '').localeCompare(b.name || '')
: (b.name || '').localeCompare(a.name || '');
}
if (sortBy === 'createdAt') {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
}
return sortOrder === 'asc'
? a.signingVolume - b.signingVolume
: b.signingVolume - a.signingVolume;
});
const paginatedResults = sortedResults.slice(skip, skip + validPerPage);
const totalPages = Math.ceil(sortedResults.length / validPerPage);
return {
leaderboard: results,
totalPages: Math.ceil(Number(count) / perPage),
leaderboard: paginatedResults,
totalPages,
};
}
};

View File

@ -13,7 +13,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid } from '@documenso/lib/universal/id';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { TCreateDocumentV2Request } from '@documenso/trpc/server/document-router/schema';
@ -142,6 +142,7 @@ export const createDocumentV2 = async ({
const document = await tx.document.create({
data: {
title,
qrToken: prefixedId('qr'),
externalId: data.externalId,
documentDataId,
userId,
@ -232,6 +233,7 @@ export const createDocumentV2 = async ({
documentMeta: true,
recipients: true,
fields: true,
folder: true,
},
});

View File

@ -1,5 +1,5 @@
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import type { DocumentVisibility, Team, TeamGlobalSettings } from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@ -13,6 +13,7 @@ import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { prefixedId } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { determineDocumentVisibility } from '../../utils/document-visibility';
@ -28,6 +29,7 @@ export type CreateDocumentOptions = {
normalizePdf?: boolean;
timezone?: string;
requestMetadata: ApiRequestMetadata;
folderId?: string;
};
export const createDocument = async ({
@ -40,6 +42,7 @@ export const createDocument = async ({
formValues,
requestMetadata,
timezone,
folderId,
}: CreateDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@ -88,6 +91,29 @@ export const createDocument = async ({
userTeamRole = teamWithUserRole.members[0]?.role;
}
let folderVisibility: DocumentVisibility | undefined;
if (folderId) {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
},
select: {
visibility: true,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
folderVisibility = folder.visibility;
}
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
@ -115,14 +141,18 @@ export const createDocument = async ({
const document = await tx.document.create({
data: {
title,
qrToken: prefixedId('qr'),
externalId,
documentDataId,
userId,
teamId,
visibility: determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
folderId,
visibility:
folderVisibility ??
determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {

View File

@ -1,8 +1,14 @@
import { DocumentSource, type Prisma } from '@prisma/client';
import { DocumentSource, type Prisma, WebhookTriggerEvents } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { prefixedId } from '../../universal/id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getDocumentWhereInput } from './get-document-by-id';
export interface DuplicateDocumentOptions {
@ -56,6 +62,7 @@ export const duplicateDocument = async ({
const createDocumentArguments: Prisma.DocumentCreateArgs = {
data: {
title: document.title,
qrToken: prefixedId('qr'),
user: {
connect: {
id: document.userId,
@ -84,7 +91,24 @@ export const duplicateDocument = async ({
};
}
const createdDocument = await prisma.document.create(createDocumentArguments);
const createdDocument = await prisma.document.create({
...createDocumentArguments,
include: {
recipients: true,
documentMeta: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse({
...mapDocumentToWebhookDocumentPayload(createdDocument),
recipients: createdDocument.recipients,
documentMeta: createdDocument.documentMeta,
}),
userId: userId,
teamId: teamId,
});
return {
documentId: createdDocument.id,

View File

@ -27,6 +27,7 @@ export type FindDocumentsOptions = {
period?: PeriodSelectorValue;
senderIds?: number[];
query?: string;
folderId?: string;
};
export const findDocuments = async ({
@ -41,6 +42,7 @@ export const findDocuments = async ({
period,
senderIds,
query = '',
folderId,
}: FindDocumentsOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@ -120,10 +122,10 @@ export const findDocuments = async ({
},
];
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user);
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user, folderId);
if (team) {
filters = findTeamDocumentsFilter(status, team, visibilityFilters);
filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId);
}
if (filters === null) {
@ -227,6 +229,12 @@ export const findDocuments = async ({
};
}
if (folderId !== undefined) {
whereClause.folderId = folderId;
} else {
whereClause.folderId = null;
}
const [data, count] = await Promise.all([
prisma.document.findMany({
where: whereClause,
@ -273,13 +281,18 @@ export const findDocuments = async ({
} satisfies FindResultResponse<typeof data>;
};
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
const findDocumentsFilter = (
status: ExtendedDocumentStatus,
user: User,
folderId?: string | null,
) => {
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
.with(ExtendedDocumentStatus.ALL, () => ({
OR: [
{
userId: user.id,
teamId: null,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.COMPLETED,
@ -288,6 +301,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
email: user.email,
},
},
folderId: folderId,
},
{
status: ExtendedDocumentStatus.PENDING,
@ -296,6 +310,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
email: user.email,
},
},
folderId: folderId,
},
],
}))
@ -324,6 +339,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.PENDING,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.PENDING,
@ -336,6 +352,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
},
},
},
folderId: folderId,
},
],
}))
@ -345,6 +362,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.COMPLETED,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.COMPLETED,
@ -353,6 +371,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
email: user.email,
},
},
folderId: folderId,
},
],
}))
@ -362,6 +381,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.REJECTED,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.REJECTED,
@ -371,6 +391,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
signingStatus: SigningStatus.REJECTED,
},
},
folderId: folderId,
},
],
}))
@ -410,6 +431,7 @@ const findTeamDocumentsFilter = (
status: ExtendedDocumentStatus,
team: Team & { teamEmail: TeamEmail | null },
visibilityFilters: Prisma.DocumentWhereInput[],
folderId?: string,
) => {
const teamEmail = team.teamEmail?.email ?? null;
@ -420,6 +442,7 @@ const findTeamDocumentsFilter = (
OR: [
{
teamId: team.id,
folderId: folderId,
OR: visibilityFilters,
},
],
@ -437,6 +460,7 @@ const findTeamDocumentsFilter = (
},
},
OR: visibilityFilters,
folderId: folderId,
});
// Filter to display all documents that have been sent by the team email.
@ -445,6 +469,7 @@ const findTeamDocumentsFilter = (
email: teamEmail,
},
OR: visibilityFilters,
folderId: folderId,
});
}
@ -470,6 +495,7 @@ const findTeamDocumentsFilter = (
},
},
OR: visibilityFilters,
folderId: folderId,
};
})
.with(ExtendedDocumentStatus.DRAFT, () => {
@ -479,6 +505,7 @@ const findTeamDocumentsFilter = (
teamId: team.id,
status: ExtendedDocumentStatus.DRAFT,
OR: visibilityFilters,
folderId: folderId,
},
],
};
@ -490,6 +517,7 @@ const findTeamDocumentsFilter = (
email: teamEmail,
},
OR: visibilityFilters,
folderId: folderId,
});
}
@ -502,6 +530,7 @@ const findTeamDocumentsFilter = (
teamId: team.id,
status: ExtendedDocumentStatus.PENDING,
OR: visibilityFilters,
folderId: folderId,
},
],
};
@ -521,12 +550,14 @@ const findTeamDocumentsFilter = (
},
},
OR: visibilityFilters,
folderId: folderId,
},
{
user: {
email: teamEmail,
},
OR: visibilityFilters,
folderId: folderId,
},
],
});

View File

@ -0,0 +1,38 @@
import { prisma } from '@documenso/prisma';
export type GetDocumentByAccessTokenOptions = {
token: string;
};
export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTokenOptions) => {
if (!token) {
throw new Error('Missing token');
}
const result = await prisma.document.findFirstOrThrow({
where: {
qrToken: token,
},
select: {
id: true,
title: true,
completedAt: true,
documentData: {
select: {
id: true,
type: true,
data: true,
initialData: true,
},
},
documentMeta: {
select: {
password: true,
},
},
recipients: true,
},
});
return result;
};

View File

@ -12,9 +12,15 @@ export type GetDocumentByIdOptions = {
documentId: number;
userId: number;
teamId?: number;
folderId?: string;
};
export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumentByIdOptions) => {
export const getDocumentById = async ({
documentId,
userId,
teamId,
folderId,
}: GetDocumentByIdOptions) => {
const documentWhereInput = await getDocumentWhereInput({
documentId,
userId,
@ -22,7 +28,10 @@ export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumen
});
const document = await prisma.document.findFirst({
where: documentWhereInput,
where: {
...documentWhereInput,
folderId,
},
include: {
documentData: true,
documentMeta: true,

View File

@ -7,12 +7,14 @@ export type GetDocumentWithDetailsByIdOptions = {
documentId: number;
userId: number;
teamId?: number;
folderId?: string;
};
export const getDocumentWithDetailsById = async ({
documentId,
userId,
teamId,
folderId,
}: GetDocumentWithDetailsByIdOptions) => {
const documentWhereInput = await getDocumentWhereInput({
documentId,
@ -21,12 +23,16 @@ export const getDocumentWithDetailsById = async ({
});
const document = await prisma.document.findFirst({
where: documentWhereInput,
where: {
...documentWhereInput,
folderId,
},
include: {
documentData: true,
documentMeta: true,
recipients: true,
fields: true,
folder: true,
},
});

View File

@ -15,9 +15,16 @@ export type GetStatsInput = {
team?: Omit<GetTeamCountsOption, 'createdAt'>;
period?: PeriodSelectorValue;
search?: string;
folderId?: string;
};
export const getStats = async ({ user, period, search = '', ...options }: GetStatsInput) => {
export const getStats = async ({
user,
period,
search = '',
folderId,
...options
}: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) {
@ -37,8 +44,9 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
currentUserEmail: user.email,
userId: user.id,
search,
folderId,
})
: getCounts({ user, createdAt, search }));
: getCounts({ user, createdAt, search, folderId }));
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
@ -84,9 +92,10 @@ type GetCountsOption = {
user: User;
createdAt: Prisma.DocumentWhereInput['createdAt'];
search?: string;
folderId?: string | null;
};
const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption) => {
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
@ -95,6 +104,8 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
],
};
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
return Promise.all([
// Owner counts.
prisma.document.groupBy({
@ -107,7 +118,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
createdAt,
teamId: null,
deletedAt: null,
AND: [searchFilter],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
// Not signed counts.
@ -126,7 +137,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
},
},
createdAt,
AND: [searchFilter],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
// Has signed counts.
@ -164,7 +175,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
},
},
],
AND: [searchFilter],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
]);
@ -179,10 +190,11 @@ type GetTeamCountsOption = {
createdAt: Prisma.DocumentWhereInput['createdAt'];
currentTeamMemberRole?: TeamMemberRole;
search?: string;
folderId?: string | null;
};
const getTeamCounts = async (options: GetTeamCountsOption) => {
const { createdAt, teamId, teamEmail } = options;
const { createdAt, teamId, teamEmail, folderId } = options;
const senderIds = options.senderIds ?? [];
@ -206,6 +218,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
createdAt,
teamId,
deletedAt: null,
folderId,
};
let notSignedCountsGroupByArgs = null;
@ -278,6 +291,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
where: {
userId: userIdWhereClause,
createdAt,
folderId,
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
@ -298,6 +312,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
where: {
userId: userIdWhereClause,
createdAt,
folderId,
OR: [
{
status: ExtendedDocumentStatus.PENDING,

View File

@ -22,6 +22,7 @@ import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
import { flattenForm } from '../pdf/flatten-form';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { legacy_insertFieldInPDF } from '../pdf/legacy-insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendCompletedEmail } from './send-completed-email';
@ -146,7 +147,9 @@ export const sealDocument = async ({
}
for (const field of fields) {
await insertFieldInPDF(doc, field);
document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(doc, field)
: await insertFieldInPDF(doc, field);
}
// Re-flatten post-insertion to handle fields that create arcoFields

View File

@ -21,6 +21,7 @@ export type UpdateDocumentOptions = {
visibility?: DocumentVisibility | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
useLegacyFieldInsertion?: boolean;
};
requestMetadata: ApiRequestMetadata;
};
@ -115,6 +116,7 @@ export const updateDocument = async ({
}
if (!data || Object.values(data).length === 0) {
console.log('no data');
return document;
}
@ -217,7 +219,8 @@ export const updateDocument = async ({
);
}
if (auditLogs.length === 0) {
// Early return if nothing is required.
if (auditLogs.length === 0 && data.useLegacyFieldInsertion === undefined) {
return document;
}
@ -235,6 +238,7 @@ export const updateDocument = async ({
title: data.title,
externalId: data.externalId,
visibility: data.visibility as DocumentVisibility,
useLegacyFieldInsertion: data.useLegacyFieldInsertion,
authOptions,
},
});

View File

@ -0,0 +1,86 @@
import { TeamMemberRole } from '@prisma/client';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
import { determineDocumentVisibility } from '../../utils/document-visibility';
export interface CreateFolderOptions {
userId: number;
teamId?: number;
name: string;
parentId?: string | null;
type?: TFolderType;
}
export const createFolder = async ({
userId,
teamId,
name,
parentId,
type = FolderType.DOCUMENT,
}: CreateFolderOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
teamMembers: {
select: {
teamId: true,
},
},
},
});
if (
teamId !== undefined &&
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;
let userTeamRole: TeamMemberRole | undefined;
if (teamId) {
const teamWithUserRole = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
},
include: {
teamGlobalSettings: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
});
team = teamWithUserRole;
userTeamRole = teamWithUserRole.members[0]?.role;
}
return await prisma.folder.create({
data: {
name,
userId,
teamId,
parentId,
type,
visibility: determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
},
});
};

View File

@ -0,0 +1,85 @@
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
export interface DeleteFolderOptions {
userId: number;
teamId?: number;
folderId: string;
}
export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOptions) => {
let teamMemberRole: TeamMemberRole | null = null;
if (teamId) {
const team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
teamMemberRole = team.members[0]?.role ?? null;
}
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
},
include: {
documents: true,
subfolders: true,
templates: true,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
if (teamId && teamMemberRole) {
const hasPermission = match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => folder.visibility !== DocumentVisibility.ADMIN)
.with(TeamMemberRole.MEMBER, () => folder.visibility === DocumentVisibility.EVERYONE)
.otherwise(() => false);
if (!hasPermission) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to delete this folder',
});
}
}
return await prisma.folder.delete({
where: {
id: folderId,
},
});
};

View File

@ -0,0 +1,152 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
export interface FindFoldersOptions {
userId: number;
teamId?: number;
parentId?: string | null;
type?: TFolderType;
}
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => {
let team = null;
let teamMemberRole = null;
if (teamId !== undefined) {
try {
team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
console.error('Error finding team:', error);
throw error;
}
}
const visibilityFilters = match(teamMemberRole)
.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: DocumentVisibility.EVERYONE }));
const whereClause = {
AND: [
{ parentId },
teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null },
],
};
try {
const folders = await prisma.folder.findMany({
where: {
...whereClause,
...(type ? { type } : {}),
},
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
});
const foldersWithDetails = await Promise.all(
folders.map(async (folder) => {
try {
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
prisma.folder.findMany({
where: {
parentId: folder.id,
...(teamId ? { teamId, ...visibilityFilters } : { userId, teamId: null }),
},
orderBy: {
createdAt: 'desc',
},
}),
prisma.document.count({
where: {
folderId: folder.id,
},
}),
prisma.template.count({
where: {
folderId: folder.id,
},
}),
prisma.folder.count({
where: {
parentId: folder.id,
...(teamId ? { teamId, ...visibilityFilters } : { userId, teamId: null }),
},
}),
]);
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
...subfolder,
subfolders: [],
_count: {
documents: 0,
templates: 0,
subfolders: 0,
},
}));
return {
...folder,
subfolders: subfoldersWithEmptySubfolders,
_count: {
documents: documentCount,
templates: templateCount,
subfolders: subfolderCount,
},
};
} catch (error) {
console.error('Error processing folder:', folder.id, error);
throw error;
}
}),
);
return foldersWithDetails;
} catch (error) {
console.error('Error in findFolders:', error);
throw error;
}
};

View File

@ -0,0 +1,112 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
export interface GetFolderBreadcrumbsOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const getFolderBreadcrumbs = async ({
userId,
teamId,
folderId,
type,
}: GetFolderBreadcrumbsOptions) => {
let teamMemberRole = null;
if (teamId !== undefined) {
try {
const team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
console.error('Error finding team:', error);
return [];
}
}
const visibilityFilters = match(teamMemberRole)
.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: DocumentVisibility.EVERYONE }));
const whereClause = (folderId: string) => ({
id: folderId,
...(type ? { type } : {}),
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
});
const breadcrumbs = [];
let currentFolderId = folderId;
const currentFolder = await prisma.folder.findFirst({
where: whereClause(currentFolderId),
});
if (!currentFolder) {
return [];
}
breadcrumbs.push(currentFolder);
while (currentFolder?.parentId) {
const parentFolder = await prisma.folder.findFirst({
where: whereClause(currentFolder.parentId),
});
if (!parentFolder) {
break;
}
breadcrumbs.unshift(parentFolder);
currentFolderId = parentFolder.id;
currentFolder.parentId = parentFolder.parentId;
}
return breadcrumbs;
};

View File

@ -0,0 +1,92 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
export interface GetFolderByIdOptions {
userId: number;
teamId?: number;
folderId?: string;
type?: TFolderType;
}
export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => {
let teamMemberRole = null;
if (teamId !== undefined) {
try {
const team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
}
const visibilityFilters = match(teamMemberRole)
.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: DocumentVisibility.EVERYONE }));
const whereClause = {
id: folderId,
...(type ? { type } : {}),
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
};
const folder = await prisma.folder.findFirst({
where: whereClause,
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return folder;
};

View File

@ -0,0 +1,130 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { FolderType } from '@documenso/lib/types/folder-type';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
export interface MoveDocumentToFolderOptions {
userId: number;
teamId?: number;
documentId: number;
folderId?: string | null;
requestMetadata?: ApiRequestMetadata;
}
export const moveDocumentToFolder = async ({
userId,
teamId,
documentId,
folderId,
}: MoveDocumentToFolderOptions) => {
let teamMemberRole = null;
if (teamId !== undefined) {
try {
const team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
console.error('Error finding team:', error);
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
}
const visibilityFilters = match(teamMemberRole)
.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: DocumentVisibility.EVERYONE }));
const documentWhereClause = {
id: documentId,
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
};
const document = await prisma.document.findFirst({
where: documentWhereClause,
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (folderId) {
const folderWhereClause = {
id: folderId,
type: FolderType.DOCUMENT,
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
};
const folder = await prisma.folder.findFirst({
where: folderWhereClause,
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
return await prisma.document.update({
where: {
id: documentId,
},
data: {
folderId,
},
});
};

View File

@ -0,0 +1,85 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
export interface MoveFolderOptions {
userId: number;
teamId?: number;
folderId?: string;
parentId?: string | null;
requestMetadata?: ApiRequestMetadata;
}
export const moveFolder = async ({ userId, teamId, folderId, parentId }: MoveFolderOptions) => {
return await prisma.$transaction(async (tx) => {
const folder = await tx.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
if (parentId) {
const parentFolder = await tx.folder.findFirst({
where: {
id: parentId,
userId,
teamId,
type: folder.type,
},
});
if (!parentFolder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
if (parentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into itself',
});
}
let currentParentId = parentFolder.parentId;
while (currentParentId) {
if (currentParentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into its descendant',
});
}
const currentParent = await tx.folder.findUnique({
where: {
id: currentParentId,
},
select: {
parentId: true,
},
});
if (!currentParent) {
break;
}
currentParentId = currentParent.parentId;
}
}
return await tx.folder.update({
where: {
id: folderId,
},
data: {
parentId,
},
});
});
};

View File

@ -0,0 +1,59 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { prisma } from '@documenso/prisma';
export interface MoveTemplateToFolderOptions {
userId: number;
teamId?: number;
templateId: number;
folderId?: string | null;
}
export const moveTemplateToFolder = async ({
userId,
teamId,
templateId,
folderId,
}: MoveTemplateToFolderOptions) => {
return await prisma.$transaction(async (tx) => {
const template = await tx.template.findFirst({
where: {
id: templateId,
userId,
teamId,
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
if (folderId !== null) {
const folder = await tx.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
type: FolderType.TEMPLATE,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
return await tx.template.update({
where: {
id: templateId,
},
data: {
folderId,
},
});
});
};

View File

@ -0,0 +1,37 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
export interface PinFolderOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const pinFolder = async ({ userId, teamId, folderId, type }: PinFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
pinned: true,
},
});
};

View File

@ -0,0 +1,37 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
export interface UnpinFolderOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const unpinFolder = async ({ userId, teamId, folderId, type }: UnpinFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
pinned: false,
},
});
};

View File

@ -0,0 +1,53 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '@documenso/prisma/generated/types';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
export interface UpdateFolderOptions {
userId: number;
teamId?: number;
folderId: string;
name: string;
visibility: DocumentVisibility;
type?: TFolderType;
}
export const updateFolder = async ({
userId,
teamId,
folderId,
name,
visibility,
type,
}: UpdateFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
const isTemplateFolder = folder.type === FolderType.TEMPLATE;
const effectiveVisibility =
isTemplateFolder && teamId !== null ? DocumentVisibility.EVERYONE : visibility;
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
name,
visibility: effectiveVisibility,
},
});
};

View File

@ -1,8 +1,8 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { FieldType } from '@prisma/client';
import type { PDFDocument } from 'pdf-lib';
import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib';
import type { PDFDocument, PDFFont } from 'pdf-lib';
import { RotationTypes, TextAlignment, degrees, radiansToDegrees, rgb } from 'pdf-lib';
import { P, match } from 'ts-pattern';
import {
@ -34,6 +34,13 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
]);
const isSignatureField = isSignatureFieldType(field.type);
/**
* Red box is the original field width, height and position.
*
* Blue box is the adjusted field width, height and position. It will represent
* where the text will overflow into.
*/
const isDebugMode =
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
@ -227,8 +234,13 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const selected: string[] = fromCheckboxValue(field.customText);
const topPadding = 12;
const leftCheckboxPadding = 8;
const leftCheckboxLabelPadding = 12;
const checkboxSpaceY = 13;
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const offsetY = index * checkboxSpaceY + topPadding;
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
@ -237,7 +249,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
}
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
x: fieldX + leftCheckboxPadding + leftCheckboxLabelPadding,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
@ -245,7 +257,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
});
checkbox.addToPage(page, {
x: fieldX,
x: fieldX + leftCheckboxPadding,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
@ -268,21 +280,28 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const selected = field.customText.split(',');
const topPadding = 12;
const leftRadioPadding = 8;
const leftRadioLabelPadding = 12;
const radioSpaceY = 13;
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const offsetY = index * radioSpaceY + topPadding;
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
// Draw label.
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
x: fieldX + leftRadioPadding + leftRadioLabelPadding,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
// Draw radio button.
radio.addOptionToPage(item.value, page, {
x: fieldX,
x: fieldX + leftRadioPadding,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
@ -304,62 +323,144 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
} as const;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const Parser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
const fieldMetaParser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
const meta = fieldMetaParser ? fieldMetaParser.safeParse(field.fieldMeta) : null;
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'left';
let fontSize = customFontSize || maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const textHeight = font.heightAtSize(fontSize);
// Scale font only if no custom font and height exceeds field height.
if (!customFontSize) {
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
const scalingFactor = Math.min(fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
}
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
/**
* Calculate whether the field should be multiline.
*
* - True = text will overflow downwards.
* - False = text will overflow sideways.
*/
const isMultiline =
field.type === FieldType.TEXT &&
(textWidth > fieldWidth || field.customText.includes('\n'));
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
const padding = 8; // PDF points, roughly equivalent to 0.5rem
const padding = 8;
// Calculate X position based on text alignment with padding
let textX = fieldX + padding; // Left alignment starts after padding
if (textAlign === 'center') {
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
} else if (textAlign === 'right') {
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
}
let textY = fieldY + (fieldHeight - textHeight) / 2;
const textAlignmentOptions = getTextAlignmentOptions(textAlign, fieldX, isMultiline, padding);
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
let textFieldBoxY = pageHeight - fieldY - fieldHeight;
const textFieldBoxX = textAlignmentOptions.xPos;
const textField = pdf.getForm().createTextField(`text.${field.secondaryId}`);
textField.setAlignment(textAlignmentOptions.textAlignment);
/**
* From now on we will adjust the field size and position so the text
* overflows correctly in the X or Y axis depending on the field type.
*/
let adjustedFieldWidth = fieldWidth - padding * 2; //
let adjustedFieldHeight = fieldHeight;
let adjustedFieldX = textFieldBoxX;
let adjustedFieldY = textFieldBoxY;
let textToInsert = field.customText;
// The padding to use when fields go off the page.
const pagePadding = 4;
// Handle multiline text, which will overflow on the Y axis.
if (isMultiline) {
textToInsert = breakLongString(textToInsert, adjustedFieldWidth, font, fontSize);
textField.enableMultiline();
textField.disableCombing();
textField.disableScrolling();
// Adjust the textFieldBox so it extends to the bottom of the page so text can wrap.
textFieldBoxY = pageHeight - fieldY - fieldHeight;
// Calculate how much PX from the current field to bottom of the page.
const fieldYOffset = pageHeight - (fieldY + fieldHeight) - pagePadding;
// Field height will be from current to bottom of page.
adjustedFieldHeight = fieldHeight + fieldYOffset;
// Need to move the field Y so it offsets the new field height.
adjustedFieldY = adjustedFieldY - fieldYOffset;
}
// Handle non-multiline text, which will overflow on the X axis.
if (!isMultiline) {
// Left align will extend all the way to the right of the page
if (textAlignmentOptions.textAlignment === TextAlignment.Left) {
adjustedFieldWidth = pageWidth - textFieldBoxX - pagePadding;
}
// Right align will extend all the way to the left of the page.
if (textAlignmentOptions.textAlignment === TextAlignment.Right) {
adjustedFieldWidth = textFieldBoxX + fieldWidth - pagePadding;
adjustedFieldX = adjustedFieldX - adjustedFieldWidth + fieldWidth;
}
// Center align will extend to the closest page edge, then use that * 2 as the width.
if (textAlignmentOptions.textAlignment === TextAlignment.Center) {
const fieldMidpoint = textFieldBoxX + fieldWidth / 2;
const isCloserToLeftEdge = fieldMidpoint < pageWidth / 2;
// If field is closer to left edge, the width must be based of the left.
if (isCloserToLeftEdge) {
adjustedFieldWidth = (textFieldBoxX - pagePadding) * 2 + fieldWidth;
adjustedFieldX = pagePadding;
}
// If field is closer to right edge, the width must be based of the right
if (!isCloserToLeftEdge) {
adjustedFieldWidth = (pageWidth - textFieldBoxX - pagePadding - fieldWidth / 2) * 2;
adjustedFieldX = pageWidth - adjustedFieldWidth - pagePadding;
}
}
}
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
adjustedFieldX,
adjustedFieldY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
adjustedFieldX = adjustedPosition.xPos;
adjustedFieldY = adjustedPosition.yPos;
}
page.drawText(field.customText, {
x: textX,
y: textY,
size: fontSize,
font,
// Set the position and size of the text field
textField.addToPage(page, {
x: adjustedFieldX,
y: adjustedFieldY,
width: adjustedFieldWidth,
height: adjustedFieldHeight,
rotate: degrees(pageRotationInDegrees),
// Hide borders.
borderWidth: 0,
borderColor: undefined,
backgroundColor: undefined,
...(isDebugMode ? { borderWidth: 1, borderColor: rgb(0, 0, 1) } : {}),
});
// Set properties for the text field
textField.setFontSize(fontSize);
textField.setText(textToInsert);
});
return pdf;
@ -393,3 +494,138 @@ const adjustPositionForRotation = (
yPos,
};
};
const textAlignmentMap = {
left: TextAlignment.Left,
center: TextAlignment.Center,
right: TextAlignment.Right,
} as const;
/**
* Get the PDF-lib alignment position, and the X position of the field with padding included.
*
* @param textAlign - The text alignment of the field.
* @param fieldX - The X position of the field.
* @param isMultiline - Whether the field is multiline.
* @param padding - The padding of the field. Defaults to 8.
*
* @returns The X position and text alignment for the field.
*/
const getTextAlignmentOptions = (
textAlign: 'left' | 'center' | 'right',
fieldX: number,
isMultiline: boolean,
padding: number = 8,
) => {
const textAlignment = textAlignmentMap[textAlign];
// For multiline, it needs to be centered so we just basic left padding.
if (isMultiline) {
return {
xPos: fieldX + padding,
textAlignment,
};
}
return match(textAlign)
.with('left', () => ({
xPos: fieldX + padding,
textAlignment,
}))
.with('center', () => ({
xPos: fieldX,
textAlignment,
}))
.with('right', () => ({
xPos: fieldX - padding,
textAlignment,
}))
.exhaustive();
};
/**
* Break a long string into multiple lines so it fits within a given width,
* using natural word breaking similar to word processors.
*
* - Keeps words together when possible
* - Only breaks words when they're too long to fit on a line
* - Handles whitespace intelligently
*
* @param text - The text to break into lines
* @param maxWidth - The maximum width of each line in PX
* @param font - The PDF font object
* @param fontSize - The font size in points
* @returns Object containing the result string and line count
*/
function breakLongString(text: string, maxWidth: number, font: PDFFont, fontSize: number): string {
// Handle empty text
if (!text) {
return '';
}
const lines: string[] = [];
// Process each original line separately to preserve newlines
for (const paragraph of text.split('\n')) {
// If paragraph fits on one line or is empty, add it as-is
if (paragraph === '' || font.widthOfTextAtSize(paragraph, fontSize) <= maxWidth) {
lines.push(paragraph);
continue;
}
// Split paragraph into words
const words = paragraph.split(' ');
let currentLine = '';
for (const word of words) {
// Check if adding word to current line would exceed max width
const lineWithWord = currentLine.length === 0 ? word : `${currentLine} ${word}`;
if (font.widthOfTextAtSize(lineWithWord, fontSize) <= maxWidth) {
// Word fits, add it to current line
currentLine = lineWithWord;
} else {
// Word doesn't fit on current line
// First, save current line if it's not empty
if (currentLine.length > 0) {
lines.push(currentLine);
currentLine = '';
}
// Check if word fits on a line by itself
if (font.widthOfTextAtSize(word, fontSize) <= maxWidth) {
// Word fits on its own line
currentLine = word;
} else {
// Word is too long, need to break it character by character
let charLine = '';
// Process each character in the word
for (const char of word) {
const nextCharLine = charLine + char;
if (font.widthOfTextAtSize(nextCharLine, fontSize) <= maxWidth) {
// Character fits, add it
charLine = nextCharLine;
} else {
// Character doesn't fit, push current charLine and start a new one
lines.push(charLine);
charLine = char;
}
}
// Add any remaining characters as the current line
currentLine = charLine;
}
}
}
// Add the last line if not empty
if (currentLine.length > 0) {
lines.push(currentLine);
}
}
return lines.join('\n');
}

View File

@ -0,0 +1,395 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { FieldType } from '@prisma/client';
import type { PDFDocument } from 'pdf-lib';
import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib';
import { P, match } from 'ts-pattern';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
DEFAULT_STANDARD_FONT_SIZE,
MIN_HANDWRITING_FONT_SIZE,
MIN_STANDARD_FONT_SIZE,
} from '@documenso/lib/constants/pdf';
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
ZCheckboxFieldMeta,
ZDateFieldMeta,
ZEmailFieldMeta,
ZInitialsFieldMeta,
ZNameFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
]);
const isSignatureField = isSignatureFieldType(field.type);
const isDebugMode =
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
pdf.registerFontkit(fontkit);
const pages = pdf.getPages();
const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
const page = pages.at(field.page - 1);
if (!page) {
throw new Error(`Page ${field.page} does not exist`);
}
const pageRotation = page.getRotation();
let pageRotationInDegrees = match(pageRotation.type)
.with(RotationTypes.Degrees, () => pageRotation.angle)
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
.exhaustive();
// Round to the closest multiple of 90 degrees.
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
//
// To account for this, we swap the width and height for pages that are rotated by 90/270
// degrees. This is so we can calculate the virtual position the field was placed if it
// was correctly oriented in the frontend.
//
// Then when we insert the fields, we apply a transformation to the position of the field
// so it is rotated correctly.
if (isPageRotatedToLandscape) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
}
const fieldWidth = pageWidth * (Number(field.width) / 100);
const fieldHeight = pageHeight * (Number(field.height) / 100);
const fieldX = pageWidth * (Number(field.positionX) / 100);
const fieldY = pageHeight * (Number(field.positionY) / 100);
// Draw debug box if debug mode is enabled
if (isDebugMode) {
let debugX = fieldX;
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
debugX,
debugY,
pageRotationInDegrees,
);
debugX = adjustedPosition.xPos;
debugY = adjustedPosition.yPos;
}
page.drawRectangle({
x: debugX,
y: debugY,
width: fieldWidth,
height: fieldHeight,
borderColor: rgb(1, 0, 0), // Red
borderWidth: 1,
rotate: degrees(pageRotationInDegrees),
});
}
const font = await pdf.embedFont(
isSignatureField ? fontCaveat : fontNoto,
isSignatureField ? { features: { calt: false } } : undefined,
);
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
await pdf.embedFont(fontCaveat);
}
await match(field)
.with(
{
type: P.union(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE),
},
async (field) => {
if (field.signature?.signatureImageAsBase64) {
const image = await pdf.embedPng(field.signature?.signatureImageAsBase64 ?? '');
let imageWidth = image.width;
let imageHeight = image.height;
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
imageY = pageHeight - imageY - imageHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
imageX,
imageY,
pageRotationInDegrees,
);
imageX = adjustedPosition.xPos;
imageY = adjustedPosition.yPos;
}
page.drawImage(image, {
x: imageX,
y: imageY,
width: imageWidth,
height: imageHeight,
rotate: degrees(pageRotationInDegrees),
});
} else {
const signatureText = field.signature?.typedSignature ?? '';
const longestLineInTextForWidth = signatureText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let fontSize = maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
let textHeight = font.heightAtSize(fontSize);
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
textHeight = font.heightAtSize(fontSize);
let textX = fieldX + (fieldWidth - textWidth) / 2;
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
}
page.drawText(signatureText, {
x: textX,
y: textY,
size: fontSize,
font,
rotate: degrees(pageRotationInDegrees),
});
}
},
)
.with({ type: FieldType.CHECKBOX }, (field) => {
const meta = ZCheckboxFieldMeta.safeParse(field.fieldMeta);
if (!meta.success) {
console.error(meta.error);
throw new Error('Invalid checkbox field meta');
}
const values = meta.data.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const selected: string[] = fromCheckboxValue(field.customText);
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
if (selected.includes(item.value)) {
checkbox.check();
}
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
checkbox.addToPage(page, {
x: fieldX,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
}
})
.with({ type: FieldType.RADIO }, (field) => {
const meta = ZRadioFieldMeta.safeParse(field.fieldMeta);
if (!meta.success) {
console.error(meta.error);
throw new Error('Invalid radio field meta');
}
const values = meta?.data.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const selected = field.customText.split(',');
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
radio.addOptionToPage(item.value, page, {
x: fieldX,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
if (selected.includes(item.value)) {
radio.select(item.value);
}
}
})
.otherwise((field) => {
const fieldMetaParsers = {
[FieldType.TEXT]: ZTextFieldMeta,
[FieldType.NUMBER]: ZNumberFieldMeta,
[FieldType.DATE]: ZDateFieldMeta,
[FieldType.EMAIL]: ZEmailFieldMeta,
[FieldType.NAME]: ZNameFieldMeta,
[FieldType.INITIALS]: ZInitialsFieldMeta,
} as const;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const Parser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let fontSize = customFontSize || maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textHeight = font.heightAtSize(fontSize);
if (!customFontSize) {
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
}
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
const padding = 8; // PDF points, roughly equivalent to 0.5rem
// Calculate X position based on text alignment with padding
let textX = fieldX + padding; // Left alignment starts after padding
if (textAlign === 'center') {
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
} else if (textAlign === 'right') {
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
}
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
}
page.drawText(field.customText, {
x: textX,
y: textY,
size: fontSize,
font,
rotate: degrees(pageRotationInDegrees),
});
});
return pdf;
};
const adjustPositionForRotation = (
pageWidth: number,
pageHeight: number,
xPos: number,
yPos: number,
pageRotationInDegrees: number,
) => {
if (pageRotationInDegrees === 270) {
xPos = pageWidth - xPos;
[xPos, yPos] = [yPos, xPos];
}
if (pageRotationInDegrees === 90) {
yPos = pageHeight - yPos;
[xPos, yPos] = [yPos, xPos];
}
// Invert all the positions since it's rotated by 180 degrees.
if (pageRotationInDegrees === 180) {
xPos = pageWidth - xPos;
yPos = pageHeight - yPos;
}
return {
xPos,
yPos,
};
};

View File

@ -252,7 +252,10 @@ export const setDocumentRecipients = async ({
});
}
return upsertedRecipient;
return {
...upsertedRecipient,
clientId: recipient.clientId,
};
}),
);
});
@ -332,7 +335,7 @@ export const setDocumentRecipients = async ({
}
// Filter out recipients that have been removed or have been updated.
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
const filteredRecipients: RecipientDataWithClientId[] = existingRecipients.filter((recipient) => {
const isRemoved = removedRecipients.find(
(removedRecipient) => removedRecipient.id === recipient.id,
);
@ -353,6 +356,7 @@ export const setDocumentRecipients = async ({
*/
type RecipientData = {
id?: number | null;
clientId?: string | null;
email: string;
name: string;
role: RecipientRole;
@ -361,6 +365,10 @@ type RecipientData = {
actionAuth?: TRecipientActionAuthTypes | null;
};
type RecipientDataWithClientId = Recipient & {
clientId?: string | null;
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);

View File

@ -19,7 +19,7 @@ import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { nanoid } from '@documenso/lib/universal/id';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
@ -276,6 +276,7 @@ export const createDocumentFromDirectTemplate = async ({
// Create the document and non direct template recipients.
const document = await tx.document.create({
data: {
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE_DIRECT_LINK,
templateId: template.id,
userId: template.userId,

View File

@ -1,6 +1,6 @@
import { DocumentSource, type RecipientRole } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
export type CreateDocumentFromTemplateLegacyOptions = {
@ -70,6 +70,7 @@ export const createDocumentFromTemplateLegacy = async ({
const document = await prisma.document.create({
data: {
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE,
templateId: template.id,
userId,
@ -77,6 +78,7 @@ export const createDocumentFromTemplateLegacy = async ({
title: template.title,
visibility: template.team?.teamGlobalSettings?.documentVisibility,
documentDataId: documentData.id,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
recipients: {
create: template.recipients.map((recipient) => ({
email: recipient.email,

View File

@ -9,11 +9,13 @@ import {
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { nanoid } from '@documenso/lib/universal/id';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
@ -372,6 +374,7 @@ export const createDocumentFromTemplate = async ({
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE,
externalId: externalId || template.externalId,
templateId: template.id,
@ -384,6 +387,7 @@ export const createDocumentFromTemplate = async ({
globalActionAuth: templateAuthOptions.globalActionAuth,
}),
visibility: template.visibility || template.team?.teamGlobalSettings?.documentVisibility,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
documentMeta: {
create: {
subject: override?.subject || template.templateMeta?.subject,
@ -506,10 +510,8 @@ export const createDocumentFromTemplate = async ({
fieldsToCreate = fieldsToCreate.concat(
fields.map((field) => {
const prefillField = prefillFields?.find((value) => value.id === field.id);
// Use type assertion to help TypeScript understand the structure
const updatedFieldMeta = getUpdatedFieldMeta(field, prefillField);
return {
const payload = {
documentId: document.id,
recipientId: recipient.id,
type: field.type,
@ -520,8 +522,38 @@ export const createDocumentFromTemplate = async ({
height: field.height,
customText: '',
inserted: false,
fieldMeta: updatedFieldMeta,
fieldMeta: field.fieldMeta,
};
if (prefillField) {
match(prefillField)
.with({ type: 'date' }, (selector) => {
if (!selector.value) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Date value is required for field ${field.id}`,
});
}
const date = new Date(selector.value);
if (isNaN(date.getTime())) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid date value for field ${field.id}: ${selector.value}`,
});
}
payload.customText = DateTime.fromJSDate(date).toFormat(
template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
);
payload.inserted = true;
})
.otherwise((selector) => {
payload.fieldMeta = getUpdatedFieldMeta(field, selector);
});
}
return payload;
}),
);
});

View File

@ -20,6 +20,7 @@ export const createTemplate = async ({
userId,
teamId,
templateDocumentDataId,
folderId,
}: CreateTemplateOptions) => {
let team = null;
@ -43,12 +44,46 @@ export const createTemplate = async ({
}
}
if (folderId) {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
if (teamId && !team) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return await prisma.template.create({
data: {
title,
userId,
templateDocumentDataId,
teamId,
folderId: folderId,
templateMeta: {
create: {
language: team?.teamGlobalSettings?.documentLanguage,

View File

@ -12,6 +12,7 @@ export type FindTemplatesOptions = {
type?: Template['type'];
page?: number;
perPage?: number;
folderId?: string;
};
export const findTemplates = async ({
@ -20,6 +21,7 @@ export const findTemplates = async ({
type,
page = 1,
perPage = 10,
folderId,
}: FindTemplatesOptions) => {
const whereFilter: Prisma.TemplateWhereInput[] = [];
@ -67,6 +69,12 @@ export const findTemplates = async ({
);
}
if (folderId) {
whereFilter.push({ folderId });
} else {
whereFilter.push({ folderId: null });
}
const [data, count] = await Promise.all([
prisma.template.findMany({
where: {

View File

@ -6,9 +6,15 @@ export type GetTemplateByIdOptions = {
id: number;
userId: number;
teamId?: number;
folderId?: string | null;
};
export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOptions) => {
export const getTemplateById = async ({
id,
userId,
teamId,
folderId = null,
}: GetTemplateByIdOptions) => {
const template = await prisma.template.findFirst({
where: {
id,
@ -27,6 +33,7 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
userId,
teamId: null,
}),
...(folderId ? { folderId } : {}),
},
include: {
directLink: true,
@ -41,6 +48,7 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
email: true,
},
},
folder: true,
},
});

View File

@ -21,6 +21,7 @@ export type UpdateTemplateOptions = {
publicTitle?: string;
publicDescription?: string;
type?: Template['type'];
useLegacyFieldInsertion?: boolean;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
};
@ -107,6 +108,7 @@ export const updateTemplate = async ({
visibility: data?.visibility,
publicDescription: data?.publicDescription,
publicTitle: data?.publicTitle,
useLegacyFieldInsertion: data?.useLegacyFieldInsertion,
authOptions,
templateMeta: {
upsert: {

View File

@ -1,54 +0,0 @@
import { Prisma, type Webhook, WebhookCallStatus, type WebhookTriggerEvents } from '@prisma/client';
import { prisma } from '@documenso/prisma';
export type ExecuteWebhookOptions = {
event: WebhookTriggerEvents;
webhook: Webhook;
data: unknown;
};
export const executeWebhook = async ({ event, webhook, data }: ExecuteWebhookOptions) => {
const { webhookUrl: url, secret } = webhook;
console.log('Executing webhook', { event, url });
const payload = {
event,
payload: data,
createdAt: new Date().toISOString(),
webhookEndpoint: url,
};
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': secret ?? '',
},
});
const body = await response.text();
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
try {
responseBody = JSON.parse(body);
} catch (err) {
responseBody = body;
}
await prisma.webhookCall.create({
data: {
url,
event,
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
requestBody: payload as Prisma.InputJsonValue,
responseCode: response.status,
responseBody,
responseHeaders: Object.fromEntries(response.headers.entries()),
webhookId: webhook.id,
},
});
};

View File

@ -1,6 +1,6 @@
import { jobs } from '../../../jobs/client';
import { verify } from '../../crypto/verify';
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
import { executeWebhook } from './execute-webhook';
import { ZTriggerWebhookBodySchema } from './schema';
export type HandlerTriggerWebhooksResponse =
@ -42,17 +42,20 @@ export const handlerTriggerWebhooks = async (req: Request) => {
const allWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
await Promise.allSettled(
allWebhooks.map(async (webhook) =>
executeWebhook({
event,
webhook,
data,
}),
),
allWebhooks.map(async (webhook) => {
await jobs.triggerJob({
name: 'internal.execute-webhook',
payload: {
event,
webhookId: webhook.id,
data,
},
});
}),
);
return Response.json(
{ success: true, message: 'Webhooks executed successfully' },
{ success: true, message: 'Webhooks queued for execution' },
{ status: 200 },
);
};