feat: add attachments (#2091)

This commit is contained in:
Lucas Smith
2025-10-23 23:07:10 +11:00
committed by GitHub
parent 4a3859ec60
commit 2eebc0e439
51 changed files with 1284 additions and 15 deletions

View File

@ -37,6 +37,7 @@ import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-sc
import { injectCss } from '~/utils/css-vars'; import { injectCss } from '~/utils/css-vars';
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form'; import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { EmbedClientLoading } from './embed-client-loading'; import { EmbedClientLoading } from './embed-client-loading';
import { EmbedDocumentCompleted } from './embed-document-completed'; import { EmbedDocumentCompleted } from './embed-document-completed';
@ -44,6 +45,7 @@ import { EmbedDocumentFields } from './embed-document-fields';
export type EmbedDirectTemplateClientPageProps = { export type EmbedDirectTemplateClientPageProps = {
token: string; token: string;
envelopeId: string;
updatedAt: Date; updatedAt: Date;
documentData: DocumentData; documentData: DocumentData;
recipient: Recipient; recipient: Recipient;
@ -55,9 +57,10 @@ export type EmbedDirectTemplateClientPageProps = {
export const EmbedDirectTemplateClientPage = ({ export const EmbedDirectTemplateClientPage = ({
token, token,
envelopeId,
updatedAt, updatedAt,
documentData, documentData,
recipient: _recipient, recipient,
fields, fields,
metadata, metadata,
hidePoweredBy = false, hidePoweredBy = false,
@ -321,9 +324,13 @@ export const EmbedDirectTemplateClientPage = ({
} }
return ( return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6"> <div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />} {(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={recipient.token} />
</div>
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row"> <div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}
<div className="flex-1"> <div className="flex-1">

View File

@ -37,6 +37,7 @@ import { BrandingLogo } from '~/components/general/branding-logo';
import { injectCss } from '~/utils/css-vars'; import { injectCss } from '~/utils/css-vars';
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema'; import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider'; import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog'; import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
@ -48,6 +49,7 @@ import { EmbedDocumentRejected } from './embed-document-rejected';
export type EmbedSignDocumentClientPageProps = { export type EmbedSignDocumentClientPageProps = {
token: string; token: string;
documentId: number; documentId: number;
envelopeId: string;
documentData: DocumentData; documentData: DocumentData;
recipient: RecipientWithFields; recipient: RecipientWithFields;
fields: Field[]; fields: Field[];
@ -62,6 +64,7 @@ export type EmbedSignDocumentClientPageProps = {
export const EmbedSignDocumentClientPage = ({ export const EmbedSignDocumentClientPage = ({
token, token,
documentId, documentId,
envelopeId,
documentData, documentData,
recipient, recipient,
fields, fields,
@ -274,15 +277,17 @@ export const EmbedSignDocumentClientPage = ({
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6"> <div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />} {(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
{allowDocumentRejection && ( <div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between"> <DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
{allowDocumentRejection && (
<DocumentSigningRejectDialog <DocumentSigningRejectDialog
documentId={documentId} documentId={documentId}
token={token} token={token}
onRejected={onDocumentRejected} onRejected={onDocumentRejected}
/> />
</div> )}
)} </div>
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row"> <div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}

View File

@ -0,0 +1,79 @@
import { Trans } from '@lingui/react/macro';
import { ExternalLink, PaperclipIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
export type DocumentSigningAttachmentsPopoverProps = {
envelopeId: string;
token: string;
};
export const DocumentSigningAttachmentsPopover = ({
envelopeId,
token,
}: DocumentSigningAttachmentsPopoverProps) => {
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId,
token,
});
if (!attachments || attachments.data.length === 0) {
return null;
}
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="gap-2">
<PaperclipIcon className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>{' '}
{attachments && attachments.data.length > 0 && (
<span className="ml-1">({attachments.data.length})</span>
)}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="start">
<div className="space-y-4">
<div>
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>Documents and resources related to this envelope.</Trans>
</p>
</div>
<div className="space-y-2">
{attachments?.data.map((attachment) => (
<a
key={attachment.id}
href={attachment.data}
title={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="border-border hover:bg-muted/50 group flex items-center justify-between rounded-md border px-3 py-2.5 transition duration-200"
>
<div className="flex flex-1 items-center gap-2.5">
<div className="bg-muted rounded p-2">
<PaperclipIcon className="h-4 w-4" />
</div>
<span className="text-muted-foreground hover:text-foreground block truncate text-sm underline">
{attachment.label}
</span>
</div>
<ExternalLink className="h-4 w-4 opacity-0 transition duration-200 group-hover:opacity-100" />
</a>
))}
</div>
</div>
</PopoverContent>
</Popover>
);
};

View File

@ -32,6 +32,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign'; import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field'; import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
@ -231,7 +232,13 @@ export const DocumentSigningPageViewV1 = ({
</span> </span>
</div> </div>
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} /> <div className="flex items-center gap-x-4">
<DocumentSigningAttachmentsPopover
envelopeId={document.envelopeId}
token={recipient.token}
/>
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
</div>
</div> </div>
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0"> <div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">

View File

@ -19,6 +19,7 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog'; import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog'; import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector'; import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form'; import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header'; import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header';
@ -31,8 +32,13 @@ const EnvelopeSignerPageRenderer = lazy(
export const DocumentSigningPageViewV2 = () => { export const DocumentSigningPageViewV2 = () => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender(); const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { envelope, recipientFields, recipientFieldsRemaining, showPendingFieldTooltip } = const {
useRequiredEnvelopeSigningContext(); envelope,
recipient,
recipientFields,
recipientFieldsRemaining,
showPendingFieldTooltip,
} = useRequiredEnvelopeSigningContext();
return ( return (
<div className="h-screen w-screen bg-gray-50"> <div className="h-screen w-screen bg-gray-50">
@ -83,6 +89,10 @@ export const DocumentSigningPageViewV2 = () => {
<Trans>Actions</Trans> <Trans>Actions</Trans>
</h4> </h4>
<div className="w-full">
<DocumentSigningAttachmentsPopover envelopeId={envelope.id} token={recipient.token} />
</div>
{/* Todo: Allow selecting which document to download and/or the original */} {/* Todo: Allow selecting which document to download and/or the original */}
<Button variant="ghost" size="sm" className="w-full justify-start"> <Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" /> <DownloadCloudIcon className="mr-2 h-4 w-4" />

View File

@ -0,0 +1,241 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Paperclip, Plus, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentAttachmentsPopoverProps = {
envelopeId: string;
};
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const utils = trpc.useUtils();
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId,
});
const { mutateAsync: createAttachment, isPending: isCreating } =
trpc.envelope.attachment.create.useMutation({
onSuccess: () => {
void utils.envelope.attachment.find.invalidate({ envelopeId });
},
});
const { mutateAsync: deleteAttachment } = trpc.envelope.attachment.delete.useMutation({
onSuccess: () => {
void utils.envelope.attachment.find.invalidate({ envelopeId });
},
});
const form = useForm<TAttachmentFormSchema>({
resolver: zodResolver(ZAttachmentFormSchema),
defaultValues: {
label: '',
url: '',
},
});
const onSubmit = async (data: TAttachmentFormSchema) => {
try {
await createAttachment({
envelopeId,
data: {
label: data.label,
data: data.url,
},
});
form.reset();
setIsAdding(false);
toast({
title: _(msg`Success`),
description: _(msg`Attachment added successfully.`),
});
} catch (err) {
const error = AppError.parseError(err);
toast({
title: _(msg`Error`),
description: error.message,
variant: 'destructive',
});
}
};
const onDeleteAttachment = async (id: string) => {
try {
await deleteAttachment({ id });
toast({
title: _(msg`Success`),
description: _(msg`Attachment removed successfully.`),
});
} catch (err) {
const error = AppError.parseError(err);
toast({
title: _(msg`Error`),
description: error.message,
variant: 'destructive',
});
}
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="gap-2">
<Paperclip className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>
{attachments && attachments.data.length > 0 && (
<span className="ml-1">({attachments.data.length})</span>
)}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="end">
<div className="space-y-4">
<div>
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>Add links to relevant documents or resources.</Trans>
</p>
</div>
{attachments && attachments.data.length > 0 && (
<div className="space-y-2">
{attachments?.data.map((attachment) => (
<div
key={attachment.id}
className="border-border flex items-center justify-between rounded-md border p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.label}</p>
<a
href={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
>
{attachment.data}
</a>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => void onDeleteAttachment(attachment.id)}
className="ml-2 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{!isAdding && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsAdding(true)}
>
<Plus className="mr-2 h-4 w-4" />
<Trans>Add Attachment</Trans>
</Button>
)}
{isAdding && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<FormField
control={form.control}
name="label"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder={_(msg`Label`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="url" placeholder={_(msg`URL`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
<Trans>Add</Trans>
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="flex-1"
onClick={() => {
setIsAdding(false);
form.reset();
}}
>
<Trans>Cancel</Trans>
</Button>
</div>
</form>
</Form>
)}
</div>
</PopoverContent>
</Popover>
);
};

View File

@ -22,6 +22,7 @@ import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribu
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog'; import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog'; import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogo } from '~/components/general/branding-logo';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog'; import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
@ -131,6 +132,8 @@ export default function EnvelopeEditorHeader() {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<DocumentAttachmentsPopover envelopeId={envelope.id} />
<EnvelopeEditorSettingsDialog <EnvelopeEditorSettingsDialog
trigger={ trigger={
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">

View File

@ -9,6 +9,7 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { logDocumentAccess } from '@documenso/lib/utils/logger'; import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams'; import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { DocumentEditForm } from '~/components/general/document/document-edit-form'; import { DocumentEditForm } from '~/components/general/document/document-edit-form';
import { DocumentStatus } from '~/components/general/document/document-status'; import { DocumentStatus } from '~/components/general/document/document-status';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover'; import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
@ -122,11 +123,13 @@ export default function DocumentEditPage() {
</div> </div>
</div> </div>
{document.useLegacyFieldInsertion && ( <div className="flex items-center gap-x-4">
<div> <DocumentAttachmentsPopover envelopeId={document.envelopeId} />
{document.useLegacyFieldInsertion && (
<LegacyFieldWarningPopover type="document" documentId={document.id} /> <LegacyFieldWarningPopover type="document" documentId={document.id} />
</div> )}
)} </div>
</div> </div>
<DocumentEditForm <DocumentEditForm

View File

@ -8,6 +8,7 @@ import { getTemplateById } from '@documenso/lib/server-only/template/get-templat
import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog'; import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover'; import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge'; import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/general/template/template-edit-form'; import { TemplateEditForm } from '~/components/general/template/template-edit-form';
@ -87,6 +88,8 @@ export default function TemplateEditPage() {
</div> </div>
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end"> <div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
<DocumentAttachmentsPopover envelopeId={template.envelopeId} />
<TemplateDirectLinkDialog <TemplateDirectLinkDialog
templateId={template.id} templateId={template.id}
directLink={template.directLink} directLink={template.directLink}

View File

@ -122,6 +122,7 @@ export default function EmbedDirectTemplatePage() {
<DocumentSigningRecipientProvider recipient={recipient}> <DocumentSigningRecipientProvider recipient={recipient}>
<EmbedDirectTemplateClientPage <EmbedDirectTemplateClientPage
token={token} token={token}
envelopeId={template.envelopeId}
updatedAt={template.updatedAt} updatedAt={template.updatedAt}
documentData={template.templateDocumentData} documentData={template.templateDocumentData}
recipient={recipient} recipient={recipient}

View File

@ -164,6 +164,7 @@ export default function EmbedSignDocumentPage() {
<EmbedSignDocumentClientPage <EmbedSignDocumentClientPage
token={token} token={token}
documentId={document.id} documentId={document.id}
envelopeId={document.envelopeId}
documentData={document.documentData} documentData={document.documentData}
recipient={recipient} recipient={recipient}
fields={fields} fields={fields}

View File

@ -427,6 +427,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
globalAccessAuth: body.authOptions?.globalAccessAuth, globalAccessAuth: body.authOptions?.globalAccessAuth,
globalActionAuth: body.authOptions?.globalActionAuth, globalActionAuth: body.authOptions?.globalActionAuth,
}, },
attachments: body.attachments,
meta: { meta: {
subject: body.meta.subject, subject: body.meta.subject,
message: body.meta.message, message: body.meta.message,
@ -497,6 +498,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
publicDescription, publicDescription,
type, type,
meta, meta,
attachments,
} = body; } = body;
try { try {
@ -568,6 +570,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
publicDescription, publicDescription,
}, },
meta, meta,
attachments,
requestMetadata: metadata, requestMetadata: metadata,
}); });
@ -792,6 +795,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
...body.meta, ...body.meta,
title: body.title, title: body.title,
}, },
attachments: body.attachments,
requestMetadata: metadata, requestMetadata: metadata,
}); });

View File

@ -22,6 +22,7 @@ import {
ZRecipientActionAuthTypesSchema, ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
extendZodWithOpenApi(z); extendZodWithOpenApi(z);
@ -197,6 +198,15 @@ export const ZCreateDocumentMutationSchema = z.object({
description: 'The globalActionAuth property is only available for Enterprise accounts.', description: 'The globalActionAuth property is only available for Enterprise accounts.',
}), }),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
}); });
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>; export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
@ -262,6 +272,15 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
}) })
.optional(), .optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
}); });
export type TCreateDocumentFromTemplateMutationSchema = z.infer< export type TCreateDocumentFromTemplateMutationSchema = z.infer<

View File

@ -0,0 +1,50 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type CreateAttachmentOptions = {
envelopeId: string;
teamId: number;
userId: number;
data: {
label: string;
data: string;
};
};
export const createAttachment = async ({
envelopeId,
teamId,
userId,
data,
}: CreateAttachmentOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.status === DocumentStatus.COMPLETED || envelope.status === DocumentStatus.REJECTED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Attachments can not be modified after the document has been completed or rejected',
});
}
return await prisma.envelopeAttachment.create({
data: {
envelopeId,
type: 'link',
...data,
},
});
};

View File

@ -0,0 +1,47 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type DeleteAttachmentOptions = {
id: string;
userId: number;
teamId: number;
};
export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentOptions) => {
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
},
});
if (!attachment) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Attachments can not be modified after the document has been completed or rejected',
});
}
await prisma.envelopeAttachment.delete({
where: {
id,
},
});
};

