mirror of
https://github.com/documenso/documenso.git
synced 2026-07-23 16:33:57 +10:00
Merge branch 'main' into feat/add-pdf-image-renderer
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
|
||||
|
||||
import { adminProcedure } from '../trpc';
|
||||
import { ZResyncLicenseRequestSchema, ZResyncLicenseResponseSchema } from './resync-license.types';
|
||||
|
||||
export const resyncLicenseRoute = adminProcedure
|
||||
.input(ZResyncLicenseRequestSchema)
|
||||
.output(ZResyncLicenseResponseSchema)
|
||||
.mutation(async () => {
|
||||
const client = LicenseClient.getInstance();
|
||||
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.resync();
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZResyncLicenseRequestSchema = z.void();
|
||||
|
||||
export const ZResyncLicenseResponseSchema = z.void();
|
||||
|
||||
export type TResyncLicenseRequest = z.infer<typeof ZResyncLicenseRequestSchema>;
|
||||
export type TResyncLicenseResponse = z.infer<typeof ZResyncLicenseResponseSchema>;
|
||||
@@ -17,6 +17,7 @@ import { getUserRoute } from './get-user';
|
||||
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
|
||||
import { resealDocumentRoute } from './reseal-document';
|
||||
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
|
||||
import { resyncLicenseRoute } from './resync-license';
|
||||
import { updateAdminOrganisationRoute } from './update-admin-organisation';
|
||||
import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role';
|
||||
import { updateRecipientRoute } from './update-recipient';
|
||||
@@ -44,6 +45,9 @@ export const adminRouter = router({
|
||||
stripe: {
|
||||
createCustomer: createStripeCustomerRoute,
|
||||
},
|
||||
license: {
|
||||
resync: resyncLicenseRoute,
|
||||
},
|
||||
user: {
|
||||
get: getUserRoute,
|
||||
update: updateUserRoute,
|
||||
|
||||
@@ -38,6 +38,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
|
||||
meta,
|
||||
folderId,
|
||||
attachments,
|
||||
formValues,
|
||||
} = input;
|
||||
|
||||
const { remaining } = await getServerLimits({ userId: user.id, teamId });
|
||||
@@ -68,6 +69,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
|
||||
title,
|
||||
externalId,
|
||||
visibility,
|
||||
formValues,
|
||||
globalAccessAuth,
|
||||
globalActionAuth,
|
||||
recipients: (recipients || []).map((recipient) => ({
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
|
||||
/**
|
||||
* Temporariy endpoint for V2 Beta until we allow passthrough documents on create.
|
||||
* @deprecated
|
||||
*/
|
||||
export const createDocumentTemporaryMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
@@ -36,6 +37,7 @@ export const createDocumentTemporaryMeta: TrpcRouteMeta = {
|
||||
description:
|
||||
'You will need to upload the PDF to the provided URL returned. Note: Once V2 API is released, this will be removed since we will allow direct uploads, instead of using an upload URL.',
|
||||
tags: ['Document'],
|
||||
deprecated: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ export const createDocumentRoute = authenticatedProcedure
|
||||
visibility,
|
||||
globalAccessAuth,
|
||||
globalActionAuth,
|
||||
formValues,
|
||||
recipients: (recipients || []).map((recipient) => ({
|
||||
...recipient,
|
||||
fields: (recipient.fields || []).map((field) => ({
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { PDF_SIZE_A4_72PPI } from '@documenso/lib/constants/pdf';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
@@ -42,12 +40,26 @@ export const downloadDocumentAuditLogsRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
const encrypted = encryptSecondaryData({
|
||||
data: mapSecondaryIdToDocumentId(envelope.secondaryId).toString(),
|
||||
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
|
||||
const certificatePdf = await generateAuditLogPdf({
|
||||
envelope,
|
||||
recipients: envelope.recipients,
|
||||
fields: envelope.fields,
|
||||
language: envelope.documentMeta.language,
|
||||
envelopeOwner: {
|
||||
email: envelope.user.email,
|
||||
name: envelope.user.name || '',
|
||||
},
|
||||
envelopeItems: envelope.envelopeItems.map((item) => item.title),
|
||||
pageWidth: PDF_SIZE_A4_72PPI.width,
|
||||
pageHeight: PDF_SIZE_A4_72PPI.height,
|
||||
});
|
||||
|
||||
const result = await certificatePdf.save();
|
||||
|
||||
const base64 = Buffer.from(result).toString('base64');
|
||||
|
||||
return {
|
||||
url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encrypted}`,
|
||||
data: base64,
|
||||
envelopeTitle: envelope.title,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ export const ZDownloadDocumentAuditLogsRequestSchema = z.object({
|
||||
});
|
||||
|
||||
export const ZDownloadDocumentAuditLogsResponseSchema = z.object({
|
||||
url: z.string(),
|
||||
data: z.string(),
|
||||
envelopeTitle: z.string(),
|
||||
});
|
||||
|
||||
export type TDownloadDocumentAuditLogsRequest = z.infer<
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { PDF_SIZE_A4_72PPI } from '@documenso/lib/constants/pdf';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
@@ -27,7 +26,7 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure
|
||||
},
|
||||
});
|
||||
|
||||
const envelope = await getEnvelopeById({
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
@@ -37,16 +36,54 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure
|
||||
teamId,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
fields: {
|
||||
include: {
|
||||
signature: true,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isDocumentCompleted(envelope.status)) {
|
||||
throw new AppError('DOCUMENT_NOT_COMPLETE');
|
||||
}
|
||||
|
||||
const encrypted = encryptSecondaryData({
|
||||
data: mapSecondaryIdToDocumentId(envelope.secondaryId).toString(),
|
||||
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
|
||||
const certificatePdf = await generateCertificatePdf({
|
||||
envelope,
|
||||
recipients: envelope.recipients,
|
||||
fields: envelope.fields,
|
||||
language: envelope.documentMeta.language,
|
||||
envelopeOwner: {
|
||||
email: envelope.user.email,
|
||||
name: envelope.user.name || '',
|
||||
},
|
||||
pageWidth: PDF_SIZE_A4_72PPI.width,
|
||||
pageHeight: PDF_SIZE_A4_72PPI.height,
|
||||
});
|
||||
|
||||
const result = await certificatePdf.save();
|
||||
|
||||
const base64 = Buffer.from(result).toString('base64');
|
||||
|
||||
return {
|
||||
url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encrypted}`,
|
||||
data: base64,
|
||||
envelopeTitle: envelope.title,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ export const ZDownloadDocumentCertificateRequestSchema = z.object({
|
||||
});
|
||||
|
||||
export const ZDownloadDocumentCertificateResponseSchema = z.object({
|
||||
url: z.string(),
|
||||
data: z.string(),
|
||||
envelopeTitle: z.string(),
|
||||
});
|
||||
|
||||
export type TDownloadDocumentCertificateRequest = z.infer<
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import pMap from 'p-map';
|
||||
|
||||
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||
import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids';
|
||||
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZBulkDeleteEnvelopesRequestSchema,
|
||||
ZBulkDeleteEnvelopesResponseSchema,
|
||||
} from './bulk-delete-envelopes.types';
|
||||
|
||||
export const bulkDeleteEnvelopesRoute = authenticatedProcedure
|
||||
// .meta(bulkDeleteEnvelopesMeta) // Keeping this as a private API for a little while until we're sure it's stable and the request/response schemas are finalized.
|
||||
.input(ZBulkDeleteEnvelopesRequestSchema)
|
||||
.output(ZBulkDeleteEnvelopesResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { envelopeIds } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeIds,
|
||||
},
|
||||
});
|
||||
|
||||
const { envelopeWhereInput } = await getMultipleEnvelopeWhereInput({
|
||||
ids: {
|
||||
type: 'envelopeId',
|
||||
ids: envelopeIds,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId,
|
||||
type: null,
|
||||
});
|
||||
|
||||
const envelopes = await prisma.envelope.findMany({
|
||||
where: envelopeWhereInput,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
const results = await pMap(
|
||||
envelopes,
|
||||
async (envelope) => {
|
||||
const { id: envelopeId, type: envelopeType } = envelope;
|
||||
|
||||
try {
|
||||
if (envelopeType === EnvelopeType.DOCUMENT) {
|
||||
await deleteDocument({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
} else if (envelopeType === EnvelopeType.TEMPLATE) {
|
||||
await deleteTemplate({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
envelopeId,
|
||||
};
|
||||
} catch (err) {
|
||||
ctx.logger.warn(
|
||||
{
|
||||
envelopeId,
|
||||
error: err,
|
||||
},
|
||||
'Failed to delete envelope during bulk delete',
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
envelopeId,
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
concurrency: 10,
|
||||
stopOnError: false,
|
||||
},
|
||||
);
|
||||
|
||||
const deletedCount = results.filter((r) => r.success).length;
|
||||
const failedIds = results.filter((r) => !r.success).map((r) => r.envelopeId);
|
||||
|
||||
// Include envelope IDs that were not attempted (unauthorized/not found)
|
||||
const attemptedIds = new Set(envelopes.map((e) => e.id));
|
||||
const unattemptedIds = envelopeIds.filter((id) => !attemptedIds.has(id));
|
||||
|
||||
return {
|
||||
deletedCount,
|
||||
failedIds: [...failedIds, ...unattemptedIds],
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// READ ME: IF YOU UNCOMMENT THIS THEN UNSKIP THE TEST IN api-access-envelope-bulk.spec.ts
|
||||
// Keeping this as a private API for a little while until we're sure it's stable and the request/response schemas are finalized.
|
||||
// export const bulkDeleteEnvelopesMeta: TrpcRouteMeta = {
|
||||
// openapi: {
|
||||
// method: 'POST',
|
||||
// path: '/envelope/bulk/delete',
|
||||
// summary: 'Bulk delete envelopes',
|
||||
// description: 'Delete multiple envelopes.',
|
||||
// tags: ['Envelopes'],
|
||||
// },
|
||||
// };
|
||||
|
||||
export const ZBulkDeleteEnvelopesRequestSchema = z.object({
|
||||
envelopeIds: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.max(100)
|
||||
.describe(
|
||||
'The IDs of the envelopes to delete. The maximum number of envelopes you can delete at once is 100.',
|
||||
),
|
||||
});
|
||||
|
||||
export const ZBulkDeleteEnvelopesResponseSchema = z.object({
|
||||
deletedCount: z.number(),
|
||||
failedIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type TBulkDeleteEnvelopesRequest = z.infer<typeof ZBulkDeleteEnvelopesRequestSchema>;
|
||||
export type TBulkDeleteEnvelopesResponse = z.infer<typeof ZBulkDeleteEnvelopesResponseSchema>;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '@documenso/lib/constants/teams';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids';
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZBulkMoveEnvelopesRequestSchema,
|
||||
ZBulkMoveEnvelopesResponseSchema,
|
||||
} from './bulk-move-envelopes.types';
|
||||
|
||||
export const bulkMoveEnvelopesRoute = authenticatedProcedure
|
||||
// .meta(bulkMoveEnvelopesMeta)
|
||||
.input(ZBulkMoveEnvelopesRequestSchema)
|
||||
.output(ZBulkMoveEnvelopesResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { envelopeIds, envelopeType, folderId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeIds,
|
||||
envelopeType,
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
|
||||
// Build the where input for the update query.
|
||||
const { envelopeWhereInput, team } = await getMultipleEnvelopeWhereInput({
|
||||
ids: {
|
||||
type: 'envelopeId',
|
||||
ids: envelopeIds,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId,
|
||||
type: envelopeType,
|
||||
});
|
||||
|
||||
// Validate folder access if moving to a folder (not root).
|
||||
if (folderId) {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId: user.id,
|
||||
}),
|
||||
type: envelopeType,
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found or access denied',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.envelope.updateMany({
|
||||
where: envelopeWhereInput,
|
||||
data: {
|
||||
folderId: folderId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
movedCount: result.count,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
// READ ME: IF YOU UNCOMMENT THIS THEN UNSKIP THE TEST IN api-access-envelope-bulk.spec.ts
|
||||
// Keeping this as a private API for a little while until we're sure it's stable and the request/response schemas are finalized.
|
||||
// export const bulkMoveEnvelopesMeta: TrpcRouteMeta = {
|
||||
// openapi: {
|
||||
// method: 'POST',
|
||||
// path: '/envelope/bulk/move',
|
||||
// summary: 'Bulk move envelopes to folder',
|
||||
// description: 'Move multiple envelopes to a specified folder.',
|
||||
// tags: ['Envelopes'],
|
||||
// },
|
||||
// };
|
||||
|
||||
export const ZBulkMoveEnvelopesRequestSchema = z.object({
|
||||
envelopeIds: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.max(100)
|
||||
.describe(
|
||||
'The IDs of the envelopes to move. The maximum number of envelopes you can move at once is 100.',
|
||||
),
|
||||
envelopeType: z.nativeEnum(EnvelopeType).describe('The type of the envelopes being moved.'),
|
||||
folderId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
'The ID of the folder to move the envelopes to. If null envelopes will be moved to the root folder.',
|
||||
),
|
||||
});
|
||||
|
||||
export const ZBulkMoveEnvelopesResponseSchema = z.object({
|
||||
movedCount: z.number().describe('The number of envelopes that were moved.'),
|
||||
});
|
||||
|
||||
export type TBulkMoveEnvelopesRequest = z.infer<typeof ZBulkMoveEnvelopesRequestSchema>;
|
||||
export type TBulkMoveEnvelopesResponse = z.infer<typeof ZBulkMoveEnvelopesResponseSchema>;
|
||||
@@ -1,8 +1,15 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import {
|
||||
convertPlaceholdersToFieldInputs,
|
||||
extractPdfPlaceholders,
|
||||
} from '@documenso/lib/server-only/pdf/auto-place-fields';
|
||||
import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers';
|
||||
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
|
||||
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@@ -84,14 +91,31 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
// For each file, stream to s3 and create the document data.
|
||||
// For each file: normalize, extract & clean placeholders, then upload.
|
||||
const envelopeItems = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file);
|
||||
let buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
if (envelope.formValues) {
|
||||
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
|
||||
}
|
||||
|
||||
const normalized = await normalizePdf(buffer, {
|
||||
flattenForm: envelope.type !== 'TEMPLATE',
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
|
||||
const { id: documentDataId } = await putPdfFileServerSide({
|
||||
name: file.name,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(cleanedPdf),
|
||||
});
|
||||
|
||||
return {
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
placeholders,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -131,6 +155,65 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
|
||||
),
|
||||
});
|
||||
|
||||
// Create fields from placeholders if the envelope already has recipients.
|
||||
if (envelope.recipients.length > 0) {
|
||||
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
|
||||
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
if (aOrder !== bOrder) {
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
for (const uploadedItem of envelopeItems) {
|
||||
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdItem = createdItems.find(
|
||||
(ci) => ci.documentDataId === uploadedItem.documentDataId,
|
||||
);
|
||||
|
||||
if (!createdItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldsToCreate = convertPlaceholdersToFieldInputs(
|
||||
uploadedItem.placeholders,
|
||||
(recipientPlaceholder, placeholder) =>
|
||||
findRecipientByPlaceholder(
|
||||
recipientPlaceholder,
|
||||
placeholder,
|
||||
orderedRecipients,
|
||||
orderedRecipients,
|
||||
),
|
||||
createdItem.id,
|
||||
);
|
||||
|
||||
if (fieldsToCreate.length > 0) {
|
||||
await tx.field.createMany({
|
||||
data: fieldsToCreate.map((field) => ({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: createdItem.id,
|
||||
recipientId: field.recipientId,
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta || undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createdItems;
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import { EnvelopeType } from '@prisma/client';
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-place-fields';
|
||||
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
|
||||
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
@@ -69,7 +71,7 @@ export const createEnvelopeRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
// For each file, stream to s3 and create the document data.
|
||||
// For each file: normalize, extract & clean placeholders, then upload.
|
||||
const envelopeItems = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
let pdf = Buffer.from(await file.arrayBuffer());
|
||||
@@ -82,20 +84,22 @@ export const createEnvelopeRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
const { id: documentDataId } = await putNormalizedPdfFileServerSide(
|
||||
{
|
||||
name: file.name,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(pdf),
|
||||
},
|
||||
{
|
||||
flattenForm: type !== EnvelopeType.TEMPLATE,
|
||||
},
|
||||
);
|
||||
const normalized = await normalizePdf(pdf, {
|
||||
flattenForm: type !== EnvelopeType.TEMPLATE,
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
|
||||
const { id: documentDataId } = await putPdfFileServerSide({
|
||||
name: file.name,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(cleanedPdf),
|
||||
});
|
||||
|
||||
return {
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
placeholders,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
+43
-6
@@ -22,7 +22,7 @@ export const createEnvelopeFieldsMeta: TrpcRouteMeta = {
|
||||
},
|
||||
};
|
||||
|
||||
const ZCreateFieldSchema = ZEnvelopeFieldAndMetaSchema.and(
|
||||
const ZCreateFieldBaseSchema = ZEnvelopeFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
recipientId: z.number().describe('The ID of the recipient to create the field for'),
|
||||
envelopeItemId: z
|
||||
@@ -31,14 +31,51 @@ const ZCreateFieldSchema = ZEnvelopeFieldAndMetaSchema.and(
|
||||
.describe(
|
||||
'The ID of the envelope item to put the field on. If not provided, field will be placed on the first item.',
|
||||
),
|
||||
page: ZFieldPageNumberSchema,
|
||||
positionX: ZClampedFieldPositionXSchema,
|
||||
positionY: ZClampedFieldPositionYSchema,
|
||||
width: ZClampedFieldWidthSchema,
|
||||
height: ZClampedFieldHeightSchema,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Position a field using explicit percentage-based coordinates.
|
||||
*/
|
||||
const ZCoordinatePositionSchema = z.object({
|
||||
page: ZFieldPageNumberSchema,
|
||||
positionX: ZClampedFieldPositionXSchema,
|
||||
positionY: ZClampedFieldPositionYSchema,
|
||||
width: ZClampedFieldWidthSchema,
|
||||
height: ZClampedFieldHeightSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Position a field using a PDF text placeholder (e.g. "{{name}}").
|
||||
*
|
||||
* The placeholder text is matched in the envelope item's PDF and the field is
|
||||
* placed at the bounding box of that match. Width and height can optionally be
|
||||
* overridden; when omitted the dimensions of the placeholder text are used.
|
||||
*/
|
||||
const ZPlaceholderPositionSchema = z.object({
|
||||
placeholder: z
|
||||
.string()
|
||||
.describe(
|
||||
'Text to search for in the PDF (e.g. "{{name}}"). The field will be placed at the location of this text.',
|
||||
),
|
||||
width: ZClampedFieldWidthSchema.optional().describe(
|
||||
'Override the width of the field. When omitted, the width of the placeholder text is used.',
|
||||
),
|
||||
height: ZClampedFieldHeightSchema.optional().describe(
|
||||
'Override the height of the field. When omitted, the height of the placeholder text is used.',
|
||||
),
|
||||
matchAll: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'When true, creates a field at every occurrence of the placeholder in the PDF. When false or omitted, only the first occurrence is used.',
|
||||
),
|
||||
});
|
||||
|
||||
const ZCreateFieldSchema = ZCreateFieldBaseSchema.and(
|
||||
z.union([ZCoordinatePositionSchema, ZPlaceholderPositionSchema]),
|
||||
);
|
||||
|
||||
export const ZCreateEnvelopeFieldsRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
data: ZCreateFieldSchema.array(),
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createAttachmentRoute } from './attachment/create-attachment';
|
||||
import { deleteAttachmentRoute } from './attachment/delete-attachment';
|
||||
import { findAttachmentsRoute } from './attachment/find-attachments';
|
||||
import { updateAttachmentRoute } from './attachment/update-attachment';
|
||||
import { bulkDeleteEnvelopesRoute } from './bulk-delete-envelopes';
|
||||
import { bulkMoveEnvelopesRoute } from './bulk-move-envelopes';
|
||||
import { createEnvelopeRoute } from './create-envelope';
|
||||
import { createEnvelopeItemsRoute } from './create-envelope-items';
|
||||
import { deleteEnvelopeRoute } from './delete-envelope';
|
||||
@@ -72,6 +74,10 @@ export const envelopeRouter = router({
|
||||
auditLog: {
|
||||
find: findEnvelopeAuditLogsRoute,
|
||||
},
|
||||
bulk: {
|
||||
move: bulkMoveEnvelopesRoute,
|
||||
delete: bulkDeleteEnvelopesRoute,
|
||||
},
|
||||
get: getEnvelopeRoute,
|
||||
getMany: getEnvelopesByIdsRoute,
|
||||
create: createEnvelopeRoute,
|
||||
|
||||
@@ -100,13 +100,15 @@ export const deleteOrganisationMembers = async ({
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
for (const member of membersToDelete) {
|
||||
await jobs.triggerJob({
|
||||
name: 'send.organisation-member-left.email',
|
||||
payload: {
|
||||
organisationId,
|
||||
memberUserId: userId,
|
||||
memberUserId: member.userId,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -90,10 +90,12 @@ export const profileRouter = router({
|
||||
|
||||
const parsedTeamId = teamId ? Number(teamId) : null;
|
||||
|
||||
if (Number.isNaN(parsedTeamId)) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid team ID provided',
|
||||
});
|
||||
if (typeof parsedTeamId === 'number') {
|
||||
if (Number.isNaN(parsedTeamId) || parsedTeamId <= 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid team ID provided',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await submitSupportTicket({
|
||||
|
||||
Reference in New Issue
Block a user