feat: embed authoring part two (#1768)

This commit is contained in:
Lucas Smith
2025-05-01 23:32:56 +10:00
committed by GitHub
parent bdb0b0ea88
commit 12ada567f5
28 changed files with 1267 additions and 269 deletions

View File

@ -57,7 +57,7 @@ export const ConfigureDocumentRecipients = ({
name: 'signers',
});
const { getValues, watch } = useFormContext<TConfigureEmbedFormSchema>();
const { getValues, watch, setValue } = useFormContext<TConfigureEmbedFormSchema>();
const signingOrder = watch('meta.signingOrder');
@ -67,13 +67,16 @@ export const ConfigureDocumentRecipients = ({
const onAddSigner = useCallback(() => {
const signerNumber = signers.length + 1;
const recipientSigningOrder =
signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1;
appendSigner({
formId: nanoid(8),
name: isTemplate ? `Recipient ${signerNumber}` : '',
email: isTemplate ? `recipient.${signerNumber}@document.com` : '',
role: RecipientRole.SIGNER,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1,
signingOrder:
signingOrder === DocumentSigningOrder.SEQUENTIAL ? recipientSigningOrder : undefined,
});
}, [appendSigner, signers]);
@ -103,7 +106,7 @@ export const ConfigureDocumentRecipients = ({
// Update signing order for each item
const updatedSigners = remainingSigners.map((s: SignerItem, idx: number) => ({
...s,
signingOrder: idx + 1,
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? idx + 1 : undefined,
}));
// Update the form
@ -123,7 +126,7 @@ export const ConfigureDocumentRecipients = ({
const currentSigners = getValues('signers');
const updatedSigners = currentSigners.map((signer: SignerItem, index: number) => ({
...signer,
signingOrder: index + 1,
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? index + 1 : undefined,
}));
// Update the form with new ordering
@ -132,6 +135,16 @@ export const ConfigureDocumentRecipients = ({
[move, replace, getValues],
);
const onSigningOrderChange = (signingOrder: DocumentSigningOrder) => {
setValue('meta.signingOrder', signingOrder);
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
signers.forEach((_signer, index) => {
setValue(`signers.${index}.signingOrder`, index + 1);
});
}
};
return (
<div>
<h3 className="text-foreground mb-1 text-lg font-medium">
@ -152,11 +165,11 @@ export const ConfigureDocumentRecipients = ({
{...field}
id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) => {
field.onChange(
onCheckedChange={(checked) =>
onSigningOrderChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
);
}}
)
}
disabled={isSubmitting}
/>
</FormControl>
@ -184,6 +197,7 @@ export const ConfigureDocumentRecipients = ({
disabled={isSubmitting || !isSigningOrderEnabled}
/>
</FormControl>
<div className="flex items-center">
<FormLabel
htmlFor="allowDictateNextSigner"
@ -227,13 +241,14 @@ export const ConfigureDocumentRecipients = ({
key={signer.id}
draggableId={signer.id}
index={index}
isDragDisabled={!isSigningOrderEnabled || isSubmitting}
isDragDisabled={!isSigningOrderEnabled || isSubmitting || signer.disabled}
>
{(provided, snapshot) => (
<div
<fieldset
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
disabled={signer.disabled}
className={cn('py-1', {
'bg-widget-foreground pointer-events-none rounded-md pt-2':
snapshot.isDragging,
@ -349,7 +364,7 @@ export const ConfigureDocumentRecipients = ({
{...field}
isAssistantEnabled={isSigningOrderEnabled}
onValueChange={field.onChange}
disabled={isSubmitting || snapshot.isDragging}
disabled={isSubmitting || snapshot.isDragging || signer.disabled}
/>
</FormControl>
<FormMessage />
@ -360,13 +375,18 @@ export const ConfigureDocumentRecipients = ({
<Button
type="button"
variant="ghost"
disabled={isSubmitting || signers.length === 1 || snapshot.isDragging}
disabled={
isSubmitting ||
signers.length === 1 ||
snapshot.isDragging ||
signer.disabled
}
onClick={() => removeSigner(index)}
>
<Trash className="h-4 w-4" />
</Button>
</motion.div>
</div>
</fieldset>
)}
</Draggable>
))}

View File

@ -30,10 +30,15 @@ import {
export interface ConfigureDocumentViewProps {
onSubmit: (data: TConfigureEmbedFormSchema) => void | Promise<void>;
defaultValues?: Partial<TConfigureEmbedFormSchema>;
disableUpload?: boolean;
isSubmitting?: boolean;
}
export const ConfigureDocumentView = ({ onSubmit, defaultValues }: ConfigureDocumentViewProps) => {
export const ConfigureDocumentView = ({
onSubmit,
defaultValues,
disableUpload,
}: ConfigureDocumentViewProps) => {
const { isTemplate } = useConfigureDocument();
const form = useForm<TConfigureEmbedFormSchema>({
@ -47,6 +52,7 @@ export const ConfigureDocumentView = ({ onSubmit, defaultValues }: ConfigureDocu
email: isTemplate ? `recipient.${1}@document.com` : '',
role: RecipientRole.SIGNER,
signingOrder: 1,
disabled: false,
},
],
meta: {
@ -110,7 +116,7 @@ export const ConfigureDocumentView = ({ onSubmit, defaultValues }: ConfigureDocu
/>
</div>
<ConfigureDocumentUpload isSubmitting={isSubmitting} />
{!disableUpload && <ConfigureDocumentUpload isSubmitting={isSubmitting} />}
<ConfigureDocumentRecipients control={control} isSubmitting={isSubmitting} />
<ConfigureDocumentAdvancedSettings control={control} isSubmitting={isSubmitting} />

View File

@ -15,11 +15,13 @@ export const ZConfigureEmbedFormSchema = z.object({
signers: z
.array(
z.object({
nativeId: z.number().optional(),
formId: z.string(),
name: z.string().min(1, { message: 'Name is required' }),
email: z.string().email('Invalid email address'),
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(),
disabled: z.boolean().optional(),
}),
)
.min(1, { message: 'At least one signer is required' }),
@ -34,7 +36,7 @@ export const ZConfigureEmbedFormSchema = z.object({
language: ZDocumentMetaLanguageSchema.optional(),
signatureTypes: z.array(z.string()).default([]),
signingOrder: z.enum(['SEQUENTIAL', 'PARALLEL']),
allowDictateNextSigner: z.boolean().default(false),
allowDictateNextSigner: z.boolean().default(false).optional(),
externalId: z.string().optional(),
}),
documentData: z

View File

@ -2,12 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentData } from '@prisma/client';
import { FieldType, ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import type { DocumentData, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { ChevronsUpDown } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { z } from 'zod';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
@ -30,6 +29,7 @@ import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/shee
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
import type { TConfigureFieldsFormSchema } from './configure-fields-view.types';
import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer';
const MIN_HEIGHT_PX = 12;
@ -38,28 +38,9 @@ const MIN_WIDTH_PX = 36;
const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5;
const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export const ZConfigureFieldsFormSchema = z.object({
fields: z.array(
z.object({
formId: z.string().min(1),
id: z.string().min(1),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
recipientId: z.number().min(0),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
fieldMeta: ZFieldMetaSchema.optional(),
}),
),
});
export type TConfigureFieldsFormSchema = z.infer<typeof ZConfigureFieldsFormSchema>;
export type ConfigureFieldsViewProps = {
configData: TConfigureEmbedFormSchema;
documentData?: DocumentData;
defaultValues?: Partial<TConfigureFieldsFormSchema>;
onBack: (data: TConfigureFieldsFormSchema) => void;
onSubmit: (data: TConfigureFieldsFormSchema) => void;
@ -67,13 +48,14 @@ export type ConfigureFieldsViewProps = {
export const ConfigureFieldsView = ({
configData,
documentData,
defaultValues,
onBack,
onSubmit,
}: ConfigureFieldsViewProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
const { _ } = useLingui();
// Track if we're on a mobile device
const [isMobile, setIsMobile] = useState(false);
@ -99,7 +81,11 @@ export const ConfigureFieldsView = ({
};
}, []);
const documentData = useMemo(() => {
const normalizedDocumentData = useMemo(() => {
if (documentData) {
return documentData;
}
if (!configData.documentData) {
return null;
}
@ -116,7 +102,7 @@ export const ConfigureFieldsView = ({
const recipients = useMemo(() => {
return configData.signers.map<Recipient>((signer, index) => ({
id: index,
id: signer.nativeId || index,
name: signer.name || '',
email: signer.email || '',
role: signer.role,
@ -129,14 +115,14 @@ export const ConfigureFieldsView = ({
signedAt: null,
authOptions: null,
rejectionReason: null,
sendStatus: SendStatus.NOT_SENT,
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: signer.disabled ? SendStatus.SENT : SendStatus.NOT_SENT,
readStatus: signer.disabled ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
signingStatus: signer.disabled ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
}));
}, [configData.signers]);
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>(
() => recipients[0] || null,
() => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null,
);
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
@ -206,8 +192,8 @@ export const ConfigureFieldsView = ({
const newField: TConfigureFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
id: nanoid(12),
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
pageX: lastActiveField.pageX + 3,
@ -229,8 +215,8 @@ export const ConfigureFieldsView = ({
append({
...copiedField,
nativeId: undefined,
formId: nanoid(12),
id: nanoid(12),
signerEmail: selectedRecipient?.email ?? copiedField.signerEmail,
recipientId: selectedRecipient?.id ?? copiedField.recipientId,
pageX: copiedField.pageX + 3,
@ -303,7 +289,6 @@ export const ConfigureFieldsView = ({
pageY -= fieldPageHeight / 2;
const field = {
id: nanoid(12),
formId: nanoid(12),
type: selectedField,
pageNumber,
@ -526,9 +511,9 @@ export const ConfigureFieldsView = ({
)}
<Form {...form}>
{documentData && (
{normalizedDocumentData && (
<div>
<PDFViewer documentData={documentData} />
<PDFViewer documentData={normalizedDocumentData} />
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field, index) => {

View File

@ -0,0 +1,29 @@
import { z } from 'zod';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
export const ZConfigureFieldsFormSchema = z.object({
fields: z.array(
z.object({
nativeId: z.number().optional(),
formId: z.string().min(1),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
inserted: z.boolean().optional(),
recipientId: z.number().min(0),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
fieldMeta: ZFieldMetaSchema.optional(),
}),
),
});
export type TConfigureFieldsFormSchema = z.infer<typeof ZConfigureFieldsFormSchema>;
export type TConfigureFieldsFormSchemaField = z.infer<
typeof ZConfigureFieldsFormSchema
>['fields'][number];

View File

@ -1,6 +1,5 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { FieldType } from '@prisma/client';
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
@ -8,35 +7,13 @@ import { FieldAdvancedSettings } from '@documenso/ui/primitives/document-flow/fi
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
import type { TConfigureFieldsFormSchemaField } from './configure-fields-view.types';
export type FieldAdvancedSettingsDrawerProps = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
currentField: {
id: string;
formId: string;
type: FieldType;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
recipientId: number;
signerEmail: string;
fieldMeta?: FieldMeta;
} | null;
fields: Array<{
id: string;
formId: string;
type: FieldType;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
recipientId: number;
signerEmail: string;
fieldMeta?: FieldMeta;
}>;
currentField: TConfigureFieldsFormSchemaField | null;
fields: TConfigureFieldsFormSchemaField[];
onFieldUpdate: (formId: string, fieldMeta: FieldMeta) => void;
};

View File

@ -0,0 +1,102 @@
import { useLayoutEffect } from 'react';
import { Outlet, useLoaderData } from 'react-router';
import { isCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { TrpcProvider } from '@documenso/trpc/react';
import { ZBaseEmbedAuthoringSchema } from '~/types/embed-authoring-base-schema';
import { injectCss } from '~/utils/css-vars';
import type { Route } from './+types/_layout';
export const loader = async ({ request }: Route.LoaderArgs) => {
const url = new URL(request.url);
const token = url.searchParams.get('token');
if (!token) {
return {
hasValidToken: false,
token,
};
}
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
let hasPlatformPlan = false;
let hasEnterprisePlan = false;
let hasCommunityPlan = false;
if (result) {
[hasCommunityPlan, hasPlatformPlan, hasEnterprisePlan] = await Promise.all([
isCommunityPlan({
userId: result.userId,
teamId: result.teamId ?? undefined,
}),
isDocumentPlatform({
userId: result.userId,
teamId: result.teamId,
}),
isUserEnterprise({
userId: result.userId,
teamId: result.teamId ?? undefined,
}),
]);
}
return {
hasValidToken: !!result,
token,
hasCommunityPlan,
hasPlatformPlan,
hasEnterprisePlan,
};
};
export default function AuthoringLayout() {
const { hasValidToken, token, hasCommunityPlan, hasPlatformPlan, hasEnterprisePlan } =
useLoaderData<typeof loader>();
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
const result = ZBaseEmbedAuthoringSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
if (!result.success) {
return;
}
const { css, cssVars, darkModeDisabled } = result.data;
if (darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
}
if (hasCommunityPlan || hasPlatformPlan || hasEnterprisePlan) {
injectCss({
css,
cssVars,
});
}
} catch (error) {
console.error(error);
}
}, []);
if (!hasValidToken) {
return <div>Invalid embedding presign token provided</div>;
}
return (
<TrpcProvider headers={{ authorization: `Bearer ${token}` }}>
<Outlet />
</TrpcProvider>
);
}

View File

@ -12,10 +12,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
import {
ConfigureFieldsView,
type TConfigureFieldsFormSchema,
} from '~/components/embed/authoring/configure-fields-view';
import { ConfigureFieldsView } from '~/components/embed/authoring/configure-fields-view';
import type { TConfigureFieldsFormSchema } from '~/components/embed/authoring/configure-fields-view.types';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringSchema,
@ -71,6 +69,8 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
// Use the externalId from the URL fragment if available
const documentExternalId = externalId || configuration.meta.externalId;
const signatureTypes = configuration.meta.signatureTypes ?? [];
const createResult = await createEmbeddingDocument({
title: configuration.title,
documentDataId: documentData.id,
@ -78,14 +78,11 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
meta: {
...configuration.meta,
drawSignatureEnabled:
configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.DRAW),
signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.DRAW),
typedSignatureEnabled:
configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.TYPE),
signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled:
configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD),
signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.UPLOAD),
},
recipients: configuration.signers.map((signer) => ({
name: signer.name,
@ -126,7 +123,7 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
// Navigate to the completion page instead of the document details page
await navigate(
`/embed/v1/authoring/create-completed?documentId=${createResult.documentId}&externalId=${documentExternalId}#${hash}`,
`/embed/v1/authoring/completed/create?documentId=${createResult.documentId}&externalId=${documentExternalId}#${hash}`,
);
} catch (err) {
console.error('Error creating document:', err);

View File

@ -0,0 +1,314 @@
import { useLayoutEffect, useMemo, useState } from 'react';
import { useLingui } from '@lingui/react';
import { DocumentDistributionMethod, DocumentSigningOrder, SigningStatus } from '@prisma/client';
import { redirect, useLoaderData } from 'react-router';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
isValidDateFormat,
} from '@documenso/lib/constants/date-formats';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { nanoid } from '@documenso/lib/universal/id';
import { trpc } from '@documenso/trpc/react';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
import { ConfigureFieldsView } from '~/components/embed/authoring/configure-fields-view';
import type { TConfigureFieldsFormSchema } from '~/components/embed/authoring/configure-fields-view.types';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringSchema,
} from '~/types/embed-authoring-base-schema';
import type { Route } from './+types/document.edit.$id';
export const loader = async ({ request, params }: Route.LoaderArgs) => {
const { id } = params;
const url = new URL(request.url);
// We know that the token is present because we're checking it in the parent _layout route
const token = url.searchParams.get('token') || '';
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
if (!result) {
throw new Error('Invalid token');
}
const documentId = Number(id);
if (!documentId || Number.isNaN(documentId)) {
redirect(`/embed/v1/authoring/error/not-found?documentId=${documentId}`);
}
const document = await getDocumentWithDetailsById({
documentId,
userId: result?.userId,
teamId: result?.teamId ?? undefined,
}).catch(() => null);
if (!document) {
throw redirect(`/embed/v1/authoring/error/not-found?documentId=${documentId}`);
}
const fields = document.fields.map((field) => ({
...field,
positionX: field.positionX.toNumber(),
positionY: field.positionY.toNumber(),
width: field.width.toNumber(),
height: field.height.toNumber(),
}));
return {
document: {
...document,
fields,
},
};
};
export default function EmbeddingAuthoringDocumentEditPage() {
const { _ } = useLingui();
const { toast } = useToast();
const { document } = useLoaderData<typeof loader>();
const signatureTypes = useMemo(() => {
const types: string[] = [];
if (document.documentMeta?.drawSignatureEnabled) {
types.push(DocumentSignatureType.DRAW);
}
if (document.documentMeta?.typedSignatureEnabled) {
types.push(DocumentSignatureType.TYPE);
}
if (document.documentMeta?.uploadSignatureEnabled) {
types.push(DocumentSignatureType.UPLOAD);
}
return types;
}, [document.documentMeta]);
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(() => ({
title: document.title,
documentData: undefined,
meta: {
subject: document.documentMeta?.subject ?? undefined,
message: document.documentMeta?.message ?? undefined,
distributionMethod:
document.documentMeta?.distributionMethod ?? DocumentDistributionMethod.EMAIL,
emailSettings: document.documentMeta?.emailSettings ?? ZDocumentEmailSettingsSchema.parse({}),
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
signingOrder: document.documentMeta?.signingOrder ?? DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: document.documentMeta?.allowDictateNextSigner ?? false,
language: isValidLanguageCode(document.documentMeta?.language)
? document.documentMeta.language
: undefined,
signatureTypes: signatureTypes,
dateFormat: isValidDateFormat(document.documentMeta?.dateFormat)
? document.documentMeta?.dateFormat
: DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: document.documentMeta?.redirectUrl ?? undefined,
},
signers: document.recipients.map((recipient) => ({
nativeId: recipient.id,
formId: nanoid(8),
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder ?? undefined,
disabled: recipient.signingStatus !== SigningStatus.NOT_SIGNED,
})),
}));
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(() => ({
fields: document.fields.map((field) => ({
nativeId: field.id,
formId: nanoid(8),
type: field.type,
signerEmail:
document.recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
inserted: field.inserted,
recipientId: field.recipientId,
pageNumber: field.page,
pageX: field.positionX,
pageY: field.positionY,
pageWidth: field.width,
pageHeight: field.height,
fieldMeta: field.fieldMeta ?? undefined,
})),
}));
const [features, setFeatures] = useState<TBaseEmbedAuthoringSchema['features'] | null>(null);
const [externalId, setExternalId] = useState<string | null>(null);
const [currentStep, setCurrentStep] = useState(1);
const { mutateAsync: updateEmbeddingDocument } =
trpc.embeddingPresign.updateEmbeddingDocument.useMutation();
const handleConfigurePageViewSubmit = (data: TConfigureEmbedFormSchema) => {
// Store the configuration data and move to the field placement stage
setConfiguration(data);
setFields((fieldData) => {
if (!fieldData) {
return fieldData;
}
const signerEmails = data.signers.map((signer) => signer.email);
return {
fields: fieldData.fields.filter((field) => signerEmails.includes(field.signerEmail)),
};
});
setCurrentStep(2);
};
const handleBackToConfig = (data: TConfigureFieldsFormSchema) => {
// Return to the configuration view but keep the data
setFields(data);
setCurrentStep(1);
};
const handleConfigureFieldsSubmit = async (data: TConfigureFieldsFormSchema) => {
try {
if (!configuration) {
toast({
variant: 'destructive',
title: _('Error'),
description: _('Please configure the document first'),
});
return;
}
const fields = data.fields;
// Use the externalId from the URL fragment if available
const documentExternalId = externalId || configuration.meta.externalId;
const updateResult = await updateEmbeddingDocument({
documentId: document.id,
title: configuration.title,
externalId: documentExternalId,
meta: {
...configuration.meta,
drawSignatureEnabled: configuration.meta.signatureTypes
? configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.DRAW)
: undefined,
typedSignatureEnabled: configuration.meta.signatureTypes
? configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.TYPE)
: undefined,
uploadSignatureEnabled: configuration.meta.signatureTypes
? configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
: undefined,
},
recipients: configuration.signers.map((signer) => ({
id: signer.nativeId,
name: signer.name,
email: signer.email,
role: signer.role,
signingOrder: signer.signingOrder,
fields: fields
.filter((field) => field.signerEmail === signer.email)
// There's a gnarly discriminated union that makes this hard to satisfy, we're casting for the second
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map<any>((f) => ({
...f,
id: f.nativeId,
pageX: f.pageX,
pageY: f.pageY,
width: f.pageWidth,
height: f.pageHeight,
})),
})),
});
toast({
title: _('Success'),
description: _('Document updated successfully'),
});
// Send a message to the parent window with the document details
if (window.parent !== window) {
window.parent.postMessage(
{
type: 'document-updated',
// documentId: updateResult.documentId,
documentId: 1,
externalId: documentExternalId,
},
'*',
);
}
} catch (err) {
console.error('Error updating document:', err);
toast({
variant: 'destructive',
title: _('Error'),
description: _('Failed to update document'),
});
}
};
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
const result = ZBaseEmbedAuthoringSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
if (!result.success) {
return;
}
setFeatures(result.data.features);
// Extract externalId from the parsed data if available
if (result.data.externalId) {
setExternalId(result.data.externalId);
}
} catch (err) {
console.error('Error parsing embedding params:', err);
}
}, []);
return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg p-6">
<ConfigureDocumentProvider isTemplate={false} features={features ?? {}}>
<Stepper currentStep={currentStep} setCurrentStep={setCurrentStep}>
<ConfigureDocumentView
defaultValues={configuration ?? undefined}
disableUpload={true}
onSubmit={handleConfigurePageViewSubmit}
/>
<ConfigureFieldsView
configData={configuration!}
documentData={document.documentData}
defaultValues={fields ?? undefined}
onBack={handleBackToConfig}
onSubmit={handleConfigureFieldsSubmit}
/>
</Stepper>
</ConfigureDocumentProvider>
</div>
);
}

View File

@ -11,10 +11,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
import {
ConfigureFieldsView,
type TConfigureFieldsFormSchema,
} from '~/components/embed/authoring/configure-fields-view';
import { ConfigureFieldsView } from '~/components/embed/authoring/configure-fields-view';
import type { TConfigureFieldsFormSchema } from '~/components/embed/authoring/configure-fields-view.types';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringSchema,
@ -48,8 +46,6 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
const handleConfigureFieldsSubmit = async (data: TConfigureFieldsFormSchema) => {
try {
console.log('configuration', configuration);
console.log('data', data);
if (!configuration || !configuration.documentData) {
toast({
variant: 'destructive',
@ -117,7 +113,7 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
// Navigate to the completion page instead of the template details page
await navigate(
`/embed/v1/authoring/create-completed?templateId=${createResult.templateId}&externalId=${metaWithExternalId.externalId}#${hash}`,
`/embed/v1/authoring/completed/create?templateId=${createResult.templateId}&externalId=${metaWithExternalId.externalId}#${hash}`,
);
} catch (err) {
console.error('Error creating template:', err);

View File

@ -0,0 +1,314 @@
import { useLayoutEffect, useMemo, useState } from 'react';
import { useLingui } from '@lingui/react';
import { DocumentDistributionMethod, DocumentSigningOrder, SigningStatus } from '@prisma/client';
import { redirect, useLoaderData } from 'react-router';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
isValidDateFormat,
} from '@documenso/lib/constants/date-formats';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { nanoid } from '@documenso/lib/universal/id';
import { trpc } from '@documenso/trpc/react';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
import { ConfigureFieldsView } from '~/components/embed/authoring/configure-fields-view';
import type { TConfigureFieldsFormSchema } from '~/components/embed/authoring/configure-fields-view.types';
import {
type TBaseEmbedAuthoringSchema,
ZBaseEmbedAuthoringSchema,
} from '~/types/embed-authoring-base-schema';
import type { Route } from './+types/document.edit.$id';
export const loader = async ({ request, params }: Route.LoaderArgs) => {
const { id } = params;
const url = new URL(request.url);
// We know that the token is present because we're checking it in the parent _layout route
const token = url.searchParams.get('token') || '';
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
if (!result) {
throw new Error('Invalid token');
}
const templateId = Number(id);
if (!templateId || Number.isNaN(templateId)) {
redirect(`/embed/v1/authoring/error/not-found?templateId=${templateId}`);
}
const template = await getTemplateById({
id: templateId,
userId: result?.userId,
teamId: result?.teamId ?? undefined,
}).catch(() => null);
if (!template) {
throw redirect(`/embed/v1/authoring/error/not-found?templateId=${templateId}`);
}
const fields = template.fields.map((field) => ({
...field,
positionX: field.positionX.toNumber(),
positionY: field.positionY.toNumber(),
width: field.width.toNumber(),
height: field.height.toNumber(),
}));
return {
template: {
...template,
fields,
},
};
};
export default function EmbeddingAuthoringTemplateEditPage() {
const { _ } = useLingui();
const { toast } = useToast();
const { template } = useLoaderData<typeof loader>();
const signatureTypes = useMemo(() => {
const types: string[] = [];
if (template.templateMeta?.drawSignatureEnabled) {
types.push(DocumentSignatureType.DRAW);
}
if (template.templateMeta?.typedSignatureEnabled) {
types.push(DocumentSignatureType.TYPE);
}
if (template.templateMeta?.uploadSignatureEnabled) {
types.push(DocumentSignatureType.UPLOAD);
}
return types;
}, [template.templateMeta]);
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(() => ({
title: template.title,
documentData: undefined,
meta: {
subject: template.templateMeta?.subject ?? undefined,
message: template.templateMeta?.message ?? undefined,
distributionMethod:
template.templateMeta?.distributionMethod ?? DocumentDistributionMethod.EMAIL,
emailSettings: template.templateMeta?.emailSettings ?? ZDocumentEmailSettingsSchema.parse({}),
timezone: template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
signingOrder: template.templateMeta?.signingOrder ?? DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: template.templateMeta?.allowDictateNextSigner ?? false,
language: isValidLanguageCode(template.templateMeta?.language)
? template.templateMeta.language
: undefined,
signatureTypes: signatureTypes,
dateFormat: isValidDateFormat(template.templateMeta?.dateFormat)
? template.templateMeta?.dateFormat
: DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: template.templateMeta?.redirectUrl ?? undefined,
},
signers: template.recipients.map((recipient) => ({
nativeId: recipient.id,
formId: nanoid(8),
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder ?? undefined,
disabled: recipient.signingStatus !== SigningStatus.NOT_SIGNED,
})),
}));
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(() => ({
fields: template.fields.map((field) => ({
nativeId: field.id,
formId: nanoid(8),
type: field.type,
signerEmail:
template.recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
inserted: field.inserted,
recipientId: field.recipientId,
pageNumber: field.page,
pageX: field.positionX,
pageY: field.positionY,
pageWidth: field.width,
pageHeight: field.height,
fieldMeta: field.fieldMeta ?? undefined,
})),
}));
const [features, setFeatures] = useState<TBaseEmbedAuthoringSchema['features'] | null>(null);
const [externalId, setExternalId] = useState<string | null>(null);
const [currentStep, setCurrentStep] = useState(1);
const { mutateAsync: updateEmbeddingTemplate } =
trpc.embeddingPresign.updateEmbeddingTemplate.useMutation();
const handleConfigurePageViewSubmit = (data: TConfigureEmbedFormSchema) => {
// Store the configuration data and move to the field placement stage
setConfiguration(data);
setFields((fieldData) => {
if (!fieldData) {
return fieldData;
}
const signerEmails = data.signers.map((signer) => signer.email);
return {
fields: fieldData.fields.filter((field) => signerEmails.includes(field.signerEmail)),
};
});
setCurrentStep(2);
};
const handleBackToConfig = (data: TConfigureFieldsFormSchema) => {
// Return to the configuration view but keep the data
setFields(data);
setCurrentStep(1);
};
const handleConfigureFieldsSubmit = async (data: TConfigureFieldsFormSchema) => {
try {
if (!configuration) {
toast({
variant: 'destructive',
title: _('Error'),
description: _('Please configure the document first'),
});
return;
}
const fields = data.fields;
// Use the externalId from the URL fragment if available
const templateExternalId = externalId || configuration.meta.externalId;
const updateResult = await updateEmbeddingTemplate({
templateId: template.id,
title: configuration.title,
externalId: templateExternalId,
meta: {
...configuration.meta,
drawSignatureEnabled: configuration.meta.signatureTypes
? configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.DRAW)
: undefined,
typedSignatureEnabled: configuration.meta.signatureTypes
? configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.TYPE)
: undefined,
uploadSignatureEnabled: configuration.meta.signatureTypes
? configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
: undefined,
},
recipients: configuration.signers.map((signer) => ({
id: signer.nativeId,
name: signer.name,
email: signer.email,
role: signer.role,
signingOrder: signer.signingOrder,
fields: fields
.filter((field) => field.signerEmail === signer.email)
// There's a gnarly discriminated union that makes this hard to satisfy, we're casting for the second
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map<any>((f) => ({
...f,
id: f.nativeId,
pageX: f.pageX,
pageY: f.pageY,
width: f.pageWidth,
height: f.pageHeight,
})),
})),
});
toast({
title: _('Success'),
description: _('Document updated successfully'),
});
// Send a message to the parent window with the template details
if (window.parent !== window) {
window.parent.postMessage(
{
type: 'template-updated',
// templateId: updateResult.templateId,
templateId: 1,
externalId: templateExternalId,
},
'*',
);
}
} catch (err) {
console.error('Error updating template:', err);
toast({
variant: 'destructive',
title: _('Error'),
description: _('Failed to update template'),
});
}
};
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
const result = ZBaseEmbedAuthoringSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
if (!result.success) {
return;
}
setFeatures(result.data.features);
// Extract externalId from the parsed data if available
if (result.data.externalId) {
setExternalId(result.data.externalId);
}
} catch (err) {
console.error('Error parsing embedding params:', err);
}
}, []);
return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg p-6">
<ConfigureDocumentProvider isTemplate={false} features={features ?? {}}>
<Stepper currentStep={currentStep} setCurrentStep={setCurrentStep}>
<ConfigureDocumentView
defaultValues={configuration ?? undefined}
disableUpload={true}
onSubmit={handleConfigurePageViewSubmit}
/>
<ConfigureFieldsView
configData={configuration!}
documentData={template.templateDocumentData}
defaultValues={fields ?? undefined}
onBack={handleBackToConfig}
onSubmit={handleConfigureFieldsSubmit}
/>
</Stepper>
</ConfigureDocumentProvider>
</div>
);
}

View File

@ -1,66 +0,0 @@
import { useLayoutEffect, useState } from 'react';
import { Outlet } from 'react-router';
import { TrpcProvider, trpc } from '@documenso/trpc/react';
import { EmbedClientLoading } from '~/components/embed/embed-client-loading';
import { ZBaseEmbedAuthoringSchema } from '~/types/embed-authoring-base-schema';
import { injectCss } from '~/utils/css-vars';
export default function AuthoringLayout() {
const [token, setToken] = useState('');
const {
mutateAsync: verifyEmbeddingPresignToken,
isPending: isVerifyingEmbeddingPresignToken,
data: isVerified,
} = trpc.embeddingPresign.verifyEmbeddingPresignToken.useMutation();
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
const result = ZBaseEmbedAuthoringSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
if (!result.success) {
return;
}
const { token, css, cssVars, darkModeDisabled } = result.data;
if (darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
}
injectCss({
css,
cssVars,
});
void verifyEmbeddingPresignToken({ token }).then((result) => {
if (result.success) {
setToken(token);
}
});
} catch (err) {
console.error('Error verifying embedding presign token:', err);
}
}, []);
if (isVerifyingEmbeddingPresignToken) {
return <EmbedClientLoading />;
}
if (typeof isVerified !== 'undefined' && !isVerified.success) {
return <div>Invalid embedding presign token</div>;
}
return (
<TrpcProvider headers={{ authorization: `Bearer ${token}` }}>
<Outlet />
</TrpcProvider>
);
}

View File

@ -17,6 +17,8 @@ export const VALID_DATE_FORMAT_VALUES = [
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
] as const;
export type ValidDateFormat = (typeof VALID_DATE_FORMAT_VALUES)[number];
export const DATE_FORMATS = [
{
key: 'yyyy-MM-dd_hh:mm_a',
@ -94,3 +96,7 @@ export const convertToLocalSystemFormat = (
return formattedDate;
};
export const isValidDateFormat = (dateFormat: unknown): dateFormat is ValidDateFormat => {
return VALID_DATE_FORMAT_VALUES.includes(dateFormat as ValidDateFormat);
};

View File

@ -25,6 +25,7 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
typedSignatureEnabled: z.boolean(),
uploadSignatureEnabled: z.boolean(),
drawSignatureEnabled: z.boolean(),
allowEmbeddedAuthoring: z.boolean(),
})
.nullish(),
}),

View File

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

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "allowEmbeddedAuthoring" BOOLEAN NOT NULL DEFAULT false;

View File

@ -565,6 +565,8 @@ model TeamGlobalSettings {
brandingCompanyDetails String @default("")
brandingHidePoweredBy Boolean @default(false)
allowEmbeddedAuthoring Boolean @default(false)
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
}

View File

@ -2,7 +2,8 @@ import { router } from '../trpc';
import { createEmbeddingDocumentRoute } from './create-embedding-document';
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
import { createEmbeddingTemplateRoute } from './create-embedding-template';
import { getEmbeddingDocumentRoute } from './get-embedding-document';
import { updateEmbeddingDocumentRoute } from './update-embedding-document';
import { updateEmbeddingTemplateRoute } from './update-embedding-template';
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
export const embeddingPresignRouter = router({
@ -10,5 +11,6 @@ export const embeddingPresignRouter = router({
verifyEmbeddingPresignToken: verifyEmbeddingPresignTokenRoute,
createEmbeddingDocument: createEmbeddingDocumentRoute,
createEmbeddingTemplate: createEmbeddingTemplateRoute,
getEmbeddingDocument: getEmbeddingDocumentRoute,
updateEmbeddingDocument: updateEmbeddingDocumentRoute,
updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
});

View File

@ -1,10 +1,10 @@
import { isCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import { prisma } from '@documenso/prisma';
import { procedure } from '../trpc';
import {
@ -42,13 +42,24 @@ export const createEmbeddingPresignTokenRoute = procedure
});
}
const [hasCommunityPlan, hasPlatformPlan, hasEnterprisePlan] = await Promise.all([
const [hasCommunityPlan, hasEnterprisePlan] = await Promise.all([
isCommunityPlan({ userId: token.userId, teamId: token.teamId ?? undefined }),
isDocumentPlatform({ userId: token.userId, teamId: token.teamId }),
isUserEnterprise({ userId: token.userId, teamId: token.teamId ?? undefined }),
]);
if (!hasCommunityPlan && !hasPlatformPlan && !hasEnterprisePlan) {
let hasTeamAuthoringFlag = false;
if (token.teamId) {
const teamGlobalSettings = await prisma.teamGlobalSettings.findFirst({
where: {
teamId: token.teamId,
},
});
hasTeamAuthoringFlag = teamGlobalSettings?.allowEmbeddedAuthoring ?? false;
}
if (!hasCommunityPlan && !hasEnterprisePlan && !hasTeamAuthoringFlag) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to create embedding presign tokens',
});

View File

@ -1,63 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { prisma } from '@documenso/prisma';
import { procedure } from '../trpc';
import {
ZGetEmbeddingDocumentRequestSchema,
ZGetEmbeddingDocumentResponseSchema,
} from './get-embedding-document.types';
export const getEmbeddingDocumentRoute = procedure
.input(ZGetEmbeddingDocumentRequestSchema)
.output(ZGetEmbeddingDocumentResponseSchema)
.query(async ({ input, ctx: { req } }) => {
try {
const authorizationHeader = req.headers.get('authorization');
const [presignToken] = (authorizationHeader || '')
.split('Bearer ')
.filter((s) => s.length > 0);
if (!presignToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'No presign token provided',
});
}
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
const { documentId } = input;
const document = await prisma.document.findFirst({
where: {
id: documentId,
userId: apiToken.userId,
...(apiToken.teamId ? { teamId: apiToken.teamId } : {}),
},
include: {
documentData: true,
recipients: true,
fields: true,
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
return {
document,
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to get document',
});
}
});

View File

@ -1,34 +0,0 @@
import { DocumentDataType, type Field, type Recipient } from '@prisma/client';
import { z } from 'zod';
export const ZGetEmbeddingDocumentRequestSchema = z.object({
documentId: z.number(),
});
export const ZGetEmbeddingDocumentResponseSchema = z.object({
document: z
.object({
id: z.number(),
title: z.string(),
status: z.string(),
documentDataId: z.string(),
userId: z.number(),
teamId: z.number().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
documentData: z.object({
id: z.string(),
type: z.nativeEnum(DocumentDataType),
data: z.string(),
initialData: z.string(),
}),
recipients: z.array(z.custom<Recipient>()),
fields: z.array(z.custom<Field>()),
})
.nullable(),
});
export type TGetEmbeddingDocumentRequestSchema = z.infer<typeof ZGetEmbeddingDocumentRequestSchema>;
export type TGetEmbeddingDocumentResponseSchema = z.infer<
typeof ZGetEmbeddingDocumentResponseSchema
>;

View File

@ -0,0 +1,118 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
import { nanoid } from '@documenso/lib/universal/id';
import { procedure } from '../trpc';
import {
ZUpdateEmbeddingDocumentRequestSchema,
ZUpdateEmbeddingDocumentResponseSchema,
} from './update-embedding-document.types';
export const updateEmbeddingDocumentRoute = procedure
.input(ZUpdateEmbeddingDocumentRequestSchema)
.output(ZUpdateEmbeddingDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
try {
const authorizationHeader = ctx.req.headers.get('authorization');
const [presignToken] = (authorizationHeader || '')
.split('Bearer ')
.filter((s) => s.length > 0);
if (!presignToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'No presign token provided',
});
}
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
const { documentId, title, externalId, recipients, meta } = input;
if (meta && Object.values(meta).length > 0) {
await upsertDocumentMeta({
documentId: documentId,
userId: apiToken.userId,
teamId: apiToken.teamId ?? undefined,
...meta,
requestMetadata: ctx.metadata,
});
}
await updateDocument({
userId: apiToken.userId,
teamId: apiToken.teamId ?? undefined,
documentId: documentId,
data: {
title,
externalId,
},
requestMetadata: ctx.metadata,
});
const recipientsWithClientId = recipients.map((recipient) => ({
...recipient,
clientId: nanoid(),
}));
const { recipients: updatedRecipients } = await setDocumentRecipients({
userId: apiToken.userId,
teamId: apiToken.teamId ?? undefined,
documentId: documentId,
recipients: recipientsWithClientId.map((recipient) => ({
id: recipient.id,
clientId: recipient.clientId,
email: recipient.email,
name: recipient.name ?? '',
role: recipient.role,
signingOrder: recipient.signingOrder,
})),
requestMetadata: ctx.metadata,
});
const fields = recipientsWithClientId.flatMap((recipient) => {
const recipientId = updatedRecipients.find((r) => r.clientId === recipient.clientId)?.id;
if (!recipientId) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Recipient not found',
});
}
return (recipient.fields ?? []).map((field) => ({
...field,
recipientId,
// !: Temp property to be removed once we don't link based on signer email
signerEmail: recipient.email,
}));
});
await setFieldsForDocument({
userId: apiToken.userId,
teamId: apiToken.teamId ?? undefined,
documentId,
fields: fields.map((field) => ({
...field,
pageWidth: field.width,
pageHeight: field.height,
})),
requestMetadata: ctx.metadata,
});
return {
documentId,
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to update document',
});
}
});

View File

@ -0,0 +1,87 @@
import { z } from 'zod';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import {
ZFieldHeightSchema,
ZFieldPageNumberSchema,
ZFieldPageXSchema,
ZFieldPageYSchema,
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { DocumentSigningOrder, RecipientRole } from '@documenso/prisma/generated/types';
import {
ZDocumentExternalIdSchema,
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
ZDocumentMetaTypedSignatureEnabledSchema,
ZDocumentMetaUploadSignatureEnabledSchema,
ZDocumentTitleSchema,
} from '../document-router/schema';
export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
documentId: z.number(),
title: ZDocumentTitleSchema,
externalId: ZDocumentExternalIdSchema.optional(),
recipients: z
.array(
z.object({
id: z.number().optional(),
email: z.string().toLowerCase().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
fields: ZFieldAndMetaSchema.and(
z.object({
id: z.number().optional(),
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
}),
)
.array()
.optional(),
}),
)
.refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{ message: 'Recipients must have unique emails' },
),
meta: z
.object({
subject: ZDocumentMetaSubjectSchema.optional(),
message: ZDocumentMetaMessageSchema.optional(),
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
});
export const ZUpdateEmbeddingDocumentResponseSchema = z.object({
documentId: z.number(),
});
export type TUpdateEmbeddingDocumentRequestSchema = z.infer<
typeof ZUpdateEmbeddingDocumentRequestSchema
>;

View File

@ -0,0 +1,104 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients';
import { updateTemplate } from '@documenso/lib/server-only/template/update-template';
import { nanoid } from '@documenso/lib/universal/id';
import { procedure } from '../trpc';
import {
ZUpdateEmbeddingTemplateRequestSchema,
ZUpdateEmbeddingTemplateResponseSchema,
} from './update-embedding-template.types';
export const updateEmbeddingTemplateRoute = procedure
.input(ZUpdateEmbeddingTemplateRequestSchema)
.output(ZUpdateEmbeddingTemplateResponseSchema)
.mutation(async ({ input, ctx }) => {
try {
const authorizationHeader = ctx.req.headers.get('authorization');
const [presignToken] = (authorizationHeader || '')
.split('Bearer ')
.filter((s) => s.length > 0);
if (!presignToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'No presign token provided',
});
}
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
const { templateId, title, externalId, recipients, meta } = input;
await updateTemplate({
templateId,
userId: apiToken.userId,
teamId: apiToken.teamId ?? undefined,
data: {
title,
externalId,
},
meta,
});
const recipientsWithClientId = recipients.map((recipient) => ({
...recipient,
clientId: nanoid(),
}));
const { recipients: updatedRecipients } = await setTemplateRecipients({
userId: apiToken.userId,
teamId: apiToken.teamId ?? undefined,
templateId,
recipients: recipientsWithClientId.map((recipient) => ({
id: recipient.id,
email: recipient.email,
name: recipient.name ?? '',
role: recipient.role ?? 'SIGNER',
signingOrder: recipient.signingOrder,
})),
});
const fields = recipientsWithClientId.flatMap((recipient) => {
const recipientId = updatedRecipients.find((r) => r.email === recipient.email)?.id;
if (!recipientId) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Recipient not found',
});
}
return (recipient.fields ?? []).map((field) => ({
...field,
recipientId,
// !: Temp property to be removed once we don't link based on signer email
signerEmail: recipient.email,
}));
});
await setFieldsForTemplate({
userId: apiToken.userId,
teamId: apiToken.teamId ?? undefined,
templateId,
fields: fields.map((field) => ({
...field,
pageWidth: field.width,
pageHeight: field.height,
})),
});
return {
templateId,
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to update template',
});
}
});

View File

@ -0,0 +1,77 @@
import { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import {
ZFieldHeightSchema,
ZFieldPageNumberSchema,
ZFieldPageXSchema,
ZFieldPageYSchema,
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
ZDocumentMetaTypedSignatureEnabledSchema,
ZDocumentMetaUploadSignatureEnabledSchema,
ZDocumentTitleSchema,
} from '../document-router/schema';
const ZFieldSchema = z.object({
id: z.number().optional(),
type: z.nativeEnum(FieldType),
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
fieldMeta: ZFieldMetaSchema.optional(),
});
export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
templateId: z.number(),
title: ZDocumentTitleSchema.optional(),
externalId: z.string().optional(),
recipients: z.array(
z.object({
id: z.number().optional(),
email: z.string().email(),
name: z.string().optional(),
role: z.nativeEnum(RecipientRole).optional(),
signingOrder: z.number().optional(),
fields: z.array(ZFieldSchema).optional(),
}),
),
meta: z
.object({
subject: ZDocumentMetaSubjectSchema.optional(),
message: ZDocumentMetaMessageSchema.optional(),
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
});
export const ZUpdateEmbeddingTemplateResponseSchema = z.object({
templateId: z.number(),
});
export type TUpdateEmbeddingTemplateRequestSchema = z.infer<
typeof ZUpdateEmbeddingTemplateRequestSchema
>;

View File

@ -30,7 +30,7 @@ export const ZCreateRecipientSchema = z.object({
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
});
const ZUpdateRecipientSchema = z.object({
export const ZUpdateRecipientSchema = z.object({
id: z.number().describe('The ID of the recipient to update.'),
email: z.string().toLowerCase().email().min(1).optional(),
name: z.string().optional(),

View File

@ -3,7 +3,7 @@ import { useCallback, useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { RecipientRole, SendStatus } from '@prisma/client';
import { RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { sortBy } from 'remeda';
@ -145,6 +145,7 @@ export const RecipientSelector = ({
onSelectedRecipientChange(recipient);
setShowRecipientsSelector(false);
}}
disabled={recipient.signingStatus !== SigningStatus.NOT_SIGNED}
>
<span
className={cn('text-foreground/70 truncate', {