View File

@ -0,0 +1,38 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type FindAttachmentsByEnvelopeIdOptions = {
envelopeId: string;
userId: number;
teamId: number;
};
export const findAttachmentsByEnvelopeId = async ({
envelopeId,
userId,
teamId,
}: FindAttachmentsByEnvelopeIdOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return await prisma.envelopeAttachment.findMany({
where: {
envelopeId,
},
orderBy: {
createdAt: 'asc',
},
});
};

View File

@ -0,0 +1,70 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
export type FindAttachmentsByTokenOptions = {
envelopeId: string;
token: string;
};
export const findAttachmentsByToken = async ({
envelopeId,
token,
}: FindAttachmentsByTokenOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
recipients: {
some: {
token,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return await prisma.envelopeAttachment.findMany({
where: {
envelopeId,
},
orderBy: {
createdAt: 'asc',
},
});
};
export type FindAttachmentsByTeamOptions = {
envelopeId: string;
teamId: number;
};
export const findAttachmentsByTeam = async ({
envelopeId,
teamId,
}: FindAttachmentsByTeamOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
teamId,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return await prisma.envelopeAttachment.findMany({
where: {
envelopeId,
},
orderBy: {
createdAt: 'asc',
},
});
};

View File

@ -0,0 +1,49 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type UpdateAttachmentOptions = {
id: string;
userId: number;
teamId: number;
data: { label?: string; data?: string };
};
export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttachmentOptions) => {
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
},
});
if (!attachment) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Attachments can not be modified after the document has been completed or rejected',
});
}
return await prisma.envelopeAttachment.update({
where: {
id,
},
data,
});
};

