mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
25 Commits
9350c53c7d
...
feat/add-a
| Author | SHA1 | Date | |
|---|---|---|---|
| 191b333e34 | |||
| 2c579c6455 | |||
| 6bd688bde4 | |||
| c0a72123bd | |||
| d710f53fb5 | |||
| 34caad2641 | |||
| 1511d2288c | |||
| e19da93ce2 | |||
| 30b240cba2 | |||
| eb78706f35 | |||
| 52b474d12b | |||
| 0b03bd3fce | |||
| 15d0be17d7 | |||
| 338965325d | |||
| 3b476e9e1f | |||
| 6da56887ee | |||
| cec25ac719 | |||
| d10ec437cf | |||
| dbacfaa841 | |||
| 6980db57d3 | |||
| e3f8e76e6a | |||
| 396a7db587 | |||
| 7ac48cb3f5 | |||
| f7ee4d0ba2 | |||
| 1b67be9099 |
@ -0,0 +1,63 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { LinkIcon } from 'lucide-react';
|
||||
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
export type DocumentSigningAttachmentsDialogProps = {
|
||||
document: DocumentAndSender;
|
||||
};
|
||||
|
||||
export const DocumentSigningAttachmentsDialog = ({
|
||||
document,
|
||||
}: DocumentSigningAttachmentsDialogProps) => {
|
||||
const attachments = document.attachments ?? [];
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Trans>Attachments</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Attachments</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>View all attachments for this document.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{attachments.length === 0 && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
<Trans>No attachments available.</Trans>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{attachments.map((attachment, idx) => (
|
||||
<a
|
||||
key={attachment.id || idx}
|
||||
href={attachment.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:bg-muted/50 flex items-center gap-2 rounded px-2 py-1"
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span className="truncate">{attachment.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -24,6 +24,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
|
||||
import { DocumentSigningAttachmentsDialog } from '~/components/general/document-signing/document-signing-attachments-dialog';
|
||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
||||
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
|
||||
@ -85,7 +86,7 @@ export const DocumentSigningPageView = ({
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
||||
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6 gap-y-4">
|
||||
<div className="max-w-[50ch]">
|
||||
<span className="text-muted-foreground truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
@ -132,7 +133,10 @@ export const DocumentSigningPageView = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DocumentSigningRejectDialog document={document} token={recipient.token} />
|
||||
<div className="flex gap-2">
|
||||
<DocumentSigningAttachmentsDialog document={document} />
|
||||
<DocumentSigningRejectDialog document={document} token={recipient.token} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||
|
||||
@ -0,0 +1,192 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Trash } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { AttachmentType } from '@documenso/prisma/generated/types';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TSetDocumentAttachmentsSchema } from '@documenso/trpc/server/document-router/set-document-attachments.types';
|
||||
import { ZSetDocumentAttachmentsSchema } from '@documenso/trpc/server/document-router/set-document-attachments.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AttachmentFormProps = {
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const AttachmentForm = ({ documentId }: AttachmentFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: attachmentsData, refetch: refetchAttachments } =
|
||||
trpc.document.attachments.find.useQuery({
|
||||
documentId,
|
||||
});
|
||||
|
||||
const { mutateAsync: setDocumentAttachments } = trpc.document.attachments.set.useMutation();
|
||||
|
||||
const defaultAttachments = [
|
||||
{
|
||||
id: nanoid(12),
|
||||
label: '',
|
||||
url: '',
|
||||
type: AttachmentType.LINK,
|
||||
},
|
||||
];
|
||||
|
||||
const form = useForm<TSetDocumentAttachmentsSchema>({
|
||||
resolver: zodResolver(ZSetDocumentAttachmentsSchema),
|
||||
defaultValues: {
|
||||
documentId,
|
||||
attachments: attachmentsData ?? defaultAttachments,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
fields: attachments,
|
||||
append: appendAttachment,
|
||||
remove: removeAttachment,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'attachments',
|
||||
});
|
||||
|
||||
const onAddAttachment = () => {
|
||||
appendAttachment({
|
||||
id: nanoid(12),
|
||||
label: '',
|
||||
url: '',
|
||||
type: AttachmentType.LINK,
|
||||
});
|
||||
};
|
||||
|
||||
const onRemoveAttachment = (index: number) => {
|
||||
removeAttachment(index);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (attachmentsData && attachmentsData.length > 0) {
|
||||
form.setValue('attachments', attachmentsData);
|
||||
}
|
||||
}, [attachmentsData]);
|
||||
|
||||
const onSubmit = async (data: TSetDocumentAttachmentsSchema) => {
|
||||
try {
|
||||
await setDocumentAttachments({
|
||||
documentId,
|
||||
attachments: data.attachments,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Attachment(s) updated`,
|
||||
description: t`The attachment(s) have been updated successfully`,
|
||||
});
|
||||
|
||||
await refetchAttachments();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`We encountered an unknown error while attempting to create the attachments.`,
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Trans>Attachments</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Attachments</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||
{attachments.map((attachment, index) => (
|
||||
<div key={attachment.id} className="flex items-end gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`attachments.${index}.label`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>
|
||||
<Trans>Label</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={t`Attachment label`} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`attachments.${index}.url`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>
|
||||
<Trans>URL</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://..." />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemoveAttachment(index)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="button" variant="outline" onClick={onAddAttachment}>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,192 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Trash } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { AttachmentType } from '@documenso/prisma/generated/types';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TSetTemplateAttachmentsSchema } from '@documenso/trpc/server/template-router/set-template-attachments.types';
|
||||
import { ZSetTemplateAttachmentsSchema } from '@documenso/trpc/server/template-router/set-template-attachments.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AttachmentFormProps = {
|
||||
templateId: number;
|
||||
};
|
||||
|
||||
export const AttachmentForm = ({ templateId }: AttachmentFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const { data: attachmentsData, refetch: refetchAttachments } =
|
||||
trpc.template.attachments.find.useQuery({
|
||||
templateId,
|
||||
});
|
||||
|
||||
const { mutateAsync: setTemplateAttachments } = trpc.template.attachments.set.useMutation();
|
||||
|
||||
const defaultAttachments = [
|
||||
{
|
||||
id: nanoid(12),
|
||||
label: '',
|
||||
url: '',
|
||||
type: AttachmentType.LINK,
|
||||
},
|
||||
];
|
||||
|
||||
const form = useForm<TSetTemplateAttachmentsSchema>({
|
||||
resolver: zodResolver(ZSetTemplateAttachmentsSchema),
|
||||
defaultValues: {
|
||||
templateId,
|
||||
attachments: attachmentsData ?? defaultAttachments,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
fields: attachments,
|
||||
append: appendAttachment,
|
||||
remove: removeAttachment,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'attachments',
|
||||
});
|
||||
|
||||
const onAddAttachment = () => {
|
||||
appendAttachment({
|
||||
id: nanoid(12),
|
||||
label: '',
|
||||
url: '',
|
||||
type: AttachmentType.LINK,
|
||||
});
|
||||
};
|
||||
|
||||
const onRemoveAttachment = (index: number) => {
|
||||
removeAttachment(index);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (attachmentsData && attachmentsData.length > 0) {
|
||||
form.setValue('attachments', attachmentsData);
|
||||
}
|
||||
}, [attachmentsData]);
|
||||
|
||||
const onSubmit = async (data: TSetTemplateAttachmentsSchema) => {
|
||||
try {
|
||||
await setTemplateAttachments({
|
||||
templateId,
|
||||
attachments: data.attachments,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Attachment(s) updated`,
|
||||
description: t`The attachment(s) have been updated successfully`,
|
||||
});
|
||||
|
||||
await refetchAttachments();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`We encountered an unknown error while attempting to create the attachments.`,
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Trans>Attachments</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Attachments</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||
{attachments.map((attachment, index) => (
|
||||
<div key={attachment.id} className="flex items-end gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`attachments.${index}.label`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>
|
||||
<Trans>Label</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={t`Attachment label`} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`attachments.${index}.url`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>
|
||||
<Trans>URL</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={t`https://...`} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemoveAttachment(index)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="button" variant="outline" onClick={onAddAttachment}>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -46,7 +46,9 @@ export const TemplatesTableActionDropdown = ({
|
||||
const isOwner = row.userId === user.id;
|
||||
const isTeamTemplate = row.teamId === teamId;
|
||||
|
||||
const formatPath = `${templateRootPath}/${row.id}/edit`;
|
||||
const formatPath = row.folderId
|
||||
? `${templateRootPath}/f/${row.folderId}/${row.id}/edit`
|
||||
: `${templateRootPath}/${row.id}/edit`;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
||||
@ -11,6 +11,7 @@ import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { AttachmentForm } from '~/components/general/document/document-attachment-form';
|
||||
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
||||
@ -99,7 +100,7 @@ export default function DocumentEditPage() {
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
<div className="mt-4 flex w-full items-end justify-between">
|
||||
<div className="mt-4 flex w-full flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div className="flex-1">
|
||||
<h1
|
||||
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
@ -133,11 +134,12 @@ export default function DocumentEditPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{document.useLegacyFieldInsertion && (
|
||||
<div>
|
||||
<div className={document.useLegacyFieldInsertion ? 'flex items-center gap-2' : undefined}>
|
||||
{document.useLegacyFieldInsertion && (
|
||||
<LegacyFieldWarningPopover type="document" documentId={document.id} />
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<AttachmentForm documentId={document.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentEditForm
|
||||
|
||||
@ -9,6 +9,7 @@ import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
|
||||
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
||||
import { AttachmentForm } from '~/components/general/template/template-attachment-form';
|
||||
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
|
||||
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
|
||||
import { TemplateType } from '~/components/general/template/template-type';
|
||||
@ -89,6 +90,7 @@ export default function TemplateEditPage() {
|
||||
|
||||
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
|
||||
<TemplateDirectLinkDialogWrapper template={template} />
|
||||
<AttachmentForm templateId={template.id} />
|
||||
|
||||
{template.useLegacyFieldInsertion && (
|
||||
<div>
|
||||
|
||||
@ -81,6 +81,7 @@ export const getDocumentAndSenderByToken = async ({
|
||||
token,
|
||||
},
|
||||
},
|
||||
attachments: true,
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
|
||||
@ -287,6 +287,7 @@ export const createDocumentFromTemplate = async ({
|
||||
fields: true,
|
||||
},
|
||||
},
|
||||
attachments: true,
|
||||
templateDocumentData: true,
|
||||
templateMeta: true,
|
||||
},
|
||||
@ -377,6 +378,15 @@ export const createDocumentFromTemplate = async ({
|
||||
}),
|
||||
visibility: template.visibility || settings.documentVisibility,
|
||||
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
|
||||
attachments: {
|
||||
create: template.attachments.map((attachment) => ({
|
||||
type: attachment.type,
|
||||
label: attachment.label,
|
||||
url: attachment.url,
|
||||
createdAt: attachment.createdAt,
|
||||
updatedAt: attachment.updatedAt,
|
||||
})),
|
||||
},
|
||||
documentMeta: {
|
||||
create: extractDerivedDocumentMeta(settings, {
|
||||
subject: override?.subject || template.templateMeta?.subject,
|
||||
|
||||
5
packages/lib/translations/.gitignore
vendored
5
packages/lib/translations/.gitignore
vendored
@ -1,5 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Compiled translations.
|
||||
*.js
|
||||
*.mjs
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -40,6 +40,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
||||
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
|
||||
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
|
||||
'DOCUMENT_ATTACHMENTS_UPDATED', // When the document attachments are updated.
|
||||
]);
|
||||
|
||||
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
||||
@ -598,6 +599,29 @@ export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document attachments updated.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentAttachmentsUpdatedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ATTACHMENTS_UPDATED),
|
||||
data: z.object({
|
||||
from: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
),
|
||||
to: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
@ -630,6 +654,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventDocumentSentSchema,
|
||||
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
|
||||
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,
|
||||
ZDocumentAuditLogEventDocumentAttachmentsUpdatedSchema,
|
||||
ZDocumentAuditLogEventFieldCreatedSchema,
|
||||
ZDocumentAuditLogEventFieldRemovedSchema,
|
||||
ZDocumentAuditLogEventFieldUpdatedSchema,
|
||||
|
||||
@ -423,6 +423,10 @@ export const formatDocumentAuditLogAction = (
|
||||
anonymous: msg`Document completed`,
|
||||
identified: msg`Document completed`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ATTACHMENTS_UPDATED }, () => ({
|
||||
anonymous: msg`Document attachments updated`,
|
||||
identified: msg`${prefix} updated the document attachments`,
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
return {
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AttachmentType" AS ENUM ('FILE', 'VIDEO', 'AUDIO', 'IMAGE', 'LINK', 'OTHER');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Attachment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "AttachmentType" NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"documentId" INTEGER,
|
||||
"templateId" INTEGER,
|
||||
|
||||
CONSTRAINT "Attachment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Attachment" ALTER COLUMN "type" SET DEFAULT 'LINK';
|
||||
@ -333,6 +333,32 @@ enum DocumentVisibility {
|
||||
ADMIN
|
||||
}
|
||||
|
||||
// Only "LINK" is supported for now.
|
||||
// All other attachment types are not yet supported.
|
||||
enum AttachmentType {
|
||||
FILE
|
||||
VIDEO
|
||||
AUDIO
|
||||
IMAGE
|
||||
LINK
|
||||
OTHER
|
||||
}
|
||||
|
||||
model Attachment {
|
||||
id String @id @default(uuid())
|
||||
type AttachmentType @default(LINK)
|
||||
label String
|
||||
url String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
documentId Int?
|
||||
document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
|
||||
templateId Int?
|
||||
template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum FolderType {
|
||||
DOCUMENT
|
||||
TEMPLATE
|
||||
@ -391,14 +417,15 @@ model Document {
|
||||
templateId Int?
|
||||
source DocumentSource
|
||||
|
||||
useLegacyFieldInsertion Boolean @default(false)
|
||||
auditLogs DocumentAuditLog[]
|
||||
attachments Attachment[]
|
||||
useLegacyFieldInsertion Boolean @default(false)
|
||||
|
||||
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
|
||||
template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull)
|
||||
|
||||
auditLogs DocumentAuditLog[]
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId String?
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
||||
folderId String?
|
||||
|
||||
@@unique([documentDataId])
|
||||
@@index([userId])
|
||||
@ -892,10 +919,11 @@ model Template {
|
||||
|
||||
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
|
||||
|
||||
recipients Recipient[]
|
||||
fields Field[]
|
||||
directLink TemplateDirectLink?
|
||||
documents Document[]
|
||||
recipients Recipient[]
|
||||
fields Field[]
|
||||
directLink TemplateDirectLink?
|
||||
documents Document[]
|
||||
attachments Attachment[]
|
||||
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId String?
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZGetDocumentAttachmentsResponseSchema,
|
||||
ZGetDocumentAttachmentsSchema,
|
||||
} from './find-document-attachments.types';
|
||||
|
||||
export const findDocumentAttachmentsRoute = authenticatedProcedure
|
||||
.input(ZGetDocumentAttachmentsSchema)
|
||||
.output(ZGetDocumentAttachmentsResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { documentId } = input;
|
||||
const { user } = ctx;
|
||||
|
||||
const attachments = await findDocumentAttachments({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: ctx.teamId,
|
||||
});
|
||||
|
||||
return attachments;
|
||||
});
|
||||
|
||||
export type FindDocumentAttachmentsOptions = {
|
||||
documentId?: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const findDocumentAttachments = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
}: FindDocumentAttachmentsOptions) => {
|
||||
const attachments = await prisma.attachment.findMany({
|
||||
where: {
|
||||
document: {
|
||||
id: documentId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return attachments;
|
||||
};
|
||||
@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AttachmentType } from '@documenso/prisma/generated/types';
|
||||
|
||||
export const ZGetDocumentAttachmentsSchema = z.object({
|
||||
documentId: z.number(),
|
||||
});
|
||||
|
||||
export const ZGetDocumentAttachmentsResponseSchema = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
url: z.string(),
|
||||
type: z.nativeEnum(AttachmentType),
|
||||
}),
|
||||
);
|
||||
@ -27,6 +27,7 @@ import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-action
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import { findDocumentAttachmentsRoute } from './find-document-attachments';
|
||||
import { findInboxRoute } from './find-inbox';
|
||||
import { getInboxCountRoute } from './get-inbox-count';
|
||||
import {
|
||||
@ -55,6 +56,7 @@ import {
|
||||
ZSetSigningOrderForDocumentMutationSchema,
|
||||
ZSuccessResponseSchema,
|
||||
} from './schema';
|
||||
import { setDocumentAttachmentsRoute } from './set-document-attachments';
|
||||
import { updateDocumentRoute } from './update-document';
|
||||
|
||||
export const documentRouter = router({
|
||||
@ -63,6 +65,10 @@ export const documentRouter = router({
|
||||
getCount: getInboxCountRoute,
|
||||
},
|
||||
updateDocument: updateDocumentRoute,
|
||||
attachments: {
|
||||
find: findDocumentAttachmentsRoute,
|
||||
set: setDocumentAttachmentsRoute,
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
|
||||
124
packages/trpc/server/document-router/set-document-attachments.ts
Normal file
124
packages/trpc/server/document-router/set-document-attachments.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import type { Attachment, User } from '@prisma/client';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZSetDocumentAttachmentsResponseSchema,
|
||||
ZSetDocumentAttachmentsSchema,
|
||||
} from './set-document-attachments.types';
|
||||
|
||||
export const setDocumentAttachmentsRoute = authenticatedProcedure
|
||||
.input(ZSetDocumentAttachmentsSchema)
|
||||
.output(ZSetDocumentAttachmentsResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { documentId, attachments } = input;
|
||||
|
||||
const updatedAttachments = await setDocumentAttachments({
|
||||
documentId,
|
||||
attachments,
|
||||
user: ctx.user,
|
||||
teamId: ctx.teamId,
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
|
||||
return updatedAttachments;
|
||||
});
|
||||
|
||||
export type CreateAttachmentsOptions = {
|
||||
documentId: number;
|
||||
attachments: Pick<Attachment, 'id' | 'label' | 'url' | 'type'>[];
|
||||
user: Pick<User, 'id' | 'email' | 'name'>;
|
||||
teamId: number;
|
||||
requestMetadata: RequestMetadata;
|
||||
};
|
||||
|
||||
export const setDocumentAttachments = async ({
|
||||
documentId,
|
||||
attachments,
|
||||
user,
|
||||
teamId,
|
||||
requestMetadata,
|
||||
}: CreateAttachmentsOptions) => {
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: documentId,
|
||||
team: buildTeamWhereQuery({ teamId, userId: user.id }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const existingAttachments = await prisma.attachment.findMany({
|
||||
where: {
|
||||
documentId,
|
||||
},
|
||||
});
|
||||
|
||||
const newIds = attachments.map((a) => a.id).filter(Boolean);
|
||||
const toDelete = existingAttachments.filter((existing) => !newIds.includes(existing.id));
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
await prisma.attachment.deleteMany({
|
||||
where: {
|
||||
id: { in: toDelete.map((a) => a.id) },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const upsertedAttachments: Attachment[] = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const updated = await prisma.attachment.upsert({
|
||||
where: { id: attachment.id, documentId: document.id },
|
||||
update: {
|
||||
label: attachment.label,
|
||||
url: attachment.url,
|
||||
type: attachment.type,
|
||||
},
|
||||
create: {
|
||||
label: attachment.label,
|
||||
url: attachment.url,
|
||||
type: attachment.type,
|
||||
documentId,
|
||||
},
|
||||
});
|
||||
upsertedAttachments.push(updated);
|
||||
}
|
||||
|
||||
const isAttachmentsSame = upsertedAttachments.every((attachment) => {
|
||||
const existingAttachment = existingAttachments.find((a) => a.id === attachment.id);
|
||||
return (
|
||||
existingAttachment?.label === attachment.label &&
|
||||
existingAttachment?.url === attachment.url &&
|
||||
existingAttachment?.type === attachment.type
|
||||
);
|
||||
});
|
||||
|
||||
if (!isAttachmentsSame) {
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ATTACHMENTS_UPDATED,
|
||||
documentId: document.id,
|
||||
user,
|
||||
data: {
|
||||
from: existingAttachments,
|
||||
to: upsertedAttachments,
|
||||
},
|
||||
requestMetadata,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return upsertedAttachments;
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AttachmentType } from '@documenso/prisma/generated/types';
|
||||
|
||||
export const ZSetDocumentAttachmentsSchema = z.object({
|
||||
documentId: z.number(),
|
||||
attachments: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
url: z.string().url('Invalid URL'),
|
||||
type: z.nativeEnum(AttachmentType),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TSetDocumentAttachmentsSchema = z.infer<typeof ZSetDocumentAttachmentsSchema>;
|
||||
|
||||
export const ZSetDocumentAttachmentsResponseSchema = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
url: z.string(),
|
||||
type: z.nativeEnum(AttachmentType),
|
||||
}),
|
||||
);
|
||||
@ -0,0 +1,46 @@
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZGetTemplateAttachmentsResponseSchema,
|
||||
ZGetTemplateAttachmentsSchema,
|
||||
} from './find-template-attachments.types';
|
||||
|
||||
export const findTemplateAttachmentsRoute = authenticatedProcedure
|
||||
.input(ZGetTemplateAttachmentsSchema)
|
||||
.output(ZGetTemplateAttachmentsResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { templateId } = input;
|
||||
|
||||
const attachments = await findTemplateAttachments({
|
||||
templateId,
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
});
|
||||
|
||||
return attachments;
|
||||
});
|
||||
|
||||
export type FindTemplateAttachmentsOptions = {
|
||||
templateId: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const findTemplateAttachments = async ({
|
||||
templateId,
|
||||
userId,
|
||||
teamId,
|
||||
}: FindTemplateAttachmentsOptions) => {
|
||||
const attachments = await prisma.attachment.findMany({
|
||||
where: {
|
||||
template: {
|
||||
id: templateId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return attachments;
|
||||
};
|
||||
@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AttachmentType } from '@documenso/prisma/generated/types';
|
||||
|
||||
export const ZGetTemplateAttachmentsSchema = z.object({
|
||||
templateId: z.number(),
|
||||
});
|
||||
|
||||
export const ZGetTemplateAttachmentsResponseSchema = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
url: z.string(),
|
||||
type: z.nativeEnum(AttachmentType),
|
||||
}),
|
||||
);
|
||||
@ -29,6 +29,7 @@ import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-action
|
||||
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
|
||||
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
|
||||
import { findTemplateAttachmentsRoute } from './find-template-attachments';
|
||||
import {
|
||||
ZBulkSendTemplateMutationSchema,
|
||||
ZCreateDocumentFromDirectTemplateRequestSchema,
|
||||
@ -52,8 +53,14 @@ import {
|
||||
ZUpdateTemplateRequestSchema,
|
||||
ZUpdateTemplateResponseSchema,
|
||||
} from './schema';
|
||||
import { setTemplateAttachmentsRoute } from './set-template-attachments';
|
||||
|
||||
export const templateRouter = router({
|
||||
attachments: {
|
||||
find: findTemplateAttachmentsRoute,
|
||||
set: setTemplateAttachmentsRoute,
|
||||
},
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
import type { Attachment } from '@prisma/client';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZSetTemplateAttachmentsResponseSchema,
|
||||
ZSetTemplateAttachmentsSchema,
|
||||
} from './set-template-attachments.types';
|
||||
|
||||
export const setTemplateAttachmentsRoute = authenticatedProcedure
|
||||
.input(ZSetTemplateAttachmentsSchema)
|
||||
.output(ZSetTemplateAttachmentsResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { templateId, attachments } = input;
|
||||
|
||||
const updatedAttachments = await setTemplateAttachments({
|
||||
templateId,
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
attachments,
|
||||
});
|
||||
|
||||
return updatedAttachments;
|
||||
});
|
||||
|
||||
export type CreateAttachmentsOptions = {
|
||||
templateId: number;
|
||||
attachments: Pick<Attachment, 'id' | 'label' | 'url' | 'type'>[];
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const setTemplateAttachments = async ({
|
||||
templateId,
|
||||
attachments,
|
||||
userId,
|
||||
teamId,
|
||||
}: CreateAttachmentsOptions) => {
|
||||
const template = await prisma.template.findUnique({
|
||||
where: {
|
||||
id: templateId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Template not found',
|
||||
});
|
||||
}
|
||||
|
||||
const existingAttachments = await prisma.attachment.findMany({
|
||||
where: {
|
||||
templateId,
|
||||
},
|
||||
});
|
||||
|
||||
const newIds = attachments.map((a) => a.id).filter(Boolean);
|
||||
const toDelete = existingAttachments.filter((existing) => !newIds.includes(existing.id));
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
await prisma.attachment.deleteMany({
|
||||
where: {
|
||||
id: { in: toDelete.map((a) => a.id) },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const upsertedAttachments: Attachment[] = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const updated = await prisma.attachment.upsert({
|
||||
where: { id: attachment.id, templateId: template.id },
|
||||
update: {
|
||||
label: attachment.label,
|
||||
url: attachment.url,
|
||||
type: attachment.type,
|
||||
templateId,
|
||||
},
|
||||
create: {
|
||||
label: attachment.label,
|
||||
url: attachment.url,
|
||||
type: attachment.type,
|
||||
templateId,
|
||||
},
|
||||
});
|
||||
upsertedAttachments.push(updated);
|
||||
}
|
||||
|
||||
return upsertedAttachments;
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AttachmentType } from '@documenso/prisma/generated/types';
|
||||
|
||||
export const ZSetTemplateAttachmentsSchema = z.object({
|
||||
templateId: z.number(),
|
||||
attachments: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
url: z.string().url('Invalid URL'),
|
||||
type: z.nativeEnum(AttachmentType),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TSetTemplateAttachmentsSchema = z.infer<typeof ZSetTemplateAttachmentsSchema>;
|
||||
|
||||
export const ZSetTemplateAttachmentsResponseSchema = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
url: z.string(),
|
||||
type: z.nativeEnum(AttachmentType),
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user