mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 09:12:02 +10:00
chore: merged main
This commit is contained in:
@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -23,6 +23,7 @@ export type UpdateDocumentOptions = {
|
||||
visibility?: DocumentVisibility | null;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||
globalActionAuth?: TDocumentActionAuthTypes | null;
|
||||
useLegacyFieldInsertion?: boolean;
|
||||
};
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
@ -118,6 +119,7 @@ export const updateDocument = async ({
|
||||
|
||||
// If no data just return the document since this function is normally chained after a meta update.
|
||||
if (!data || Object.values(data).length === 0) {
|
||||
console.log('no data');
|
||||
return document;
|
||||
}
|
||||
|
||||
@ -236,7 +238,7 @@ export const updateDocument = async ({
|
||||
}
|
||||
|
||||
// Early return if nothing is required.
|
||||
if (auditLogs.length === 0) {
|
||||
if (auditLogs.length === 0 && data.useLegacyFieldInsertion === undefined) {
|
||||
return document;
|
||||
}
|
||||
|
||||
@ -254,6 +256,7 @@ export const updateDocument = async ({
|
||||
title: data.title,
|
||||
externalId: data.externalId,
|
||||
visibility: data.visibility as DocumentVisibility,
|
||||
useLegacyFieldInsertion: data.useLegacyFieldInsertion,
|
||||
authOptions,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
395
packages/lib/server-only/pdf/legacy-insert-field-in-pdf.ts
Normal file
395
packages/lib/server-only/pdf/legacy-insert-field-in-pdf.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@ -77,6 +77,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,
|
||||
|
||||
@ -384,6 +384,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,
|
||||
|
||||
@ -20,6 +20,7 @@ export type UpdateTemplateOptions = {
|
||||
publicTitle?: string;
|
||||
publicDescription?: string;
|
||||
type?: Template['type'];
|
||||
useLegacyFieldInsertion?: boolean;
|
||||
};
|
||||
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
|
||||
};
|
||||
@ -102,6 +103,7 @@ export const updateTemplate = async ({
|
||||
visibility: data?.visibility,
|
||||
publicDescription: data?.publicDescription,
|
||||
publicTitle: data?.publicTitle,
|
||||
useLegacyFieldInsertion: data?.useLegacyFieldInsertion,
|
||||
authOptions,
|
||||
templateMeta: {
|
||||
upsert: {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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 },
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user