View File

@ -20,6 +20,7 @@ import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-rou
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth'; import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values'; import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import { import {
ZWebhookDocumentSchema, ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload, mapEnvelopeToWebhookDocumentPayload,
@ -58,6 +59,11 @@ export type CreateEnvelopeOptions = {
recipients?: TCreateEnvelopeRequest['recipients']; recipients?: TCreateEnvelopeRequest['recipients'];
folderId?: string; folderId?: string;
}; };
attachments?: Array<{
label: string;
data: string;
type?: TEnvelopeAttachmentType;
}>;
meta?: Partial<Omit<DocumentMeta, 'id'>>; meta?: Partial<Omit<DocumentMeta, 'id'>>;
requestMetadata: ApiRequestMetadata; requestMetadata: ApiRequestMetadata;
}; };
@ -67,6 +73,7 @@ export const createEnvelope = async ({
teamId, teamId,
normalizePdf, normalizePdf,
data, data,
attachments,
meta, meta,
requestMetadata, requestMetadata,
internalVersion, internalVersion,
@ -246,6 +253,15 @@ export const createEnvelope = async ({
})), })),
}, },
}, },
envelopeAttachments: {
createMany: {
data: (attachments || []).map((attachment) => ({
label: attachment.label,
data: attachment.data,
type: attachment.type ?? 'link',
})),
},
},
userId, userId,
teamId, teamId,
authOptions, authOptions,
@ -338,6 +354,7 @@ export const createEnvelope = async ({
fields: true, fields: true,
folder: true, folder: true,
envelopeItems: true, envelopeItems: true,
envelopeAttachments: true,
}, },
}); });

