fix: merge conflicts

This commit is contained in:
Ephraim Atta-Duncan
2025-11-12 12:26:14 +00:00
317 changed files with 13305 additions and 11517 deletions

View File

@ -11,7 +11,7 @@ export const validateNumberField = (
const { minValue, maxValue, readOnly, required, numberFormat, fontSize } = fieldMeta || {};
if (numberFormat) {
if (numberFormat && value.length > 0) {
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
if (!foundRegex) {

View File

@ -1,12 +1,14 @@
import type { DocumentData } from '@prisma/client';
import type { EnvelopeItem } from '@prisma/client';
import { getFile } from '../universal/upload/get-file';
import { getEnvelopeItemPdfUrl } from '../utils/envelope-download';
import { downloadFile } from './download-file';
type DocumentVersion = 'original' | 'signed';
type DownloadPDFProps = {
documentData: DocumentData;
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined;
fileName?: string;
/**
* Specifies which version of the document to download.
@ -17,18 +19,19 @@ type DownloadPDFProps = {
};
export const downloadPDF = async ({
documentData,
envelopeItem,
token,
fileName,
version = 'signed',
}: DownloadPDFProps) => {
const bytes = await getFile({
type: documentData.type,
data: version === 'signed' ? documentData.data : documentData.initialData,
const downloadUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: envelopeItem,
token,
version,
});
const blob = new Blob([bytes], {
type: 'application/pdf',
});
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { Recipient } from '@prisma/client';
import type { Field, Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
@ -63,6 +63,8 @@ type UseEditorFieldsResponse = {
// Selected recipient
selectedRecipient: Recipient | null;
setSelectedRecipient: (recipientId: number | null) => void;
resetForm: (fields?: Field[]) => void;
};
export const useEditorFields = ({
@ -72,24 +74,30 @@ export const useEditorFields = ({
const [selectedFieldFormId, setSelectedFieldFormId] = useState<string | null>(null);
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
const generateDefaultValues = (fields?: Field[]) => {
const formFields = (fields || envelope.fields).map(
(field): TLocalField => ({
id: field.id,
formId: nanoid(),
envelopeItemId: field.envelopeItemId,
page: field.page,
type: field.type,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
recipientId: field.recipientId,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
}),
);
return {
fields: formFields,
};
};
const form = useForm<TEditorFieldsFormSchema>({
defaultValues: {
fields: envelope.fields.map(
(field): TLocalField => ({
id: field.id,
formId: nanoid(),
envelopeItemId: field.envelopeItemId,
page: field.page,
type: field.type,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
recipientId: field.recipientId,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
}),
),
},
defaultValues: generateDefaultValues(),
resolver: zodResolver(ZEditorFieldsFormSchema),
});
@ -165,10 +173,7 @@ export const useEditorFields = ({
const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) {
update(index, {
...localFields[index],
id,
});
form.setValue(`fields.${index}.id`, id);
}
};
@ -275,6 +280,10 @@ export const useEditorFields = ({
setSelectedRecipientId(foundRecipient?.id ?? null);
};
const resetForm = (fields?: Field[]) => {
form.reset(generateDefaultValues(fields));
};
return {
// Core state
localFields,
@ -298,6 +307,8 @@ export const useEditorFields = ({
// Selected recipient
selectedRecipient,
setSelectedRecipient,
resetForm,
};
};

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import Konva from 'konva';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
@ -25,6 +25,8 @@ export function usePageRenderer(renderFunction: RenderFunction) {
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Konva.Layer | null>(null);
const [renderError, setRenderError] = useState<boolean>(false);
/**
* The raw viewport with no scaling. Basically the actual PDF size.
*/
@ -105,6 +107,10 @@ export function usePageRenderer(renderFunction: RenderFunction) {
stage: stage.current,
pageLayer: pageLayer.current,
});
void document.fonts.ready.then(function () {
pageLayer.current?.batchDraw();
});
});
return () => {
@ -122,5 +128,7 @@ export function usePageRenderer(renderFunction: RenderFunction) {
unscaledViewport,
scaledViewport,
pageContext,
renderError,
setRenderError,
};
}

View File

@ -46,6 +46,7 @@ type EnvelopeEditorProviderValue = {
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
@ -66,8 +67,6 @@ type EnvelopeEditorProviderValue = {
};
syncEnvelope: () => Promise<void>;
// refetchEnvelope: () => Promise<void>;
// updateEnvelope: (envelope: TEnvelope) => Promise<void>;
};
interface EnvelopeEditorProviderProps {
@ -151,7 +150,7 @@ export const EnvelopeEditorProvider = ({
});
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: ({ recipients }) => {
onSuccess: ({ data: recipients }) => {
setEnvelope((prev) => ({
...prev,
recipients,
@ -197,7 +196,7 @@ export const EnvelopeEditorProvider = ({
});
// Insert the IDs into the local fields.
envelopeFields.fields.forEach((field) => {
envelopeFields.data.forEach((field) => {
const localField = localFields.find((localField) => localField.formId === field.formId);
if (localField && !localField.id) {
@ -215,7 +214,6 @@ export const EnvelopeEditorProvider = ({
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
data: envelopeUpdates.data,
meta: envelopeUpdates.meta,
});
@ -237,6 +235,13 @@ export const EnvelopeEditorProvider = ({
setEnvelopeDebounced(envelopeUpdates);
};
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
...envelopeUpdates,
});
};
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = envelope.recipients.findIndex(
@ -324,6 +329,7 @@ export const EnvelopeEditorProvider = ({
setLocalEnvelope,
getRecipientColorKey,
updateEnvelope,
updateEnvelopeAsync,
setRecipientsDebounced,
setRecipientsAsync,
editorFields,

View File

@ -1,13 +1,14 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import React from 'react';
import type { DocumentData } from '@prisma/client';
import type { Field, Recipient } from '@prisma/client';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import type { TEnvelope } from '../../types/envelope';
import { getFile } from '../../universal/upload/get-file';
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download';
type FileData =
| {
@ -18,34 +19,61 @@ type FileData =
status: 'loaded';
};
type EnvelopeRenderOverrideSettings = {
mode?: FieldRenderMode;
showRecipientTooltip?: boolean;
showRecipientSigningStatus?: boolean;
};
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
type EnvelopeRenderProviderValue = {
getPdfBuffer: (documentDataId: string) => FileData | null;
getPdfBuffer: (envelopeItemId: string) => FileData | null;
envelopeItems: EnvelopeRenderItem[];
envelopeStatus: TEnvelope['status'];
envelopeType: TEnvelope['type'];
currentEnvelopeItem: EnvelopeRenderItem | null;
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: TEnvelope['fields'];
fields: Field[];
recipients: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
getRecipientColorKey: (recipientId: number) => TRecipientColor;
renderError: boolean;
setRenderError: (renderError: boolean) => void;
overrideSettings?: EnvelopeRenderOverrideSettings;
};
interface EnvelopeRenderProviderProps {
children: React.ReactNode;
envelope: Pick<TEnvelope, 'envelopeItems'>;
envelope: Pick<TEnvelope, 'envelopeItems' | 'status' | 'type'>;
/**
* Optional fields which are passed down to renderers for custom rendering needs.
*
* Only pass if the CustomRenderer you are passing in wants fields.
*/
fields?: TEnvelope['fields'];
fields?: Field[];
/**
* Optional recipient IDs used to determine the color of the fields.
* Optional recipient used to determine the color of the fields and hover
* previews.
*
* Only required for generic page renderers.
*/
recipientIds?: number[];
recipients?: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
/**
* The token to access the envelope.
*
* If not provided, it will be assumed that the current user can access the document.
*/
token: string | undefined;
/**
* Custom override settings for generic page renderers.
*/
overrideSettings?: EnvelopeRenderOverrideSettings;
}
const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null);
@ -67,39 +95,51 @@ export const EnvelopeRenderProvider = ({
children,
envelope,
fields,
recipientIds = [],
token,
recipients = [],
overrideSettings,
}: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId.
const [files, setFiles] = useState<Record<string, FileData>>({});
const [currentItem, setItem] = useState<EnvelopeRenderItem | null>(null);
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(null);
const [renderError, setRenderError] = useState<boolean>(false);
const envelopeItems = useMemo(
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
[envelope.envelopeItems],
);
const loadEnvelopeItemPdfFile = async (documentData: DocumentData) => {
if (files[documentData.id]?.status === 'loading') {
const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => {
if (files[envelopeItem.id]?.status === 'loading') {
return;
}
if (!files[documentData.id]) {
if (!files[envelopeItem.id]) {
setFiles((prev) => ({
...prev,
[documentData.id]: {
[envelopeItem.id]: {
status: 'loading',
},
}));
}
try {
const file = await getFile(documentData);
const downloadUrl = getEnvelopeItemPdfUrl({
type: 'view',
envelopeItem: envelopeItem,
token,
});
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const file = await blob.arrayBuffer();
setFiles((prev) => ({
...prev,
[documentData.id]: {
file,
[envelopeItem.id]: {
file: new Uint8Array(file),
status: 'loaded',
},
}));
@ -108,7 +148,7 @@ export const EnvelopeRenderProvider = ({
setFiles((prev) => ({
...prev,
[documentData.id]: {
[envelopeItem.id]: {
status: 'error',
},
}));
@ -116,8 +156,8 @@ export const EnvelopeRenderProvider = ({
};
const getPdfBuffer = useCallback(
(documentDataId: string) => {
return files[documentDataId] || null;
(envelopeItemId: string) => {
return files[envelopeItemId] || null;
},
[files],
);
@ -125,11 +165,15 @@ export const EnvelopeRenderProvider = ({
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
setItem(foundItem ?? null);
setCurrentItem(foundItem ?? null);
};
// Set the selected item to the first item if none is set.
useEffect(() => {
if (currentItem && !envelopeItems.some((item) => item.id === currentItem.id)) {
setCurrentItem(null);
}
if (!currentItem && envelopeItems.length > 0) {
setCurrentEnvelopeItem(envelopeItems[0].id);
}
@ -137,13 +181,18 @@ export const EnvelopeRenderProvider = ({
// Look for any missing pdf files and load them.
useEffect(() => {
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.documentDataId]);
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.id]);
for (const item of missingFiles) {
void loadEnvelopeItemPdfFile(item.documentData);
void loadEnvelopeItemPdfFile(item);
}
}, [envelope.envelopeItems]);
const recipientIds = useMemo(
() => recipients.map((recipient) => recipient.id).sort(),
[recipients],
);
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
@ -160,10 +209,16 @@ export const EnvelopeRenderProvider = ({
value={{
getPdfBuffer,
envelopeItems,
envelopeStatus: envelope.status,
envelopeType: envelope.type,
currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem,
fields: fields ?? [],
recipients,
getRecipientColorKey,
renderError,
setRenderError,
overrideSettings,
}}
>
{children}

View File

@ -12,7 +12,6 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL =
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
export const API_V2_BETA_URL = '/api/v2-beta';
export const API_V2_URL = '/api/v2';
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';
export const IS_ENVELOPES_ENABLED = env('NEXT_PUBLIC_FEATURE_ENVELOPES_ENABLED') === 'true';

View File

@ -32,6 +32,7 @@ export type JobDefinition<Name extends string = string, Schema = any> = {
name: string;
version: string;
enabled?: boolean;
optimizeParallelism?: boolean;
trigger: {
name: Name;
schema?: z.ZodType<Schema>;

View File

@ -40,6 +40,7 @@ export class InngestJobProvider extends BaseJobProvider {
{
id: job.id,
name: job.name,
optimizeParallelism: job.optimizeParallelism ?? false,
},
{
event: job.trigger.name,

View File

@ -189,29 +189,65 @@ export const run = async ({
settings,
});
const newDocumentData = await Promise.all(
envelopeItems.map(async (envelopeItem) =>
io.runTask(`decorate-and-sign-envelope-item-${envelopeItem.id}`, async () => {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;
// !: The commented out code is our desired implementation but we're seemingly
// !: running into issues with inngest parallelism in production.
// !: Until this is resolved we will do this sequentially which is slower but
// !: will actually work.
// const decoratePromises: Array<Promise<{ oldDocumentDataId: string; newDocumentDataId: string }>> =
// [];
if (!envelopeItemFields) {
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
}
// for (const envelopeItem of envelopeItems) {
// const task = io.runTask(`decorate-${envelopeItem.id}`, async () => {
// const envelopeItemFields = envelope.envelopeItems.find(
// (item) => item.id === envelopeItem.id,
// )?.field;
return decorateAndSignPdf({
envelope,
envelopeItem,
envelopeItemFields,
isRejected,
rejectionReason,
certificateData,
auditLogData,
});
}),
),
);
// if (!envelopeItemFields) {
// throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
// }
// return decorateAndSignPdf({
// envelope,
// envelopeItem,
// envelopeItemFields,
// isRejected,
// rejectionReason,
// certificateData,
// auditLogData,
// });
// });
// decoratePromises.push(task);
// }
// const newDocumentData = await Promise.all(decoratePromises);
// TODO: Remove once parallelization is working
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
for (const envelopeItem of envelopeItems) {
const result = await io.runTask(`decorate-${envelopeItem.id}`, async () => {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;
if (!envelopeItemFields) {
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
}
return decorateAndSignPdf({
envelope,
envelopeItem,
envelopeItemFields,
isRejected,
rejectionReason,
certificateData,
auditLogData,
});
});
newDocumentData.push(result);
}
const postHog = PostHogServerClient();

View File

@ -18,6 +18,7 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
id: SEAL_DOCUMENT_JOB_DEFINITION_ID,
name: 'Seal Document',
version: '1.0.0',
optimizeParallelism: true,
trigger: {
name: SEAL_DOCUMENT_JOB_DEFINITION_ID,
schema: SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA,

View File

@ -56,11 +56,11 @@
"skia-canvas": "^3.0.8",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",
"zod": "3.25.76"
"zod": "^3.25.76"
},
"devDependencies": {
"@playwright/browser-chromium": "1.52.0",
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4"
}
}
}

View File

@ -78,6 +78,14 @@ export const adminFindDocuments = async ({
url: true,
},
},
envelopeItems: {
select: {
id: true,
envelopeId: true,
title: true,
order: true,
},
},
},
}),
prisma.envelope.count({

View File

@ -0,0 +1,363 @@
import type { DocumentStatus } from '@prisma/client';
import { EnvelopeType } from '@prisma/client';
import type { DateRange } from '@documenso/lib/types/search-params';
import { kyselyPrisma, sql } from '@documenso/prisma';
export type OrganisationSummary = {
totalTeams: number;
totalMembers: number;
totalDocuments: number;
activeDocuments: number;
completedDocuments: number;
volumeThisPeriod: number;
volumeAllTime: number;
};
export type OrganisationDetailedInsights = {
teams: TeamInsights[];
users: UserInsights[];
documents: DocumentInsights[];
totalPages: number;
summary?: OrganisationSummary;
};
export type TeamInsights = {
id: number;
name: string;
memberCount: number;
documentCount: number;
createdAt: Date;
};
export type UserInsights = {
id: number;
name: string;
email: string;
documentCount: number;
signedDocumentCount: number;
createdAt: Date;
};
export type DocumentInsights = {
id: string;
title: string;
status: DocumentStatus;
teamName: string;
createdAt: Date;
completedAt: Date | null;
};
export type GetOrganisationDetailedInsightsOptions = {
organisationId: string;
page?: number;
perPage?: number;
dateRange?: DateRange;
view: 'teams' | 'users' | 'documents';
};
export async function getOrganisationDetailedInsights({
organisationId,
page = 1,
perPage = 10,
dateRange = 'last30days',
view,
}: GetOrganisationDetailedInsightsOptions): Promise<OrganisationDetailedInsights> {
const offset = Math.max(page - 1, 0) * perPage;
const now = new Date();
let createdAtFrom: Date | null = null;
switch (dateRange) {
case 'last30days': {
createdAtFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
}
case 'last90days': {
createdAtFrom = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
break;
}
case 'lastYear': {
createdAtFrom = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
break;
}
case 'allTime':
default:
createdAtFrom = null;
break;
}
const summaryData = await getOrganisationSummary(organisationId, createdAtFrom);
const viewData = await (async () => {
switch (view) {
case 'teams':
return await getTeamInsights(organisationId, offset, perPage, createdAtFrom);
case 'users':
return await getUserInsights(organisationId, offset, perPage, createdAtFrom);
case 'documents':
return await getDocumentInsights(organisationId, offset, perPage, createdAtFrom);
default:
throw new Error(`Invalid view: ${view}`);
}
})();
return {
...viewData,
summary: summaryData,
};
}
async function getTeamInsights(
organisationId: string,
offset: number,
perPage: number,
createdAtFrom: Date | null,
): Promise<OrganisationDetailedInsights> {
const teamsQuery = kyselyPrisma.$kysely
.selectFrom('Team as t')
.leftJoin('Envelope as e', (join) =>
join
.onRef('t.id', '=', 'e.teamId')
.on('e.deletedAt', 'is', null)
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.leftJoin('TeamGroup as tg', 'tg.teamId', 't.id')
.leftJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId')
.leftJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id')
.leftJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId')
.where('t.organisationId', '=', organisationId)
.select([
't.id as id',
't.name as name',
't.createdAt as createdAt',
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
(createdAtFrom
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
: sql<number>`COUNT(DISTINCT e.id)`
).as('documentCount'),
])
.groupBy(['t.id', 't.name', 't.createdAt'])
.orderBy('documentCount', 'desc')
.limit(perPage)
.offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('Team as t')
.where('t.organisationId', '=', organisationId)
.select(({ fn }) => [fn.countAll().as('count')]);
const [teams, countResult] = await Promise.all([teamsQuery.execute(), countQuery.execute()]);
const count = Number(countResult[0]?.count || 0);
return {
teams: teams as TeamInsights[],
users: [],
documents: [],
totalPages: Math.ceil(Number(count) / perPage),
};
}
async function getUserInsights(
organisationId: string,
offset: number,
perPage: number,
createdAtFrom: Date | null,
): Promise<OrganisationDetailedInsights> {
const usersBase = kyselyPrisma.$kysely
.selectFrom('OrganisationMember as om')
.innerJoin('User as u', 'u.id', 'om.userId')
.where('om.organisationId', '=', organisationId)
.leftJoin('Envelope as e', (join) =>
join
.onRef('e.userId', '=', 'u.id')
.on('e.deletedAt', 'is', null)
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.leftJoin('Team as td', (join) =>
join.onRef('td.id', '=', 'e.teamId').on('td.organisationId', '=', organisationId),
)
.leftJoin('Recipient as r', (join) =>
join.onRef('r.email', '=', 'u.email').on('r.signedAt', 'is not', null),
)
.leftJoin('Envelope as se', (join) =>
join
.onRef('se.id', '=', 'r.envelopeId')
.on('se.deletedAt', 'is', null)
.on('se.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.leftJoin('Team as ts', (join) =>
join.onRef('ts.id', '=', 'se.teamId').on('ts.organisationId', '=', organisationId),
);
const usersQuery = usersBase
.select([
'u.id as id',
'u.name as name',
'u.email as email',
'u.createdAt as createdAt',
(createdAtFrom
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
: sql<number>`COUNT(DISTINCT CASE WHEN td.id IS NOT NULL THEN e.id END)`
).as('documentCount'),
(createdAtFrom
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
: sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' THEN e.id END)`
).as('signedDocumentCount'),
])
.groupBy(['u.id', 'u.name', 'u.email', 'u.createdAt'])
.orderBy('u.createdAt', 'desc')
.limit(perPage)
.offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('OrganisationMember as om')
.innerJoin('User as u', 'u.id', 'om.userId')
.where('om.organisationId', '=', organisationId)
.select(({ fn }) => [fn.countAll().as('count')]);
const [users, countResult] = await Promise.all([usersQuery.execute(), countQuery.execute()]);
const count = Number(countResult[0]?.count || 0);
return {
teams: [],
users: users as UserInsights[],
documents: [],
totalPages: Math.ceil(Number(count) / perPage),
};
}
async function getDocumentInsights(
organisationId: string,
offset: number,
perPage: number,
createdAtFrom: Date | null,
): Promise<OrganisationDetailedInsights> {
let documentsQuery = kyselyPrisma.$kysely
.selectFrom('Envelope as e')
.innerJoin('Team as t', 'e.teamId', 't.id')
.where('t.organisationId', '=', organisationId)
.where('e.deletedAt', 'is', null)
.where(() => sql`e.type = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`);
if (createdAtFrom) {
documentsQuery = documentsQuery.where('e.createdAt', '>=', createdAtFrom);
}
documentsQuery = documentsQuery
.select([
'e.id as id',
'e.title as title',
'e.status as status',
'e.createdAt as createdAt',
'e.completedAt as completedAt',
't.name as teamName',
])
.orderBy('e.createdAt', 'desc')
.limit(perPage)
.offset(offset);
let countQuery = kyselyPrisma.$kysely
.selectFrom('Envelope as e')
.innerJoin('Team as t', 'e.teamId', 't.id')
.where('t.organisationId', '=', organisationId)
.where('e.deletedAt', 'is', null)
.where(() => sql`e.type = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`);
if (createdAtFrom) {
countQuery = countQuery.where('e.createdAt', '>=', createdAtFrom);
}
countQuery = countQuery.select(({ fn }) => [fn.countAll().as('count')]);
const [documents, countResult] = await Promise.all([
documentsQuery.execute(),
countQuery.execute(),
]);
const count = Number((countResult[0] as { count: number })?.count || 0);
return {
teams: [],
users: [],
documents: documents.map((doc) => ({
...doc,
id: String((doc as { id: number }).id),
})) as DocumentInsights[],
totalPages: Math.ceil(Number(count) / perPage),
};
}
async function getOrganisationSummary(
organisationId: string,
createdAtFrom: Date | null,
): Promise<OrganisationSummary> {
const summaryQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o')
.where('o.id', '=', organisationId)
.select([
sql<number>`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as(
'totalTeams',
),
sql<number>`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as(
'totalMembers',
),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT'
)`.as('totalDocuments'),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status IN ('DRAFT', 'PENDING')
)`.as('activeDocuments'),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
)`.as('completedDocuments'),
(createdAtFrom
? sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id
AND e2."deletedAt" IS NULL
AND e2.type = 'DOCUMENT'
AND e2.status = 'COMPLETED'
AND e2."createdAt" >= ${createdAtFrom}
)`
: sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id
AND e2."deletedAt" IS NULL
AND e2.type = 'DOCUMENT'
AND e2.status = 'COMPLETED'
)`
).as('volumeThisPeriod'),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
)`.as('volumeAllTime'),
]);
const result = await summaryQuery.executeTakeFirst();
return {
totalTeams: Number(result?.totalTeams || 0),
totalMembers: Number(result?.totalMembers || 0),
totalDocuments: Number(result?.totalDocuments || 0),
activeDocuments: Number(result?.activeDocuments || 0),
completedDocuments: Number(result?.completedDocuments || 0),
volumeThisPeriod: Number(result?.volumeThisPeriod || 0),
volumeAllTime: Number(result?.volumeAllTime || 0),
};
}

View File

@ -1,13 +1,17 @@
import { DocumentStatus, EnvelopeType, SubscriptionStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import type { DateRange } from '@documenso/lib/types/search-params';
import { kyselyPrisma, sql } from '@documenso/prisma';
export type SigningVolume = {
export type OrganisationInsights = {
id: number;
name: string;
signingVolume: number;
createdAt: Date;
planId: string;
customerId: string | null;
subscriptionStatus?: string;
teamCount?: number;
memberCount?: number;
};
export type GetSigningVolumeOptions = {
@ -28,28 +32,26 @@ export async function getSigningVolume({
const offset = Math.max(page - 1, 0) * perPage;
let findQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.leftJoin('Envelope as e', (join) =>
join
.onRef('t.id', '=', 'e.teamId')
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('e.deletedAt', 'is', null),
.on('e.deletedAt', 'is', null)
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.where('e.type', '=', EnvelopeType.DOCUMENT)
.select([
's.id as id',
's.createdAt as createdAt',
's.planId as planId',
'o.id as id',
'o.createdAt as createdAt',
'o.customerId as customerId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
])
.groupBy(['s.id', 'o.name']);
.groupBy(['o.id', 'o.name', 'o.customerId']);
switch (sortBy) {
case 'name':
@ -68,19 +70,127 @@ export async function getSigningVolume({
findQuery = findQuery.limit(perPage).offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select(({ fn }) => [fn.countAll().as('count')]);
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return {
leaderboard: results,
organisations: results,
totalPages: Math.ceil(Number(count) / perPage),
};
}
export type GetOrganisationInsightsOptions = GetSigningVolumeOptions & {
dateRange?: DateRange;
startDate?: Date;
endDate?: Date;
};
export async function getOrganisationInsights({
search = '',
page = 1,
perPage = 10,
sortBy = 'signingVolume',
sortOrder = 'desc',
dateRange = 'last30days',
startDate,
endDate,
}: GetOrganisationInsightsOptions) {
const offset = Math.max(page - 1, 0) * perPage;
const now = new Date();
let dateCondition = sql`1=1`;
if (startDate && endDate) {
dateCondition = sql`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`;
} else {
switch (dateRange) {
case 'last30days': {
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
dateCondition = sql`e."createdAt" >= ${thirtyDaysAgo}`;
break;
}
case 'last90days': {
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`;
break;
}
case 'lastYear': {
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
dateCondition = sql`e."createdAt" >= ${oneYearAgo}`;
break;
}
case 'allTime':
default:
dateCondition = sql`1=1`;
break;
}
}
let findQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.leftJoin('Envelope as e', (join) =>
join
.onRef('t.id', '=', 'e.teamId')
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('e.deletedAt', 'is', null)
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.leftJoin('OrganisationMember as om', 'o.id', 'om.organisationId')
.leftJoin('Subscription as s', 'o.id', 's.organisationId')
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select([
'o.id as id',
'o.createdAt as createdAt',
'o.customerId as customerId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND ${dateCondition} THEN e.id END)`.as(
'signingVolume',
),
sql<number>`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'),
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
sql<string>`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as(
'subscriptionStatus',
),
])
.groupBy(['o.id', 'o.name', 'o.customerId', 's.status']);
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');
}
findQuery = findQuery.limit(perPage).offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return {
organisations: results,
totalPages: Math.ceil(Number(count) / perPage),
};
}

View File

@ -19,7 +19,7 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import type { TRecipientAccessAuth, TRecipientActionAuth } from '../../types/document-auth';
import type { TRecipientAccessAuth } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
@ -37,7 +37,6 @@ export type CompleteDocumentWithTokenOptions = {
token: string;
id: EnvelopeIdOptions;
userId?: number;
authOptions?: TRecipientActionAuth;
accessAuthOptions?: TRecipientAccessAuth;
requestMetadata?: RequestMetadata;
nextSigner?: {

View File

@ -248,6 +248,14 @@ export const findDocuments = async ({
url: true,
},
},
envelopeItems: {
select: {
id: true,
envelopeId: true,
title: true,
order: true,
},
},
},
}),
prisma.envelope.count({

View File

@ -92,6 +92,10 @@ export const getDocumentAndSenderByToken = async ({
},
envelopeItems: {
select: {
id: true,
title: true,
order: true,
envelopeId: true,
documentData: true,
},
},

View File

@ -63,5 +63,8 @@ export const getDocumentWithDetailsById = async ({
documentId: legacyDocumentId,
password: null,
},
envelopeItems: envelope.envelopeItems.map((envelopeItem) => ({
...envelopeItem,
})),
};
};

View File

@ -1,4 +1,4 @@
import type { DocumentData, Envelope, EnvelopeItem } from '@prisma/client';
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentStatus,
@ -20,7 +20,14 @@ import { validateCheckboxLength } from '../../advanced-fields-validation/validat
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldAndMetaSchema,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
@ -174,72 +181,22 @@ export const sendDocument = async ({
const fieldsToAutoInsert: { fieldId: number; customText: string }[] = [];
// Auto insert radio and checkboxes that have default values.
// Validate and autoinsert fields for V2 envelopes.
if (envelope.internalVersion === 2) {
for (const field of envelope.fields) {
if (field.type === FieldType.RADIO) {
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
for (const unknownField of envelope.fields) {
const recipient = envelope.recipients.find((r) => r.id === unknownField.recipientId);
const checkedItemIndex = values.findIndex((value) => value.checked);
if (checkedItemIndex !== -1) {
fieldsToAutoInsert.push({
fieldId: field.id,
customText: toRadioCustomText(checkedItemIndex),
});
}
}
if (field.type === FieldType.DROPDOWN) {
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
if (defaultValue && values.some((value) => value.value === defaultValue)) {
fieldsToAutoInsert.push({
fieldId: field.id,
customText: defaultValue,
});
}
}
if (field.type === FieldType.CHECKBOX) {
const {
values = [],
validationRule,
validationLength,
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkedIndices: number[] = [];
values.forEach((value, i) => {
if (value.checked) {
checkedIndices.push(i);
}
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
let isValid = true;
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField);
if (validationRule && validationLength) {
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
if (!validation) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid checkbox validation rule',
});
}
isValid = validateCheckboxLength(
checkedIndices.length,
validation.value,
validationLength,
);
}
if (isValid) {
fieldsToAutoInsert.push({
fieldId: field.id,
customText: toCheckboxCustomText(checkedIndices),
});
}
// Only auto-insert fields if the recipient has not been sent the document yet.
if (fieldToAutoInsert && recipient.sendStatus !== SendStatus.SENT) {
fieldsToAutoInsert.push(fieldToAutoInsert);
}
}
}
@ -259,6 +216,7 @@ export const sendDocument = async ({
if (envelope.internalVersion === 2) {
const autoInsertedFields = await Promise.all(
fieldsToAutoInsert.map(async (field) => {
// Warning: Only auto-insert fields if the recipient has not been sent the document yet.
return await tx.field.update({
where: {
id: field.fieldId,
@ -371,3 +329,113 @@ const injectFormValuesIntoDocument = async (
},
});
};
/**
* Extracts the auto insertion values for a given field.
*
* If field is not auto insertable, returns `null`.
*/
export const extractFieldAutoInsertValues = (
unknownField: Field,
): { fieldId: number; customText: string } | null => {
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
if (parsedField.error) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message,
});
}
const field = parsedField.data;
const fieldId = unknownField.id;
// Auto insert text fields with prefilled values.
if (field.type === FieldType.TEXT) {
const { text } = ZTextFieldMeta.parse(field.fieldMeta);
if (text) {
return {
fieldId,
customText: text,
};
}
}
// Auto insert number fields with prefilled values.
if (field.type === FieldType.NUMBER) {
const { value } = ZNumberFieldMeta.parse(field.fieldMeta);
if (value) {
return {
fieldId,
customText: value,
};
}
}
// Auto insert radio fields with the pre-checked value.
if (field.type === FieldType.RADIO) {
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedItemIndex = values.findIndex((value) => value.checked);
if (checkedItemIndex !== -1) {
return {
fieldId,
customText: toRadioCustomText(checkedItemIndex),
};
}
}
// Auto insert dropdown fields with the default value.
if (field.type === FieldType.DROPDOWN) {
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
if (defaultValue && values.some((value) => value.value === defaultValue)) {
return {
fieldId,
customText: defaultValue,
};
}
}
// Auto insert checkbox fields with the pre-checked values.
if (field.type === FieldType.CHECKBOX) {
const {
values = [],
validationRule,
validationLength,
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkedIndices: number[] = [];
values.forEach((value, i) => {
if (value.checked) {
checkedIndices.push(i);
}
});
let isValid = true;
if (validationRule && validationLength) {
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
if (!validation) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid checkbox validation rule',
});
}
isValid = validateCheckboxLength(checkedIndices.length, validation.value, validationLength);
}
if (isValid && checkedIndices.length > 0) {
return {
fieldId,
customText: toCheckboxCustomText(checkedIndices),
};
}
}
return null;
};

View File

@ -16,11 +16,16 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type {
TDocumentAccessAuthTypes,
TDocumentActionAuthTypes,
TRecipientAccessAuthTypes,
TRecipientActionAuthTypes,
} from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import type { TFieldAndMeta } from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
@ -34,6 +39,25 @@ import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & {
documentDataId: string;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
type CreateEnvelopeRecipientOptions = {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
fields?: CreateEnvelopeRecipientFieldOptions[];
};
export type CreateEnvelopeOptions = {
userId: number;
teamId: number;
@ -46,7 +70,6 @@ export type CreateEnvelopeOptions = {
envelopeItems: { title?: string; documentDataId: string; order?: number }[];
formValues?: TDocumentFormValues;
timezone?: string;
userTimezone?: string;
templateType?: TemplateType;
@ -56,7 +79,7 @@ export type CreateEnvelopeOptions = {
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
recipients?: TCreateEnvelopeRequest['recipients'];
recipients?: CreateEnvelopeRecipientOptions[];
folderId?: string;
};
attachments?: Array<{
@ -83,7 +106,6 @@ export const createEnvelope = async ({
title,
externalId,
formValues,
timezone,
userTimezone,
folderId,
templateType,
@ -142,6 +164,7 @@ export const createEnvelope = async ({
let envelopeItems: { title?: string; documentDataId: string; order?: number }[] =
data.envelopeItems;
// Todo: Envelopes - Remove
if (normalizePdf) {
envelopeItems = await Promise.all(
data.envelopeItems.map(async (item) => {
@ -219,7 +242,7 @@ export const createEnvelope = async ({
// userTimezone is last because it's always passed in regardless of the organisation/team settings
// for uploads from the frontend
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
const timezoneToUse = meta?.timezone || settings.documentTimezone || userTimezone;
const documentMeta = await prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, {

View File

@ -1,10 +1,12 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { extractFieldAutoInsertValues } from '../document/send-document';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@ -98,14 +100,28 @@ export const getEnvelopeForDirectTemplateSigning = async ({
});
}
const documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: envelope.authOptions,
recipient,
userId,
authOptions: accessAuth,
// Currently not using this since for direct templates "User" access means they just need to be
// logged in.
// const documentAccessValid = await isRecipientAuthorized({
// type: 'ACCESS',
// documentAuthOptions: envelope.authOptions,
// recipient,
// userId,
// authOptions: accessAuth,
// });
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
});
// Ensure typesafety when we add more options.
const documentAccessValid = derivedRecipientAccessAuth.every((auth) =>
match(auth)
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(userId))
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
.exhaustive(),
);
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid access values',
@ -128,7 +144,20 @@ export const getEnvelopeForDirectTemplateSigning = async ({
envelope,
recipient: {
...recipient,
token: envelope.directLink?.token || '',
directToken: envelope.directLink?.token || '',
fields: recipient.fields.map((field) => {
const autoInsertValue = extractFieldAutoInsertValues(field);
if (!autoInsertValue) {
return field;
}
return {
...field,
inserted: true,
customText: autoInsertValue.customText,
};
}),
},
recipientSignature: null,
isRecipientsTurn: true,

View File

@ -2,7 +2,6 @@ import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } fro
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import DocumentMetaSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeSchema';
@ -12,7 +11,7 @@ import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { ZFieldSchema } from '../../types/field';
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
import { ZRecipientLiteSchema } from '../../types/recipient';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { getTeamSettings } from '../team/get-team-settings';
@ -64,28 +63,21 @@ export const ZEnvelopeForSigningResponse = z.object({
rejectionReason: true,
})
.extend({
fields: ZFieldSchema.omit({
documentId: true,
templateId: true,
fields: ZEnvelopeFieldSchema.extend({
signature: SignatureSchema.pick({
signatureImageAsBase64: true,
typedSignature: true,
}).nullish(),
}).array(),
})
.array(),
envelopeItems: EnvelopeItemSchema.pick({
envelopeId: true,
id: true,
title: true,
documentDataId: true,
order: true,
})
.extend({
documentData: DocumentDataSchema.pick({
type: true,
id: true,
data: true,
initialData: true,
}),
})
.array(),
}).array(),
team: TeamSchema.pick({
id: true,
@ -117,6 +109,7 @@ export const ZEnvelopeForSigningResponse = z.object({
signingOrder: true,
rejectionReason: true,
}).extend({
directToken: z.string().nullish(),
fields: ZFieldSchema.omit({
documentId: true,
templateId: true,
@ -199,11 +192,7 @@ export const getEnvelopeForRecipientSigning = async ({
signingOrder: 'asc',
},
},
envelopeItems: {
include: {
documentData: true,
},
},
envelopeItems: true,
team: {
select: {
id: true,

View File

@ -54,54 +54,3 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};
export const getEnvelopeDirectTemplateRequiredAccessData = async ({ token }: { token: string }) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.TEMPLATE,
directLink: {
enabled: true,
token,
},
status: DocumentStatus.DRAFT,
},
include: {
recipients: {
where: {
token,
},
},
directLink: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const recipient = envelope.recipients.find(
(r) => r.id === envelope.directLink?.directTemplateRecipientId,
);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const recipientUserAccount = await prisma.user.findFirst({
where: {
email: recipient.email.toLowerCase(),
},
select: {
id: true,
},
});
return {
recipientEmail: recipient.email,
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};

View File

@ -26,9 +26,9 @@ export interface CreateEnvelopeFieldsOptions {
envelopeItemId?: string;
recipientId: number;
pageNumber: number;
pageX: number;
pageY: number;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
})[];
@ -122,9 +122,9 @@ export const createEnvelopeFields = async ({
const newlyCreatedFields = await tx.field.createManyAndReturn({
data: validatedFields.map((field) => ({
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',

View File

@ -11,7 +11,7 @@ export type GetFieldByIdOptions = {
userId: number;
teamId: number;
fieldId: number;
envelopeType: EnvelopeType;
envelopeType?: EnvelopeType;
};
export const getFieldById = async ({
@ -41,7 +41,7 @@ export const getFieldById = async ({
type: 'envelopeId',
id: field.envelopeId,
},
type: envelopeType,
type: envelopeType ?? null,
userId,
teamId,
});

View File

@ -158,7 +158,7 @@ export const setFieldsForDocument = async ({
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value),
String(numberFieldParsedMeta.value || ''),
numberFieldParsedMeta,
false,
);

View File

@ -129,7 +129,7 @@ export const setFieldsForTemplate = async ({
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value),
String(numberFieldParsedMeta.value || ''),
numberFieldParsedMeta,
);
if (errors.length > 0) {

View File

@ -10,18 +10,21 @@ import {
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateDocumentFieldsOptions {
export interface UpdateEnvelopeFieldsOptions {
userId: number;
teamId: number;
documentId: number;
id: EnvelopeIdOptions;
type?: EnvelopeType | null; // Only used to enforce the type.
fields: {
id: number;
type?: FieldType;
pageNumber?: number;
envelopeItemId?: string;
pageX?: number;
pageY?: number;
width?: number;
@ -31,19 +34,17 @@ export interface UpdateDocumentFieldsOptions {
requestMetadata: ApiRequestMetadata;
}
export const updateDocumentFields = async ({
export const updateEnvelopeFields = async ({
userId,
teamId,
documentId,
id,
type = null,
fields,
requestMetadata,
}: UpdateDocumentFieldsOptions) => {
}: UpdateEnvelopeFieldsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
id,
type,
userId,
teamId,
});
@ -53,18 +54,19 @@ export const updateDocumentFields = async ({
include: {
recipients: true,
fields: true,
envelopeItems: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -96,6 +98,29 @@ export const updateDocumentFields = async ({
});
}
const fieldType = field.type || originalField.type;
const fieldMetaType = field.fieldMeta?.type || originalField.fieldMeta?.type;
// Not going to mess with V1 envelopes.
if (
envelope.internalVersion === 2 &&
fieldMetaType &&
fieldMetaType.toLowerCase() !== fieldType.toLowerCase()
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Field meta type does not match the field type',
});
}
if (
field.envelopeItemId &&
!envelope.envelopeItems.some((item) => item.id === field.envelopeItemId)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item not found',
});
}
return {
originalField,
updateData: field,
@ -118,27 +143,30 @@ export const updateDocumentFields = async ({
width: updateData.width,
height: updateData.height,
fieldMeta: updateData.fieldMeta,
envelopeItemId: updateData.envelopeItemId,
},
});
const changes = diffFieldChanges(originalField, updatedField);
// Handle field updated audit log.
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: updatedField.secondaryId,
fieldRecipientEmail: recipientEmail,
fieldRecipientId: updatedField.recipientId,
fieldType: updatedField.type,
changes,
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
const changes = diffFieldChanges(originalField, updatedField);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: updatedField.secondaryId,
fieldRecipientEmail: recipientEmail,
fieldRecipientId: updatedField.recipientId,
fieldType: updatedField.type,
changes,
},
}),
});
}
}
return updatedField;

View File

@ -1,116 +0,0 @@
import { EnvelopeType, type FieldType } from '@prisma/client';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateTemplateFieldsOptions {
userId: number;
teamId: number;
templateId: number;
fields: {
id: number;
type?: FieldType;
pageNumber?: number;
pageX?: number;
pageY?: number;
width?: number;
height?: number;
fieldMeta?: TFieldMetaSchema;
}[];
}
export const updateTemplateFields = async ({
userId,
teamId,
templateId,
fields,
}: UpdateTemplateFieldsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
fields: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const fieldsToUpdate = fields.map((field) => {
const originalField = envelope.fields.find((existingField) => existingField.id === field.id);
if (!originalField) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Field with id ${field.id} not found`,
});
}
const recipient = envelope.recipients.find(
(recipient) => recipient.id === originalField.recipientId,
);
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient attached to field ${field.id} not found`,
});
}
// Check whether the recipient associated with the field can be modified.
if (!canRecipientFieldsBeModified(recipient, envelope.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Cannot modify a field where the recipient has already interacted with the document',
});
}
return {
updateData: field,
};
});
const updatedFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
fieldsToUpdate.map(async ({ updateData }) => {
const updatedField = await tx.field.update({
where: {
id: updateData.id,
},
data: {
type: updateData.type,
page: updateData.pageNumber,
positionX: updateData.pageX,
positionY: updateData.pageY,
width: updateData.width,
height: updateData.height,
fieldMeta: updateData.fieldMeta,
},
});
return updatedField;
}),
);
});
return {
fields: updatedFields.map((field) => mapFieldToLegacyField(field, envelope)),
};
};

View File

@ -88,11 +88,13 @@ export const addUserToOrganisation = async ({
organisationId,
organisationGroups,
organisationMemberRole,
bypassEmail = false,
}: {
userId: number;
organisationId: string;
organisationGroups: OrganisationGroup[];
organisationMemberRole: OrganisationMemberRole;
bypassEmail?: boolean;
}) => {
const organisationGroupToUse = organisationGroups.find(
(group) =>
@ -122,13 +124,15 @@ export const addUserToOrganisation = async ({
},
});
await jobs.triggerJob({
name: 'send.organisation-member-joined.email',
payload: {
organisationId,
memberUserId: userId,
},
});
if (!bypassEmail) {
await jobs.triggerJob({
name: 'send.organisation-member-joined.email',
payload: {
organisationId,
memberUserId: userId,
},
});
}
},
{ timeout: 30_000 },
);

View File

@ -1,5 +1,7 @@
import Konva from 'konva';
// sort-imports-ignore
import 'konva/skia-backend';
import Konva from 'konva';
import path from 'node:path';
import type { Canvas } from 'skia-canvas';
import { FontLibrary } from 'skia-canvas';
@ -21,21 +23,19 @@ export const insertFieldInPDFV2 = async ({
}: InsertFieldInPDFV2Options) => {
const fontPath = path.join(process.cwd(), 'public/fonts');
FontLibrary.use([
path.join(fontPath, 'caveat.ttf'),
path.join(fontPath, 'noto-sans.ttf'),
path.join(fontPath, 'noto-sans-japanese.ttf'),
path.join(fontPath, 'noto-sans-chinese.ttf'),
path.join(fontPath, 'noto-sans-korean.ttf'),
]);
FontLibrary.use({
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
['Noto Sans']: [path.join(fontPath, 'noto-sans.ttf')],
['Noto Sans Japanese']: [path.join(fontPath, 'noto-sans-japanese.ttf')],
['Noto Sans Chinese']: [path.join(fontPath, 'noto-sans-chinese.ttf')],
['Noto Sans Korean']: [path.join(fontPath, 'noto-sans-korean.ttf')],
});
const stage = new Konva.Stage({ width: pageWidth, height: pageHeight });
const layer = new Konva.Layer();
const insertedFields = fields.filter((field) => field.inserted);
// Render the fields onto the layer.
for (const field of insertedFields) {
for (const field of fields) {
renderField({
scale: 1,
field: {

View File

@ -1,13 +1,22 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { AppError } from '../../errors/app-error';
import { flattenAnnotations } from './flatten-annotations';
import { flattenForm, removeOptionalContentGroups } from './flatten-form';
export const normalizePdf = async (pdf: Buffer) => {
const pdfDoc = await PDFDocument.load(pdf).catch(() => null);
const pdfDoc = await PDFDocument.load(pdf).catch((e) => {
console.error(`PDF normalization error: ${e.message}`);
if (!pdfDoc) {
return pdf;
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is not a valid PDF',
});
});
if (pdfDoc.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is encrypted',
});
}
removeOptionalContentGroups(pdfDoc);

View File

@ -15,7 +15,7 @@ import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface CreateDocumentRecipientsOptions {
export interface CreateEnvelopeRecipientsOptions {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
@ -30,16 +30,16 @@ export interface CreateDocumentRecipientsOptions {
requestMetadata: ApiRequestMetadata;
}
export const createDocumentRecipients = async ({
export const createEnvelopeRecipients = async ({
userId,
teamId,
id,
recipients: recipientsToCreate,
requestMetadata,
}: CreateDocumentRecipientsOptions) => {
}: CreateEnvelopeRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
type: null,
userId,
teamId,
});
@ -62,13 +62,13 @@ export const createDocumentRecipients = async ({
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -112,21 +112,23 @@ export const createDocumentRecipients = async ({
});
// Handle recipient created audit log.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: createdRecipient.email,
recipientName: createdRecipient.name,
recipientId: createdRecipient.id,
recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: createdRecipient.email,
recipientName: createdRecipient.name,
recipientId: createdRecipient.id,
recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
},
}),
});
}
return createdRecipient;
}),

View File

@ -1,115 +0,0 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface CreateTemplateRecipientsOptions {
userId: number;
teamId: number;
templateId: number;
recipients: {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
export const createTemplateRecipients = async ({
userId,
teamId,
templateId,
recipients: recipientsToCreate,
}: CreateTemplateRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const template = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const recipientsHaveActionAuth = recipientsToCreate.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
}));
const createdRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const createdRecipient = await tx.recipient.create({
data: {
envelopeId: template.id,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions,
},
});
return createdRecipient;
}),
);
});
return {
recipients: createdRecipients.map((recipient) =>
mapRecipientToLegacyRecipient(recipient, template),
),
};
};

View File

@ -14,26 +14,27 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { canRecipientBeModified } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteDocumentRecipientOptions {
export interface DeleteEnvelopeRecipientOptions {
userId: number;
teamId: number;
recipientId: number;
requestMetadata: ApiRequestMetadata;
}
export const deleteDocumentRecipient = async ({
export const deleteEnvelopeRecipient = async ({
userId,
teamId,
recipientId,
requestMetadata,
}: DeleteDocumentRecipientOptions) => {
}: DeleteEnvelopeRecipientOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.DOCUMENT,
recipients: {
some: {
id: recipientId,
@ -48,6 +49,9 @@ export const deleteDocumentRecipient = async ({
where: {
id: recipientId,
},
include: {
fields: true,
},
},
},
});
@ -89,24 +93,43 @@ export const deleteDocumentRecipient = async ({
});
}
const deletedRecipient = await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
recipientId: recipientToDelete.id,
recipientRole: recipientToDelete.role,
},
}),
if (!canRecipientBeModified(recipientToDelete, recipientToDelete.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Recipient has already interacted with the document.',
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelope.id,
},
type: null,
userId,
teamId,
});
const deletedRecipient = await prisma.$transaction(async (tx) => {
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
recipientId: recipientToDelete.id,
recipientRole: recipientToDelete.role,
},
}),
});
}
return await tx.recipient.delete({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
});
});
@ -116,7 +139,11 @@ export const deleteDocumentRecipient = async ({
).recipientRemoved;
// Send email to deleted recipient.
if (recipientToDelete.sendStatus === SendStatus.SENT && isRecipientRemovedEmailEnabled) {
if (
recipientToDelete.sendStatus === SendStatus.SENT &&
isRecipientRemovedEmailEnabled &&
envelope.type === EnvelopeType.DOCUMENT
) {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {

View File

@ -1,58 +0,0 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteTemplateRecipientOptions {
userId: number;
teamId: number;
recipientId: number;
}
export const deleteTemplateRecipient = async ({
userId,
teamId,
recipientId,
}: DeleteTemplateRecipientOptions): Promise<void> => {
const recipientToDelete = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelope: {
type: EnvelopeType.TEMPLATE,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
});
if (!recipientToDelete) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: recipientToDelete.envelopeId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
if (!recipientToDelete || recipientToDelete.id !== recipientId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
await prisma.recipient.delete({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
});
};

View File

@ -1,5 +1,4 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
@ -16,29 +15,38 @@ import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { extractLegacyIds } from '../../universal/id';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateDocumentRecipientsOptions {
export interface UpdateEnvelopeRecipientsOptions {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
recipients: RecipientData[];
recipients: {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
requestMetadata: ApiRequestMetadata;
}
export const updateDocumentRecipients = async ({
export const updateEnvelopeRecipients = async ({
userId,
teamId,
id,
recipients,
requestMetadata,
}: UpdateDocumentRecipientsOptions) => {
}: UpdateEnvelopeRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
type: null,
userId,
teamId,
});
@ -62,13 +70,13 @@ export const updateDocumentRecipients = async ({
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -160,24 +168,26 @@ export const updateDocumentRecipients = async ({
});
}
const changes = diffRecipientChanges(originalRecipient, updatedRecipient);
// Handle recipient updated audit log.
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: updatedRecipient.email,
recipientName: updatedRecipient.name,
recipientId: updatedRecipient.id,
recipientRole: updatedRecipient.role,
changes,
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
const changes = diffRecipientChanges(originalRecipient, updatedRecipient);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: updatedRecipient.email,
recipientName: updatedRecipient.name,
recipientId: updatedRecipient.id,
recipientRole: updatedRecipient.role,
changes,
},
}),
});
}
}
return updatedRecipient;
@ -188,19 +198,8 @@ export const updateDocumentRecipients = async ({
return {
recipients: updatedRecipients.map((recipient) => ({
...recipient,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
templateId: null,
...extractLegacyIds(envelope),
fields: recipient.fields.map((field) => mapFieldToLegacyField(field, envelope)),
})),
};
};
type RecipientData = {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};

View File

@ -1,168 +0,0 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateTemplateRecipientsOptions {
userId: number;
teamId: number;
templateId: number;
recipients: {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
export const updateTemplateRecipients = async ({
userId,
teamId,
templateId,
recipients,
}: UpdateTemplateRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth && !envelope.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const recipientsToUpdate = recipients.map((recipient) => {
const originalRecipient = envelope.recipients.find(
(existingRecipient) => existingRecipient.id === recipient.id,
);
if (!originalRecipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Recipient with id ${recipient.id} not found`,
});
}
return {
originalRecipient,
recipientUpdateData: recipient,
};
});
const updatedRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
recipientsToUpdate.map(async ({ originalRecipient, recipientUpdateData }) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions);
if (
recipientUpdateData.actionAuth !== undefined ||
recipientUpdateData.accessAuth !== undefined
) {
authOptions = createRecipientAuthOptions({
accessAuth: recipientUpdateData.accessAuth || authOptions.accessAuth,
actionAuth: recipientUpdateData.actionAuth || authOptions.actionAuth,
});
}
const mergedRecipient = {
...originalRecipient,
...recipientUpdateData,
};
const updatedRecipient = await tx.recipient.update({
where: {
id: originalRecipient.id,
envelopeId: envelope.id,
},
data: {
name: mergedRecipient.name,
email: mergedRecipient.email,
role: mergedRecipient.role,
signingOrder: mergedRecipient.signingOrder,
envelopeId: envelope.id,
sendStatus:
mergedRecipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
mergedRecipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
authOptions,
},
include: {
fields: true,
},
});
// Clear all fields if the recipient role is changed to a type that cannot have fields.
if (
originalRecipient.role !== updatedRecipient.role &&
(updatedRecipient.role === RecipientRole.CC ||
updatedRecipient.role === RecipientRole.VIEWER)
) {
await tx.field.deleteMany({
where: {
recipientId: updatedRecipient.id,
},
});
}
return updatedRecipient;
}),
);
});
return {
recipients: updatedRecipients.map((recipient) => ({
...recipient,
documentId: null,
templateId: mapSecondaryIdToTemplateId(envelope.secondaryId),
fields: recipient.fields.map((field) => mapFieldToLegacyField(field, envelope)),
})),
};
};

View File

@ -3,6 +3,7 @@ import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
DocumentStatus,
EnvelopeType,
@ -26,7 +27,7 @@ import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/f
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { ZFieldMetaSchema } from '../../types/field-meta';
@ -68,6 +69,10 @@ export type CreateDocumentFromDirectTemplateOptions = {
name?: string;
email: string;
};
nextSigner?: {
email: string;
name: string;
};
};
type CreatedDirectRecipientField = {
@ -77,6 +82,7 @@ type CreatedDirectRecipientField = {
export const ZCreateDocumentFromDirectTemplateResponseSchema = z.object({
token: z.string(),
envelopeId: z.string(),
documentId: z.number(),
recipientId: z.number(),
});
@ -92,6 +98,7 @@ export const createDocumentFromDirectTemplate = async ({
directTemplateExternalId,
signedFieldValues,
templateUpdatedAt,
nextSigner,
requestMetadata,
user,
}: CreateDocumentFromDirectTemplateOptions): Promise<TCreateDocumentFromDirectTemplateResponse> => {
@ -128,6 +135,17 @@ export const createDocumentFromDirectTemplate = async ({
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' });
}
if (
nextSigner &&
(!directTemplateEnvelope.documentMeta?.allowDictateNextSigner ||
directTemplateEnvelope.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'You need to enable allowDictateNextSigner and sequential signing to dictate the next signer',
});
}
const directTemplateEnvelopeLegacyId = mapSecondaryIdToTemplateId(
directTemplateEnvelope.secondaryId,
);
@ -197,6 +215,12 @@ export const createDocumentFromDirectTemplate = async ({
const fieldsToProcess = directTemplateRecipient.fields.filter((templateField) => {
const signedFieldValue = signedFieldValues.find((value) => value.fieldId === templateField.id);
// Custom logic for V2 to include all fields, since v1 excludes read only
// and prefilled fields.
if (directTemplateEnvelope.internalVersion === 2) {
return true;
}
// Include if it's required or has a signed value
return isRequiredField(templateField) || signedFieldValue !== undefined;
});
@ -450,19 +474,28 @@ export const createDocumentFromDirectTemplate = async ({
signingOrder: directTemplateRecipient.signingOrder,
fields: {
createMany: {
data: directTemplateNonSignatureFields.map(({ templateField, customText }) => ({
envelopeId: createdEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[templateField.envelopeItemId],
type: templateField.type,
page: templateField.page,
positionX: templateField.positionX,
positionY: templateField.positionY,
width: templateField.width,
height: templateField.height,
customText: customText ?? '',
inserted: true,
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
})),
data: directTemplateNonSignatureFields.map(({ templateField, customText }) => {
let inserted = true;
// Custom logic for V2 to only insert if values exist.
if (directTemplateEnvelope.internalVersion === 2) {
inserted = customText !== '';
}
return {
envelopeId: createdEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[templateField.envelopeItemId],
type: templateField.type,
page: templateField.page,
positionX: templateField.positionX,
positionY: templateField.positionY,
width: templateField.width,
height: templateField.height,
customText: customText ?? '',
inserted,
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
};
}),
},
},
},
@ -630,6 +663,77 @@ export const createDocumentFromDirectTemplate = async ({
}),
];
if (nextSigner) {
const pendingRecipients = await tx.recipient.findMany({
select: {
id: true,
signingOrder: true,
name: true,
email: true,
role: true,
},
where: {
envelopeId: createdEnvelope.id,
signingStatus: {
not: SigningStatus.SIGNED,
},
role: {
not: RecipientRole.CC,
},
},
// Composite sort so our next recipient is always the one with the lowest signing order or id
// if there is a tie.
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
});
const nextRecipient = pendingRecipients[0];
if (nextRecipient) {
auditLogsToCreate.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: createdEnvelope.id,
user: {
name: user?.name || directRecipientName || '',
email: user?.email || directRecipientEmail,
},
metadata: requestMetadata,
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
},
}),
);
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
});
}
}
await tx.documentAuditLog.createMany({
data: auditLogsToCreate,
});
@ -727,6 +831,7 @@ export const createDocumentFromDirectTemplate = async ({
return {
token,
envelopeId: createdEnvelope.id,
documentId: incrementedDocumentId.documentId,
recipientId,
};

View File

@ -87,5 +87,9 @@ export const getTemplateByDirectLinkToken = async ({
},
recipients: recipientsWithMappedFields,
fields: recipientsWithMappedFields.flatMap((recipient) => recipient.fields),
envelopeItems: envelope.envelopeItems.map((item) => ({
id: item.id,
envelopeId: item.envelopeId,
})),
};
};

View File

@ -29,6 +29,7 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
envelopeItems: {
select: {
id: true,
envelopeId: true,
documentData: true,
},
},
@ -94,5 +95,9 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
}
: null,
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
envelopeItems: envelope.envelopeItems.map((envelopeItem) => ({
id: envelopeItem.id,
envelopeId: envelopeItem.envelopeId,
})),
};
};

View File

@ -2,6 +2,7 @@ import { z } from 'zod';
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
@ -74,6 +75,10 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
password: z.string().nullable().default(null),
documentId: z.number().default(-1).optional(),
}),
envelopeItems: EnvelopeItemSchema.pick({
id: true,
envelopeId: true,
}).array(),
folder: FolderSchema.pick({
id: true,

View File

@ -1,14 +1,13 @@
import { z } from 'zod';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import { EnvelopeItemSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
import { EnvelopeSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeSchema';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import TemplateDirectLinkSchema from '@documenso/prisma/generated/zod/modelSchema/TemplateDirectLinkSchema';
import { ZFieldSchema } from './field';
import { ZRecipientLiteSchema } from './recipient';
import { ZEnvelopeFieldSchema } from './field';
import { ZEnvelopeRecipientLiteSchema } from './recipient';
/**
* The full envelope response schema.
@ -37,11 +36,8 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
userId: true,
teamId: true,
folderId: true,
templateId: true,
}).extend({
templateId: z
.number()
.nullish()
.describe('The ID of the template that the document was created from, if any.'),
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
@ -60,28 +56,16 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
emailId: true,
emailReplyTo: true,
}),
recipients: ZRecipientLiteSchema.omit({
documentId: true,
templateId: true,
}).array(),
fields: ZFieldSchema.omit({
documentId: true,
templateId: true,
}).array(),
recipients: ZEnvelopeRecipientLiteSchema.array(),
fields: ZEnvelopeFieldSchema.array(),
envelopeItems: EnvelopeItemSchema.pick({
envelopeId: true,
id: true,
title: true,
documentDataId: true,
order: true,
documentDataId: true,
})
.extend({
documentData: DocumentDataSchema.pick({
type: true,
id: true,
data: true,
initialData: true,
}),
})
.partial({ documentDataId: true })
.array(),
directLink: TemplateDirectLinkSchema.pick({
directTemplateRecipientId: true,

View File

@ -1,9 +1,46 @@
import { msg } from '@lingui/core/macro';
import { FieldType } from '@prisma/client';
import { z } from 'zod';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../constants/pdf';
export const DEFAULT_FIELD_FONT_SIZE = 14;
export const FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN = 'middle';
export const FIELD_DEFAULT_GENERIC_ALIGN = 'left';
export const FIELD_DEFAULT_LINE_HEIGHT = 1;
export const FIELD_DEFAULT_LETTER_SPACING = 0;
export const FIELD_MIN_LINE_HEIGHT = 1;
export const FIELD_MAX_LINE_HEIGHT = 10;
export const FIELD_MIN_LETTER_SPACING = 0;
export const FIELD_MAX_LETTER_SPACING = 100;
export const DEFAULT_FIELD_FONT_SIZE = 12;
/**
* Grouped field types that use the same generic text rendering function.
*/
export type GenericTextFieldTypeMetas =
| TInitialsFieldMeta
| TNameFieldMeta
| TEmailFieldMeta
| TDateFieldMeta
| TTextFieldMeta
| TNumberFieldMeta;
const ZFieldMetaLineHeight = z.coerce
.number()
.min(FIELD_MIN_LINE_HEIGHT)
.max(FIELD_MAX_LINE_HEIGHT)
.describe('The line height of the text');
const ZFieldMetaLetterSpacing = z.coerce
.number()
.min(FIELD_MIN_LETTER_SPACING)
.max(FIELD_MAX_LETTER_SPACING)
.describe('The spacing between each character');
const ZFieldMetaVerticalAlign = z
.enum(['top', 'middle', 'bottom'])
.describe('The vertical alignment of the text');
export const ZBaseFieldMeta = z.object({
label: z.string().optional(),
@ -50,8 +87,14 @@ export type TDateFieldMeta = z.infer<typeof ZDateFieldMeta>;
export const ZTextFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('text'),
text: z.string().optional(),
characterLimit: z.number().optional(),
characterLimit: z.coerce
.number({ invalid_type_error: msg`Value must be a number`.id })
.min(0)
.optional(),
textAlign: ZFieldTextAlignSchema.optional(),
lineHeight: ZFieldMetaLineHeight.nullish(),
letterSpacing: ZFieldMetaLetterSpacing.nullish(),
verticalAlign: ZFieldMetaVerticalAlign.nullish(),
});
export type TTextFieldMeta = z.infer<typeof ZTextFieldMeta>;
@ -63,6 +106,9 @@ export const ZNumberFieldMeta = ZBaseFieldMeta.extend({
minValue: z.coerce.number().nullish(),
maxValue: z.coerce.number().nullish(),
textAlign: ZFieldTextAlignSchema.optional(),
lineHeight: ZFieldMetaLineHeight.nullish(),
letterSpacing: ZFieldMetaLetterSpacing.nullish(),
verticalAlign: ZFieldMetaVerticalAlign.nullish(),
});
export type TNumberFieldMeta = z.infer<typeof ZNumberFieldMeta>;
@ -188,7 +234,7 @@ export type TFieldMetaSchema = z.infer<typeof ZFieldMetaSchema>;
export const ZFieldAndMetaSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal(FieldType.SIGNATURE),
fieldMeta: z.undefined(),
fieldMeta: ZSignatureFieldMeta.optional(),
}),
z.object({
type: z.literal(FieldType.FREE_SIGNATURE),

View File

@ -50,6 +50,11 @@ export const ZFieldSchema = FieldSchema.pick({
templateId: z.number().nullish(),
});
export const ZEnvelopeFieldSchema = ZFieldSchema.omit({
documentId: true,
templateId: true,
});
export const ZFieldPageNumberSchema = z
.number()
.min(1)
@ -69,6 +74,30 @@ export const ZFieldWidthSchema = z.number().min(1).describe('The width of the fi
export const ZFieldHeightSchema = z.number().min(1).describe('The height of the field.');
export const ZClampedFieldPositionXSchema = z
.number()
.min(0)
.max(100)
.describe('The percentage based X coordinate where the field will be placed.');
export const ZClampedFieldPositionYSchema = z
.number()
.min(0)
.max(100)
.describe('The percentage based Y coordinate where the field will be placed.');
export const ZClampedFieldWidthSchema = z
.number()
.min(0)
.max(100)
.describe('The percentage based width of the field on the page.');
export const ZClampedFieldHeightSchema = z
.number()
.min(0)
.max(100)
.describe('The percentage based height of the field on the page.');
// ---------------------------------------------
const PrismaDecimalSchema = z.preprocess(

View File

@ -95,3 +95,18 @@ export const ZRecipientManySchema = RecipientSchema.pick({
documentId: z.number().nullish(),
templateId: z.number().nullish(),
});
export const ZEnvelopeRecipientSchema = ZRecipientSchema.omit({
documentId: true,
templateId: true,
});
export const ZEnvelopeRecipientLiteSchema = ZRecipientLiteSchema.omit({
documentId: true,
templateId: true,
});
export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
documentId: true,
templateId: true,
});

View File

@ -1,5 +1,7 @@
import { z } from 'zod';
export type DateRange = 'last30days' | 'last90days' | 'lastYear' | 'allTime';
/**
* Backend only schema is used for find search params.
*

View File

@ -2,6 +2,7 @@ import { z } from 'zod';
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
@ -87,6 +88,10 @@ export const ZTemplateSchema = TemplateSchema.pick({
createdAt: true,
updatedAt: true,
}).nullable(),
envelopeItems: EnvelopeItemSchema.pick({
id: true,
envelopeId: true,
}).array(),
});
export type TTemplate = z.infer<typeof ZTemplateSchema>;

View File

@ -30,3 +30,5 @@ export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => {
return chacha.decrypt(dataAsBytes);
};
export { sha256 };

View File

@ -9,7 +9,7 @@ import type { FieldToRender, RenderFieldElementOptions } from './field-renderer'
import { calculateFieldPosition } from './field-renderer';
export const konvaTextFontFamily =
'Noto Sans, Noto Sans Japanese, Noto Sans Chinese, Noto Sans Korean, sans-serif';
'"Noto Sans", "Noto Sans Japanese", "Noto Sans Chinese", "Noto Sans Korean", sans-serif';
export const konvaTextFill = 'black';
export const upsertFieldGroup = (
@ -153,6 +153,11 @@ export const createFieldHoverInteraction = ({
const hoverColor = RECIPIENT_COLOR_STYLES[options.color].baseRingHover;
fieldGroup.on('mouseover', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
@ -161,6 +166,11 @@ export const createFieldHoverInteraction = ({
});
fieldGroup.on('mouseout', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
@ -169,6 +179,11 @@ export const createFieldHoverInteraction = ({
});
fieldGroup.on('transformstart', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
@ -177,6 +192,11 @@ export const createFieldHoverInteraction = ({
});
fieldGroup.on('transformend', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,

View File

@ -19,7 +19,7 @@ export type FieldToRender = Pick<
positionX: number;
positionY: number;
fieldMeta?: TFieldMetaSchema | null;
signature?: Signature | null;
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'> | null;
};
export type RenderFieldElementOptions = {

View File

@ -3,6 +3,7 @@ import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TCheckboxFieldMeta } from '../../types/field-meta';
import { parseCheckboxCustomText } from '../../utils/fields';
import {
createFieldHoverInteraction,
konvaTextFill,
@ -25,7 +26,7 @@ export const renderCheckboxFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { pageWidth, pageHeight, pageLayer, mode } = options;
const { pageWidth, pageHeight, pageLayer, mode, color } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
@ -62,16 +63,15 @@ export const renderCheckboxFieldElement = (
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Todo: Envelopes - check sorting more than 10
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const squares = fieldGroup
.find('.checkbox-square')
.sort((a, b) => a.id().localeCompare(b.id()));
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true }));
const checkmarks = fieldGroup
.find('.checkbox-checkmark')
.sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true }));
const text = fieldGroup
.find('.checkbox-text')
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true }));
const groupedItems = squares.map((square, i) => ({
squareElement: square,
@ -130,7 +130,7 @@ export const renderCheckboxFieldElement = (
pageLayer.batchDraw();
});
const checkedValues: number[] = field.customText ? JSON.parse(field.customText) : [];
const checkedValues: number[] = field.customText ? parseCheckboxCustomText(field.customText) : [];
checkboxValues.forEach(({ value, checked }, index) => {
const isCheckboxChecked = match(mode)
@ -170,7 +170,7 @@ export const renderCheckboxFieldElement = (
width: itemSize,
height: itemSize,
stroke: '#374151',
strokeWidth: 2,
strokeWidth: 1.5,
cornerRadius: 2,
fill: 'white',
});
@ -210,7 +210,9 @@ export const renderCheckboxFieldElement = (
fieldGroup.add(text);
});
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
if (color !== 'readOnly' && mode !== 'export') {
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
}
return {
fieldGroup,

View File

@ -50,7 +50,7 @@ export const renderDropdownFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { pageWidth, pageHeight, pageLayer, mode, translations } = options;
const { pageWidth, pageHeight, pageLayer, mode, translations, color } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
@ -74,6 +74,21 @@ export const renderDropdownFieldElement = (
const fontSize = dropdownMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
// Don't show any labels when exporting.
if (mode === 'export') {
selectedValue = '';
}
// Render the default value if readonly.
if (
dropdownMeta?.readOnly &&
dropdownMeta.defaultValue &&
dropdownMeta.values &&
dropdownMeta.values.some((value) => value.value === dropdownMeta.defaultValue)
) {
selectedValue = dropdownMeta.defaultValue;
}
if (field.inserted) {
selectedValue = field.customText;
}
@ -166,7 +181,9 @@ export const renderDropdownFieldElement = (
pageLayer.batchDraw();
});
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
if (color !== 'readOnly' && mode !== 'export') {
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
}
return {
fieldGroup,

View File

@ -8,13 +8,24 @@ import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import type { TFieldMetaSchema } from '../../types/field-meta';
import { renderCheckboxFieldElement } from './render-checkbox-field';
import { renderDropdownFieldElement } from './render-dropdown-field';
import { renderGenericTextFieldElement } from './render-generic-text-field';
import { renderRadioFieldElement } from './render-radio-field';
import { renderSignatureFieldElement } from './render-signature-field';
import { renderTextFieldElement } from './render-text-field';
export const MIN_FIELD_HEIGHT_PX = 12;
export const MIN_FIELD_WIDTH_PX = 36;
/**
* The render type.
*
* @default 'edit'
*
* - `edit` - The field is rendered in editor page.
* - `sign` - The field is rendered for the signing page.
* - `export` - The field is rendered for exporting and sealing into the PDF. No backgrounds, interactive elements, etc.
*/
export type FieldRenderMode = 'edit' | 'sign' | 'export';
export type FieldToRender = Pick<
Field,
'envelopeItemId' | 'recipientId' | 'type' | 'page' | 'customText' | 'inserted' | 'recipientId'
@ -25,7 +36,7 @@ export type FieldToRender = Pick<
positionX: number;
positionY: number;
fieldMeta?: TFieldMetaSchema | null;
signature?: Signature | null;
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'> | null;
};
type RenderFieldOptions = {
@ -38,16 +49,7 @@ type RenderFieldOptions = {
translations: Record<FieldType, string> | null;
/**
* The render type.
*
* @default 'edit'
*
* - `edit` - The field is rendered in edit mode.
* - `sign` - The field is rendered in sign mode. No interactive elements.
* - `export` - The field is rendered in export mode. No backgrounds, interactive elements, etc.
*/
mode: 'edit' | 'sign' | 'export';
mode: FieldRenderMode;
scale: number;
editable?: boolean;
@ -75,11 +77,23 @@ export const renderField = ({
scale,
};
// If the generic text field element array changes, update the `GenericTextFieldTypeMetas` type
return match(field.type)
.with(FieldType.TEXT, () => renderTextFieldElement(field, options))
.with(
FieldType.INITIALS,
FieldType.NAME,
FieldType.EMAIL,
FieldType.DATE,
FieldType.TEXT,
FieldType.NUMBER,
() => renderGenericTextFieldElement(field, options),
)
.with(FieldType.CHECKBOX, () => renderCheckboxFieldElement(field, options))
.with(FieldType.RADIO, () => renderRadioFieldElement(field, options))
.with(FieldType.DROPDOWN, () => renderDropdownFieldElement(field, options))
.with(FieldType.SIGNATURE, () => renderSignatureFieldElement(field, options))
.otherwise(() => renderTextFieldElement(field, options)); // Todo: Envelopes
.with(FieldType.FREE_SIGNATURE, () => {
throw new Error('Free signature fields are not supported');
})
.exhaustive();
};

View File

@ -0,0 +1,199 @@
import Konva from 'konva';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { GenericTextFieldTypeMetas } from '../../types/field-meta';
import {
FIELD_DEFAULT_GENERIC_ALIGN,
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
FIELD_DEFAULT_LETTER_SPACING,
FIELD_DEFAULT_LINE_HEIGHT,
} from '../../types/field-meta';
import {
createFieldHoverInteraction,
konvaTextFill,
konvaTextFontFamily,
upsertFieldGroup,
upsertFieldRect,
} from './field-generic-items';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
import { calculateFieldPosition } from './field-renderer';
const DEFAULT_TEXT_X_PADDING = 6;
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const fieldMeta = field.fieldMeta as GenericTextFieldTypeMetas | undefined;
const fieldTypeName = translations?.[field.type] || field.type;
const fieldText: Konva.Text =
pageLayer.findOne(`#${field.renderId}-text`) ||
new Konva.Text({
id: `${field.renderId}-text`,
name: 'field-text',
});
// Calculate text positioning based on alignment
const textX = 0;
const textY = 0;
const textFontSize = fieldMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
// By default, render the field name or label centered
let textToRender: string = fieldMeta?.label || fieldTypeName;
let textAlign: 'left' | 'center' | 'right' = 'center';
let textVerticalAlign: 'top' | 'middle' | 'bottom' = FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN;
let textLineHeight = FIELD_DEFAULT_LINE_HEIGHT;
let textLetterSpacing = FIELD_DEFAULT_LETTER_SPACING;
// Render default values for text/number if provided for editing mode.
if (mode === 'edit' && (fieldMeta?.type === 'text' || fieldMeta?.type === 'number')) {
const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value;
if (value) {
textToRender = value;
textVerticalAlign = fieldMeta.verticalAlign || FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN;
textAlign = fieldMeta.textAlign || FIELD_DEFAULT_GENERIC_ALIGN;
textLetterSpacing = fieldMeta.letterSpacing || FIELD_DEFAULT_LETTER_SPACING;
textLineHeight = fieldMeta.lineHeight || FIELD_DEFAULT_LINE_HEIGHT;
}
}
// Default to blank for export mode since we want to ensure we don't show
// any placeholder text or labels unless actually it's inserted.
if (mode === 'export') {
textToRender = '';
}
// Fallback render readonly fields if prefilled value exists.
if (field?.fieldMeta?.readOnly && (fieldMeta?.type === 'text' || fieldMeta?.type === 'number')) {
const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value;
if (value) {
textToRender = value;
textVerticalAlign = fieldMeta.verticalAlign || FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN;
textAlign = fieldMeta.textAlign || FIELD_DEFAULT_GENERIC_ALIGN;
textLetterSpacing = fieldMeta.letterSpacing || FIELD_DEFAULT_LETTER_SPACING;
textLineHeight = fieldMeta.lineHeight || FIELD_DEFAULT_LINE_HEIGHT;
}
}
// Override everything with value if it's inserted.
if (field.inserted) {
textToRender = field.customText;
textAlign = fieldMeta?.textAlign || FIELD_DEFAULT_GENERIC_ALIGN;
if (fieldMeta?.type === 'text' || fieldMeta?.type === 'number') {
textVerticalAlign = fieldMeta.verticalAlign || FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN;
textLetterSpacing = fieldMeta.letterSpacing || FIELD_DEFAULT_LETTER_SPACING;
textLineHeight = fieldMeta.lineHeight || FIELD_DEFAULT_LINE_HEIGHT;
}
}
// Note: Do not use native text padding since it's uniform.
// We only want to have padding on the left and right hand sides.
fieldText.setAttrs({
x: textX + DEFAULT_TEXT_X_PADDING,
y: textY,
verticalAlign: textVerticalAlign,
wrap: 'word',
text: textToRender,
fontSize: textFontSize,
align: textAlign,
lineHeight: textLineHeight,
letterSpacing: textLetterSpacing,
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
width: fieldWidth - DEFAULT_TEXT_X_PADDING * 2,
height: fieldHeight,
} satisfies Partial<Konva.TextConfig>);
return fieldText;
};
export const renderGenericTextFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { mode = 'edit', pageLayer, color } = options;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
// Clear previous children and listeners to re-render fresh.
const fieldGroup = upsertFieldGroup(field, options);
fieldGroup.removeChildren();
fieldGroup.off('transform');
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Update text dimensions
fieldText.width(rectWidth - DEFAULT_TEXT_X_PADDING * 2);
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
fieldText.height();
fieldGroup.getLayer()?.batchDraw();
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Update text dimensions
fieldText.width(rectWidth - DEFAULT_TEXT_X_PADDING * 2);
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
fieldText.height();
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode.
if (mode === 'export') {
// Hide the rectangle.
fieldRect.opacity(0);
}
if (color !== 'readOnly' && mode !== 'export') {
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
}
return {
fieldGroup,
isFirstRender,
};
};

View File

@ -25,7 +25,7 @@ export const renderRadioFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { pageWidth, pageHeight, pageLayer, mode } = options;
const { pageWidth, pageHeight, pageLayer, mode, color } = options;
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
const radioValues = radioMeta?.values || [];
@ -159,7 +159,7 @@ export const renderRadioFieldElement = (
y: itemInputY,
radius: calculateRadioSize(fontSize) / 2,
stroke: '#374151',
strokeWidth: 2,
strokeWidth: 1.5,
fill: 'white',
});
@ -195,7 +195,9 @@ export const renderRadioFieldElement = (
fieldGroup.add(text);
});
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
if (color !== 'readOnly' && mode !== 'export') {
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
}
return {
fieldGroup,

View File

@ -142,7 +142,7 @@ export const renderSignatureFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { mode = 'edit', pageLayer } = options;
const { mode = 'edit', pageLayer, color } = options;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
@ -211,7 +211,9 @@ export const renderSignatureFieldElement = (
fieldRect.opacity(0);
}
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
if (color !== 'readOnly' && mode !== 'export') {
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
}
return {
fieldGroup,

View File

@ -1,187 +0,0 @@
import Konva from 'konva';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TTextFieldMeta } from '../../types/field-meta';
import {
createFieldHoverInteraction,
konvaTextFill,
konvaTextFontFamily,
upsertFieldGroup,
upsertFieldRect,
} from './field-generic-items';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
import { calculateFieldPosition } from './field-renderer';
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const textMeta = field.fieldMeta as TTextFieldMeta | undefined;
const fieldTypeName = translations?.[field.type] || field.type;
const fieldText: Konva.Text =
pageLayer.findOne(`#${field.renderId}-text`) ||
new Konva.Text({
id: `${field.renderId}-text`,
name: 'field-text',
});
// Calculate text positioning based on alignment
const textX = 0;
const textY = 0;
let textAlign: 'left' | 'center' | 'right' = textMeta?.textAlign || 'left';
let textVerticalAlign: 'top' | 'middle' | 'bottom' = 'top';
const textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
const textPadding = 10;
let textToRender: string = fieldTypeName;
// Handle edit mode.
if (mode === 'edit') {
textToRender = fieldTypeName;
textAlign = 'center';
textVerticalAlign = 'middle';
if (textMeta?.label) {
textToRender = textMeta.label;
} else if (textMeta?.text) {
textToRender = textMeta.text;
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
}
}
}
// Handle sign mode.
if (mode === 'sign' || mode === 'export') {
textToRender = fieldTypeName;
textAlign = 'center';
textVerticalAlign = 'middle';
if (textMeta?.label) {
textToRender = textMeta.label;
}
if (textMeta?.text) {
textToRender = textMeta.text;
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
}
}
if (field.inserted) {
textToRender = field.customText;
textAlign = textMeta?.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta?.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
}
}
}
fieldText.setAttrs({
x: textX,
y: textY,
verticalAlign: textVerticalAlign,
wrap: 'word',
padding: textPadding,
text: textToRender,
fontSize: textFontSize,
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
align: textAlign,
width: fieldWidth,
height: fieldHeight,
} satisfies Partial<Konva.TextConfig>);
return fieldText;
};
export const renderTextFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { mode = 'edit', pageLayer } = options;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
// Clear previous children and listeners to re-render fresh.
const fieldGroup = upsertFieldGroup(field, options);
fieldGroup.removeChildren();
fieldGroup.off('transform');
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Update text dimensions
fieldText.width(rectWidth);
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
fieldText.height();
fieldGroup.getLayer()?.batchDraw();
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
fieldText.height();
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode.
if (mode === 'export') {
// Hide the rectangle.
fieldRect.opacity(0);
}
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
return {
fieldGroup,
isFirstRender,
};
};

View File

@ -7,7 +7,13 @@ export type GetFileOptions = {
data: string;
};
export const getFile = async ({ type, data }: GetFileOptions) => {
/**
* KEPT FOR POSTERITY, SHOULD BE REMOVED IN THE FUTURE
* DO NOT USE OR I WILL FIRE YOU
*
* - Lucas, 2025-11-04
*/
const getFile = async ({ type, data }: GetFileOptions) => {
return await match(type)
.with(DocumentDataType.BYTES, () => getFileFromBytes(data))
.with(DocumentDataType.BYTES_64, () => getFileFromBytes64(data))

View File

@ -7,6 +7,7 @@ import { env } from '@documenso/lib/utils/env';
import { AppError } from '../../errors/app-error';
import { createDocumentData } from '../../server-only/document-data/create-document-data';
import { normalizePdf } from '../../server-only/pdf/normalize-pdf';
import { uploadS3File } from './server-actions';
type File = {
@ -43,6 +44,28 @@ export const putPdfFileServerSide = async (file: File) => {
return await createDocumentData({ type, data });
};
/**
* Uploads a pdf file and normalizes it.
*/
export const putNormalizedPdfFileServerSide = async (file: File) => {
const buffer = Buffer.from(await file.arrayBuffer());
const normalized = await normalizePdf(buffer);
const fileName = file.name.endsWith('.pdf') ? file.name : `${file.name}.pdf`;
const documentData = await putFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalized),
});
return await createDocumentData({
type: documentData.type,
data: documentData.data,
});
};
/**
* Uploads a file to the appropriate storage location.
*/

View File

@ -6,7 +6,7 @@ import { env } from '@documenso/lib/utils/env';
import type {
TGetPresignedPostUrlResponse,
TUploadPdfResponse,
} from '@documenso/remix/server/api/files.types';
} from '@documenso/remix/server/api/files/files.types';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError } from '../../errors/app-error';

View File

@ -0,0 +1,34 @@
import type { EnvelopeItem } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
export type EnvelopeItemPdfUrlOptions =
| {
type: 'download';
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined;
version: 'original' | 'signed';
}
| {
type: 'view';
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined;
};
export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => {
const { envelopeItem, token, type } = options;
const { id, envelopeId } = envelopeItem;
if (type === 'download') {
const version = options.version;
return token
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}/download/${version}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}/download/${version}`;
}
return token
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}`;
};

View File

@ -102,9 +102,8 @@ export const extractFieldInsertionValues = ({
}
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(fieldValue.value.toString(), numberFieldParsedMeta, true);
const errors = validateNumberField(fieldValue.value, numberFieldParsedMeta, true);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid number',
@ -112,7 +111,7 @@ export const extractFieldInsertionValues = ({
}
return {
customText: fieldValue.value.toString(),
customText: fieldValue.value,
inserted: true,
};
})
@ -127,7 +126,6 @@ export const extractFieldInsertionValues = ({
const parsedTextFieldMeta = ZTextFieldMeta.parse(field.fieldMeta);
const errors = validateTextField(fieldValue.value, parsedTextFieldMeta, true);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid email',
@ -189,7 +187,6 @@ export const extractFieldInsertionValues = ({
(sign) => sign.label === validationRule,
);
// Todo: Envelopes - Test this.
if (checkboxValidationRule) {
const isValid = validateCheckboxLength(
selectedValues.length,
@ -224,7 +221,6 @@ export const extractFieldInsertionValues = ({
const parsedDropdownFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(fieldValue.value, parsedDropdownFieldMeta, true);
// Todo: Envelopes
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid dropdown value',

View File

@ -1,5 +1,11 @@
import type { Envelope, Recipient } from '@prisma/client';
import { DocumentStatus, EnvelopeType, SendStatus, SigningStatus } from '@prisma/client';
import {
DocumentStatus,
EnvelopeType,
RecipientRole,
SendStatus,
SigningStatus,
} from '@prisma/client';
import { match } from 'ts-pattern';
import { z } from 'zod';
@ -156,8 +162,9 @@ export const canEnvelopeItemsBeModified = (
if (
recipients.some(
(recipient) =>
recipient.signingStatus === SigningStatus.SIGNED ||
recipient.sendStatus === SendStatus.SENT,
recipient.role !== RecipientRole.CC &&
(recipient.signingStatus === SigningStatus.SIGNED ||
recipient.sendStatus === SendStatus.SENT),
)
) {
return false;

View File

@ -81,6 +81,10 @@ export const mapFieldToLegacyField = (
};
export const parseCheckboxCustomText = (customText: string): number[] => {
if (!customText) {
return [];
}
return JSON.parse(customText);
};

View File

@ -0,0 +1,37 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
export const isValidReturnTo = (returnTo?: string) => {
if (!returnTo) {
return false;
}
try {
// Decode if it's URL encoded
const decodedReturnTo = decodeURIComponent(returnTo);
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
if (returnToUrl.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
return false;
}
return true;
} catch {
return false;
}
};
export const normalizeReturnTo = (returnTo?: string) => {
if (!returnTo) {
return undefined;
}
try {
// Decode if it's URL encoded
const decodedReturnTo = decodeURIComponent(returnTo);
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
return `${returnToUrl.pathname}${returnToUrl.search}${returnToUrl.hash}`;
} catch {
return undefined;
}
};