View File

@ -640,6 +640,23 @@ export const createDocumentFromDirectTemplate = async ({
data: auditLogsToCreate, data: auditLogsToCreate,
}); });
const templateAttachments = await tx.envelopeAttachment.findMany({
where: {
envelopeId: directTemplateEnvelope.id,
},
});
if (templateAttachments.length > 0) {
await tx.envelopeAttachment.createMany({
data: templateAttachments.map((attachment) => ({
envelopeId: createdEnvelope.id,
type: attachment.type,
label: attachment.label,
data: attachment.data,
})),
});
}
// Send email to template owner. // Send email to template owner.
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, { const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: directRecipientEmail, recipientName: directRecipientEmail,

View File

@ -91,6 +91,12 @@ export type CreateDocumentFromTemplateOptions = {
envelopeItemId?: string; envelopeItemId?: string;
}[]; }[];
attachments?: Array<{
label: string;
data: string;
type?: 'link';
}>;
/** /**
* Values that will override the predefined values in the template. * Values that will override the predefined values in the template.
*/ */
@ -295,6 +301,7 @@ export const createDocumentFromTemplate = async ({
requestMetadata, requestMetadata,
folderId, folderId,
prefillFields, prefillFields,
attachments,
}: CreateDocumentFromTemplateOptions) => { }: CreateDocumentFromTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({ const { envelopeWhereInput } = await getEnvelopeWhereInput({
id, id,
@ -667,6 +674,33 @@ export const createDocumentFromTemplate = async ({
}), }),
}); });
const templateAttachments = await tx.envelopeAttachment.findMany({
where: {
envelopeId: template.id,
},
});
const attachmentsToCreate = [
...templateAttachments.map((attachment) => ({
envelopeId: envelope.id,
type: attachment.type,
label: attachment.label,
data: attachment.data,
})),
...(attachments || []).map((attachment) => ({
envelopeId: envelope.id,
type: attachment.type || 'link',
label: attachment.label,
data: attachment.data,
})),
];
if (attachmentsToCreate.length > 0) {
await tx.envelopeAttachment.createMany({
data: attachmentsToCreate,
});
}
const createdEnvelope = await tx.envelope.findFirst({ const createdEnvelope = await tx.envelope.findFirst({
where: { where: {
id: envelope.id, id: envelope.id,

View File

@ -0,0 +1,5 @@
import { z } from 'zod';
export const ZEnvelopeAttachmentTypeSchema = z.enum(['link']);
export type TEnvelopeAttachmentType = z.infer<typeof ZEnvelopeAttachmentTypeSchema>;

View File

@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "EnvelopeAttachment" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"label" TEXT NOT NULL,
"data" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"envelopeId" TEXT NOT NULL,
CONSTRAINT "EnvelopeAttachment_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "EnvelopeAttachment" ADD CONSTRAINT "EnvelopeAttachment_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -422,6 +422,8 @@ model Envelope {
documentMetaId String @unique documentMetaId String @unique
documentMeta DocumentMeta @relation(fields: [documentMetaId], references: [id]) documentMeta DocumentMeta @relation(fields: [documentMetaId], references: [id])
envelopeAttachments EnvelopeAttachment[]
} }
model EnvelopeItem { model EnvelopeItem {
@ -508,6 +510,22 @@ model DocumentMeta {
envelope Envelope? envelope Envelope?
} }
/// @zod.import(["import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';"])
model EnvelopeAttachment {
id String @id @default(cuid())
type String /// [EnvelopeAttachmentType] @zod.custom.use(ZEnvelopeAttachmentTypeSchema)
label String
data String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
envelopeId String
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
}
enum ReadStatus { enum ReadStatus {
NOT_OPENED NOT_OPENED
OPENED OPENED

View File

@ -5,6 +5,7 @@ import type {
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email'; import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values'; import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values';
import type { TEnvelopeAttachmentType } from '@documenso/lib/types/envelope-attachment';
import type { TFieldMetaNotOptionalSchema } from '@documenso/lib/types/field-meta'; import type { TFieldMetaNotOptionalSchema } from '@documenso/lib/types/field-meta';
import type { TClaimFlags } from '@documenso/lib/types/subscription'; import type { TClaimFlags } from '@documenso/lib/types/subscription';
@ -23,6 +24,8 @@ declare global {
type RecipientAuthOptions = TRecipientAuthOptions; type RecipientAuthOptions = TRecipientAuthOptions;
type FieldMeta = TFieldMetaNotOptionalSchema; type FieldMeta = TFieldMetaNotOptionalSchema;
type EnvelopeAttachmentType = TEnvelopeAttachmentType;
} }
} }

View File

@ -0,0 +1,50 @@
import { EnvelopeType } from '@prisma/client';
import { createAttachment } from '@documenso/lib/server-only/envelope-attachment/create-attachment';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { authenticatedProcedure } from '../../trpc';
import {
ZCreateAttachmentRequestSchema,
ZCreateAttachmentResponseSchema,
} from './create-attachment.types';
export const createAttachmentRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/attachment/create',
summary: 'Create attachment',
description: 'Create a new attachment for a document',
tags: ['Document'],
},
})
.input(ZCreateAttachmentRequestSchema)
.output(ZCreateAttachmentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const userId = ctx.user.id;
const { documentId, data } = input;
ctx.logger.info({
input: { documentId, label: data.label },
});
const envelope = await getEnvelopeById({
id: {
type: 'documentId',
id: documentId,
},
userId,
teamId,
type: EnvelopeType.DOCUMENT,
});
await createAttachment({
envelopeId: envelope.id,
teamId,
userId,
data,
});
});

View File

@ -0,0 +1,14 @@
import { z } from 'zod';
export const ZCreateAttachmentRequestSchema = z.object({
documentId: z.number(),
data: z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
}),
});
export const ZCreateAttachmentResponseSchema = z.void();
export type TCreateAttachmentRequest = z.infer<typeof ZCreateAttachmentRequestSchema>;
export type TCreateAttachmentResponse = z.infer<typeof ZCreateAttachmentResponseSchema>;

View File

@ -0,0 +1,36 @@
import { deleteAttachment } from '@documenso/lib/server-only/envelope-attachment/delete-attachment';
import { authenticatedProcedure } from '../../trpc';
import {
ZDeleteAttachmentRequestSchema,
ZDeleteAttachmentResponseSchema,
} from './delete-attachment.types';
export const deleteAttachmentRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/attachment/delete',
summary: 'Delete attachment',
description: 'Delete an attachment from a document',
tags: ['Document'],
},
})
.input(ZDeleteAttachmentRequestSchema)
.output(ZDeleteAttachmentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const userId = ctx.user.id;
const { id } = input;
ctx.logger.info({
input: { id },
});
await deleteAttachment({
id,
userId,
teamId,
});
});

View File

@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZDeleteAttachmentRequestSchema = z.object({
id: z.string(),
});
export const ZDeleteAttachmentResponseSchema = z.void();
export type TDeleteAttachmentRequest = z.infer<typeof ZDeleteAttachmentRequestSchema>;
export type TDeleteAttachmentResponse = z.infer<typeof ZDeleteAttachmentResponseSchema>;

View File

@ -0,0 +1,52 @@
import { EnvelopeType } from '@prisma/client';
import { findAttachmentsByEnvelopeId } from '@documenso/lib/server-only/envelope-attachment/find-attachments-by-envelope-id';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { authenticatedProcedure } from '../../trpc';
import {
ZFindAttachmentsRequestSchema,
ZFindAttachmentsResponseSchema,
} from './find-attachments.types';
export const findAttachmentsRoute = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/document/attachment',
summary: 'Find attachments',
description: 'Find all attachments for a document',
tags: ['Document'],
},
})
.input(ZFindAttachmentsRequestSchema)
.output(ZFindAttachmentsResponseSchema)
.query(async ({ input, ctx }) => {
const { documentId } = input;
const { teamId } = ctx;
const userId = ctx.user.id;
ctx.logger.info({
input: { documentId },
});
const envelope = await getEnvelopeById({
id: {
type: 'documentId',
id: documentId,
},
userId,
teamId,
type: EnvelopeType.DOCUMENT,
});
const data = await findAttachmentsByEnvelopeId({
envelopeId: envelope.id,
teamId,
userId,
});
return {
data,
};
});

View File

@ -0,0 +1,21 @@
import { z } from 'zod';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
export const ZFindAttachmentsRequestSchema = z.object({
documentId: z.number(),
});
export const ZFindAttachmentsResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
type: ZEnvelopeAttachmentTypeSchema,
label: z.string(),
data: z.string(),
}),
),
});
export type TFindAttachmentsRequest = z.infer<typeof ZFindAttachmentsRequestSchema>;
export type TFindAttachmentsResponse = z.infer<typeof ZFindAttachmentsResponseSchema>;

View File

@ -0,0 +1,37 @@
import { updateAttachment } from '@documenso/lib/server-only/envelope-attachment/update-attachment';
import { authenticatedProcedure } from '../../trpc';
import {
ZUpdateAttachmentRequestSchema,
ZUpdateAttachmentResponseSchema,
} from './update-attachment.types';
export const updateAttachmentRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/attachment/update',
summary: 'Update attachment',
description: 'Update an existing attachment',
tags: ['Document'],
},
})
.input(ZUpdateAttachmentRequestSchema)
.output(ZUpdateAttachmentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const userId = ctx.user.id;
const { id, data } = input;
ctx.logger.info({
input: { id },
});
await updateAttachment({
id,
userId,
teamId,
data,
});
});

View File

@ -0,0 +1,14 @@
import { z } from 'zod';
export const ZUpdateAttachmentRequestSchema = z.object({
id: z.string(),
data: z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
}),
});
export const ZUpdateAttachmentResponseSchema = z.void();
export type TUpdateAttachmentRequest = z.infer<typeof ZUpdateAttachmentRequestSchema>;
export type TUpdateAttachmentResponse = z.infer<typeof ZUpdateAttachmentResponseSchema>;

View File

@ -37,6 +37,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
recipients, recipients,
meta, meta,
folderId, folderId,
attachments,
} = input; } = input;
const { remaining } = await getServerLimits({ userId: user.id, teamId }); const { remaining } = await getServerLimits({ userId: user.id, teamId });
@ -86,6 +87,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
}, },
], ],
}, },
attachments,
meta: { meta: {
...meta, ...meta,
emailSettings: meta?.emailSettings ?? undefined, emailSettings: meta?.emailSettings ?? undefined,

View File

@ -7,6 +7,7 @@ import {
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values'; import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
import { ZDocumentMetaCreateSchema } from '@documenso/lib/types/document-meta'; import { ZDocumentMetaCreateSchema } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { import {
ZFieldHeightSchema, ZFieldHeightSchema,
ZFieldPageNumberSchema, ZFieldPageNumberSchema,
@ -68,6 +69,15 @@ export const ZCreateDocumentTemporaryRequestSchema = z.object({
}), }),
) )
.optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(), .optional(),
meta: ZDocumentMetaCreateSchema.optional(), meta: ZDocumentMetaCreateSchema.optional(),
}); });

View File

@ -16,7 +16,7 @@ export const createDocumentRoute = authenticatedProcedure
.output(ZCreateDocumentResponseSchema) .output(ZCreateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx; const { user, teamId } = ctx;
const { title, documentDataId, timezone, folderId } = input; const { title, documentDataId, timezone, folderId, attachments } = input;
ctx.logger.info({ ctx.logger.info({
input: { input: {
@ -48,6 +48,7 @@ export const createDocumentRoute = authenticatedProcedure
}, },
], ],
}, },
attachments,
normalizePdf: true, normalizePdf: true,
requestMetadata: ctx.metadata, requestMetadata: ctx.metadata,
}); });

View File

@ -1,6 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta'; import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZDocumentTitleSchema } from './schema'; import { ZDocumentTitleSchema } from './schema';
@ -19,6 +20,15 @@ export const ZCreateDocumentRequestSchema = z.object({
documentDataId: z.string().min(1), documentDataId: z.string().min(1),
timezone: ZDocumentMetaTimezoneSchema.optional(), timezone: ZDocumentMetaTimezoneSchema.optional(),
folderId: z.string().describe('The ID of the folder to create the document in').optional(), folderId: z.string().describe('The ID of the folder to create the document in').optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
}); });
export const ZCreateDocumentResponseSchema = z.object({ export const ZCreateDocumentResponseSchema = z.object({

View File

@ -1,5 +1,9 @@
import { router } from '../trpc'; import { router } from '../trpc';
import { accessAuthRequest2FAEmailRoute } from './access-auth-request-2fa-email'; import { accessAuthRequest2FAEmailRoute } from './access-auth-request-2fa-email';
import { createAttachmentRoute } from './attachment/create-attachment';
import { deleteAttachmentRoute } from './attachment/delete-attachment';
import { findAttachmentsRoute } from './attachment/find-attachments';
import { updateAttachmentRoute } from './attachment/update-attachment';
import { createDocumentRoute } from './create-document'; import { createDocumentRoute } from './create-document';
import { createDocumentTemporaryRoute } from './create-document-temporary'; import { createDocumentTemporaryRoute } from './create-document-temporary';
import { deleteDocumentRoute } from './delete-document'; import { deleteDocumentRoute } from './delete-document';
@ -53,4 +57,10 @@ export const documentRouter = router({
find: findInboxRoute, find: findInboxRoute,
getCount: getInboxCountRoute, getCount: getInboxCountRoute,
}), }),
attachment: {
create: createAttachmentRoute,
update: updateAttachmentRoute,
delete: deleteAttachmentRoute,
find: findAttachmentsRoute,
},
}); });

View File

@ -0,0 +1,37 @@
import { createAttachment } from '@documenso/lib/server-only/envelope-attachment/create-attachment';
import { authenticatedProcedure } from '../../trpc';
import {
ZCreateAttachmentRequestSchema,
ZCreateAttachmentResponseSchema,
} from './create-attachment.types';
export const createAttachmentRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/attachment/create',
summary: 'Create attachment',
description: 'Create a new attachment for an envelope',
tags: ['Envelope'],
},
})
.input(ZCreateAttachmentRequestSchema)
.output(ZCreateAttachmentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const userId = ctx.user.id;
const { envelopeId, data } = input;
ctx.logger.info({
input: { envelopeId, label: data.label },
});
await createAttachment({
envelopeId,
teamId,
userId,
data,
});
});

View File

@ -0,0 +1,14 @@
import { z } from 'zod';
export const ZCreateAttachmentRequestSchema = z.object({
envelopeId: z.string(),
data: z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
}),
});
export const ZCreateAttachmentResponseSchema = z.void();
export type TCreateAttachmentRequest = z.infer<typeof ZCreateAttachmentRequestSchema>;
export type TCreateAttachmentResponse = z.infer<typeof ZCreateAttachmentResponseSchema>;

View File

@ -0,0 +1,36 @@
import { deleteAttachment } from '@documenso/lib/server-only/envelope-attachment/delete-attachment';
import { authenticatedProcedure } from '../../trpc';
import {
ZDeleteAttachmentRequestSchema,
ZDeleteAttachmentResponseSchema,
} from './delete-attachment.types';
export const deleteAttachmentRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/attachment/delete',
summary: 'Delete attachment',
description: 'Delete an attachment from an envelope',
tags: ['Envelope'],
},
})
.input(ZDeleteAttachmentRequestSchema)
.output(ZDeleteAttachmentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const userId = ctx.user.id;
const { id } = input;
ctx.logger.info({
input: { id },
});
await deleteAttachment({
id,
userId,
teamId,
});
});

View File

@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZDeleteAttachmentRequestSchema = z.object({
id: z.string(),
});
export const ZDeleteAttachmentResponseSchema = z.void();
export type TDeleteAttachmentRequest = z.infer<typeof ZDeleteAttachmentRequestSchema>;
export type TDeleteAttachmentResponse = z.infer<typeof ZDeleteAttachmentResponseSchema>;

View File

@ -0,0 +1,52 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { findAttachmentsByEnvelopeId } from '@documenso/lib/server-only/envelope-attachment/find-attachments-by-envelope-id';
import { findAttachmentsByToken } from '@documenso/lib/server-only/envelope-attachment/find-attachments-by-token';
import { procedure } from '../../trpc';
import {
ZFindAttachmentsRequestSchema,
ZFindAttachmentsResponseSchema,
} from './find-attachments.types';
export const findAttachmentsRoute = procedure
.meta({
openapi: {
method: 'GET',
path: '/envelope/attachment',
summary: 'Find attachments',
description: 'Find all attachments for an envelope',
tags: ['Envelope'],
},
})
.input(ZFindAttachmentsRequestSchema)
.output(ZFindAttachmentsResponseSchema)
.query(async ({ input, ctx }) => {
const { envelopeId, token } = input;
ctx.logger.info({
input: { envelopeId },
});
if (token) {
const data = await findAttachmentsByToken({ envelopeId, token });
return {
data,
};
}
const { teamId } = ctx;
const userId = ctx.user?.id;
if (!userId || !teamId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You must be authenticated to access this resource',
});
}
const data = await findAttachmentsByEnvelopeId({ envelopeId, teamId, userId });
return {
data,
};
});

View File

@ -0,0 +1,22 @@
import { z } from 'zod';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
export const ZFindAttachmentsRequestSchema = z.object({
envelopeId: z.string(),
token: z.string().optional(),
});
export const ZFindAttachmentsResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
type: ZEnvelopeAttachmentTypeSchema,
label: z.string(),
data: z.string(),
}),
),
});
export type TFindAttachmentsRequest = z.infer<typeof ZFindAttachmentsRequestSchema>;
export type TFindAttachmentsResponse = z.infer<typeof ZFindAttachmentsResponseSchema>;

View File

@ -0,0 +1,37 @@
import { updateAttachment } from '@documenso/lib/server-only/envelope-attachment/update-attachment';
import { authenticatedProcedure } from '../../trpc';
import {
ZUpdateAttachmentRequestSchema,
ZUpdateAttachmentResponseSchema,
} from './update-attachment.types';
export const updateAttachmentRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/attachment/update',
summary: 'Update attachment',
description: 'Update an existing attachment',
tags: ['Envelope'],
},
})
.input(ZUpdateAttachmentRequestSchema)
.output(ZUpdateAttachmentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const userId = ctx.user.id;
const { id, data } = input;
ctx.logger.info({
input: { id },
});
await updateAttachment({
id,
userId,
teamId,
data,
});
});

View File

@ -0,0 +1,14 @@
import { z } from 'zod';
export const ZUpdateAttachmentRequestSchema = z.object({
id: z.string(),
data: z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
}),
});
export const ZUpdateAttachmentResponseSchema = z.void();
export type TUpdateAttachmentRequest = z.infer<typeof ZUpdateAttachmentRequestSchema>;
export type TUpdateAttachmentResponse = z.infer<typeof ZUpdateAttachmentResponseSchema>;

View File

@ -9,7 +9,7 @@ import {
} from './create-envelope.types'; } from './create-envelope.types';
export const createEnvelopeRoute = authenticatedProcedure export const createEnvelopeRoute = authenticatedProcedure
.input(ZCreateEnvelopeRequestSchema) // Note: Before releasing this to public, update the response schema to be correct. .input(ZCreateEnvelopeRequestSchema)
.output(ZCreateEnvelopeResponseSchema) .output(ZCreateEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx; const { user, teamId } = ctx;
@ -24,6 +24,7 @@ export const createEnvelopeRoute = authenticatedProcedure
folderId, folderId,
items, items,
meta, meta,
attachments,
} = input; } = input;
ctx.logger.info({ ctx.logger.info({
@ -57,6 +58,7 @@ export const createEnvelopeRoute = authenticatedProcedure
folderId, folderId,
envelopeItems: items, envelopeItems: items,
}, },
attachments,
meta, meta,
normalizePdf: true, normalizePdf: true,
requestMetadata: ctx.metadata, requestMetadata: ctx.metadata,

View File

@ -7,6 +7,7 @@ import {
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values'; import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
import { ZDocumentMetaCreateSchema } from '@documenso/lib/types/document-meta'; import { ZDocumentMetaCreateSchema } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { import {
ZFieldHeightSchema, ZFieldHeightSchema,
ZFieldPageNumberSchema, ZFieldPageNumberSchema,
@ -76,6 +77,15 @@ export const ZCreateEnvelopeRequestSchema = z.object({
) )
.optional(), .optional(),
meta: ZDocumentMetaCreateSchema.optional(), meta: ZDocumentMetaCreateSchema.optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
}); });
export const ZCreateEnvelopeResponseSchema = z.object({ export const ZCreateEnvelopeResponseSchema = z.object({

View File

@ -1,4 +1,8 @@
import { router } from '../trpc'; import { router } from '../trpc';
import { createAttachmentRoute } from './attachment/create-attachment';
import { deleteAttachmentRoute } from './attachment/delete-attachment';
import { findAttachmentsRoute } from './attachment/find-attachments';
import { updateAttachmentRoute } from './attachment/update-attachment';
import { createEnvelopeRoute } from './create-envelope'; import { createEnvelopeRoute } from './create-envelope';
import { createEnvelopeItemsRoute } from './create-envelope-items'; import { createEnvelopeItemsRoute } from './create-envelope-items';
import { deleteEnvelopeRoute } from './delete-envelope'; import { deleteEnvelopeRoute } from './delete-envelope';
@ -35,4 +39,10 @@ export const envelopeRouter = router({
set: setEnvelopeFieldsRoute, set: setEnvelopeFieldsRoute,
sign: signEnvelopeFieldRoute, sign: signEnvelopeFieldRoute,
}, },
attachment: {
find: findAttachmentsRoute,
create: createAttachmentRoute,
update: updateAttachmentRoute,
delete: deleteAttachmentRoute,
},
}); });

View File

@ -235,6 +235,7 @@ export const templateRouter = router({
publicDescription, publicDescription,
type, type,
meta, meta,
attachments,
} = input; } = input;
const fileName = title.endsWith('.pdf') ? title : `${title}.pdf`; const fileName = title.endsWith('.pdf') ? title : `${title}.pdf`;
@ -268,6 +269,7 @@ export const templateRouter = router({
publicDescription, publicDescription,
}, },
meta, meta,
attachments,
requestMetadata: ctx.metadata, requestMetadata: ctx.metadata,
}); });

View File

@ -19,6 +19,7 @@ import {
ZDocumentMetaTypedSignatureEnabledSchema, ZDocumentMetaTypedSignatureEnabledSchema,
ZDocumentMetaUploadSignatureEnabledSchema, ZDocumentMetaUploadSignatureEnabledSchema,
} from '@documenso/lib/types/document-meta'; } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta'; import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { import {
@ -197,6 +198,15 @@ export const ZCreateTemplateV2RequestSchema = z.object({
publicDescription: ZTemplatePublicDescriptionSchema.optional(), publicDescription: ZTemplatePublicDescriptionSchema.optional(),
type: z.nativeEnum(TemplateType).optional(), type: z.nativeEnum(TemplateType).optional(),
meta: ZTemplateMetaUpsertSchema.optional(), meta: ZTemplateMetaUpsertSchema.optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
}); });
/** /**