mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Compare commits
4 Commits
v2.0.10
...
feat/add-a
| Author | SHA1 | Date | |
|---|---|---|---|
| 01ff304e6e | |||
| 90e40e4368 | |||
| 2c89b0805a | |||
| f53fad8cd7 |
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 { DocumentSigningAttachmentsDialog } from '~/components/general/document-signing/document-signing-attachments-dialog';
|
||||||
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,10 @@ export const DocumentSigningPageView = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentSigningRejectDialog document={document} token={recipient.token} />
|
<div className="flex gap-2">
|
||||||
|
<DocumentSigningAttachmentsDialog document={document} />
|
||||||
|
<DocumentSigningRejectDialog document={document} 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">
|
||||||
|
|||||||
@ -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 isOwner = row.userId === user.id;
|
||||||
const isTeamTemplate = row.teamId === teamId;
|
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 (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|||||||
import { logDocumentAccess } from '@documenso/lib/utils/logger';
|
import { logDocumentAccess } from '@documenso/lib/utils/logger';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
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 { 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';
|
||||||
@ -106,7 +107,7 @@ export default function DocumentEditPage() {
|
|||||||
<Trans>Documents</Trans>
|
<Trans>Documents</Trans>
|
||||||
</Link>
|
</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">
|
<div className="flex-1">
|
||||||
<h1
|
<h1
|
||||||
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
@ -140,11 +141,12 @@ export default function DocumentEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{document.useLegacyFieldInsertion && (
|
<div className={document.useLegacyFieldInsertion ? 'flex items-center gap-2' : undefined}>
|
||||||
<div>
|
{document.useLegacyFieldInsertion && (
|
||||||
<LegacyFieldWarningPopover type="document" documentId={document.id} />
|
<LegacyFieldWarningPopover type="document" documentId={document.id} />
|
||||||
</div>
|
)}
|
||||||
)}
|
<AttachmentForm documentId={document.id} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentEditForm
|
<DocumentEditForm
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
|||||||
|
|
||||||
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
|
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
|
||||||
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
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 { 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';
|
||||||
import { TemplateType } from '~/components/general/template/template-type';
|
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">
|
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
|
||||||
<TemplateDirectLinkDialogWrapper template={template} />
|
<TemplateDirectLinkDialogWrapper template={template} />
|
||||||
|
<AttachmentForm templateId={template.id} />
|
||||||
|
|
||||||
{template.useLegacyFieldInsertion && (
|
{template.useLegacyFieldInsertion && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -87,6 +87,7 @@ export const getDocumentAndSenderByToken = async ({
|
|||||||
token,
|
token,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
attachments: true,
|
||||||
team: {
|
team: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
|
|||||||
@ -290,6 +290,7 @@ export const createDocumentFromTemplate = async ({
|
|||||||
fields: true,
|
fields: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
attachments: true,
|
||||||
templateDocumentData: true,
|
templateDocumentData: true,
|
||||||
templateMeta: true,
|
templateMeta: true,
|
||||||
},
|
},
|
||||||
@ -398,6 +399,15 @@ export const createDocumentFromTemplate = async ({
|
|||||||
}),
|
}),
|
||||||
visibility: template.visibility || settings.documentVisibility,
|
visibility: template.visibility || settings.documentVisibility,
|
||||||
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
|
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: {
|
documentMeta: {
|
||||||
create: extractDerivedDocumentMeta(settings, {
|
create: extractDerivedDocumentMeta(settings, {
|
||||||
subject: override?.subject || template.templateMeta?.subject,
|
subject: override?.subject || template.templateMeta?.subject,
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
|||||||
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
||||||
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID 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_MOVED_TO_TEAM', // When the document is moved to a team.
|
||||||
|
'DOCUMENT_ATTACHMENTS_UPDATED', // When the document attachments are updated.
|
||||||
|
|
||||||
// ACCESS AUTH 2FA events.
|
// ACCESS AUTH 2FA events.
|
||||||
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
|
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
|
||||||
@ -639,6 +640,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({
|
export const ZDocumentAuditLogBaseSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
@ -674,6 +698,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
|||||||
ZDocumentAuditLogEventDocumentSentSchema,
|
ZDocumentAuditLogEventDocumentSentSchema,
|
||||||
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
|
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
|
||||||
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,
|
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentAttachmentsUpdatedSchema,
|
||||||
ZDocumentAuditLogEventFieldCreatedSchema,
|
ZDocumentAuditLogEventFieldCreatedSchema,
|
||||||
ZDocumentAuditLogEventFieldRemovedSchema,
|
ZDocumentAuditLogEventFieldRemovedSchema,
|
||||||
ZDocumentAuditLogEventFieldUpdatedSchema,
|
ZDocumentAuditLogEventFieldUpdatedSchema,
|
||||||
|
|||||||
@ -522,6 +522,10 @@ export const formatDocumentAuditLogAction = (
|
|||||||
context: `Audit log format`,
|
context: `Audit log format`,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ATTACHMENTS_UPDATED }, () => ({
|
||||||
|
anonymous: msg`Document attachments updated`,
|
||||||
|
identified: msg`${prefix} updated the document attachments`,
|
||||||
|
}))
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AttachmentType" AS ENUM ('FILE', 'VIDEO', 'AUDIO', 'IMAGE', 'LINK', 'OTHER');
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Document" DROP CONSTRAINT "Document_folderId_fkey";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Attachment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"type" "AttachmentType" NOT NULL DEFAULT 'LINK',
|
||||||
|
"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;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Document" ADD CONSTRAINT "Document_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -339,6 +339,32 @@ enum DocumentVisibility {
|
|||||||
ADMIN
|
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 {
|
enum FolderType {
|
||||||
DOCUMENT
|
DOCUMENT
|
||||||
TEMPLATE
|
TEMPLATE
|
||||||
@ -397,14 +423,15 @@ model Document {
|
|||||||
templateId Int?
|
templateId Int?
|
||||||
source DocumentSource
|
source DocumentSource
|
||||||
|
|
||||||
useLegacyFieldInsertion Boolean @default(false)
|
auditLogs DocumentAuditLog[]
|
||||||
|
attachments Attachment[]
|
||||||
|
useLegacyFieldInsertion Boolean @default(false)
|
||||||
|
|
||||||
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
|
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
|
||||||
template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull)
|
template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
auditLogs DocumentAuditLog[]
|
folder Folder? @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
||||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
folderId String?
|
||||||
folderId String?
|
|
||||||
|
|
||||||
@@unique([documentDataId])
|
@@unique([documentDataId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@ -900,10 +927,11 @@ model Template {
|
|||||||
|
|
||||||
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
|
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
recipients Recipient[]
|
recipients Recipient[]
|
||||||
fields Field[]
|
fields Field[]
|
||||||
directLink TemplateDirectLink?
|
directLink TemplateDirectLink?
|
||||||
documents Document[]
|
documents Document[]
|
||||||
|
attachments Attachment[]
|
||||||
|
|
||||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||||
folderId String?
|
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),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@ -8,6 +8,7 @@ import { downloadDocumentRoute } from './download-document';
|
|||||||
import { downloadDocumentAuditLogsRoute } from './download-document-audit-logs';
|
import { downloadDocumentAuditLogsRoute } from './download-document-audit-logs';
|
||||||
import { downloadDocumentCertificateRoute } from './download-document-certificate';
|
import { downloadDocumentCertificateRoute } from './download-document-certificate';
|
||||||
import { duplicateDocumentRoute } from './duplicate-document';
|
import { duplicateDocumentRoute } from './duplicate-document';
|
||||||
|
import { findDocumentAttachmentsRoute } from './find-document-attachments';
|
||||||
import { findDocumentAuditLogsRoute } from './find-document-audit-logs';
|
import { findDocumentAuditLogsRoute } from './find-document-audit-logs';
|
||||||
import { findDocumentsRoute } from './find-documents';
|
import { findDocumentsRoute } from './find-documents';
|
||||||
import { findDocumentsInternalRoute } from './find-documents-internal';
|
import { findDocumentsInternalRoute } from './find-documents-internal';
|
||||||
@ -17,6 +18,7 @@ import { getDocumentByTokenRoute } from './get-document-by-token';
|
|||||||
import { getInboxCountRoute } from './get-inbox-count';
|
import { getInboxCountRoute } from './get-inbox-count';
|
||||||
import { redistributeDocumentRoute } from './redistribute-document';
|
import { redistributeDocumentRoute } from './redistribute-document';
|
||||||
import { searchDocumentRoute } from './search-document';
|
import { searchDocumentRoute } from './search-document';
|
||||||
|
import { setDocumentAttachmentsRoute } from './set-document-attachments';
|
||||||
import { updateDocumentRoute } from './update-document';
|
import { updateDocumentRoute } from './update-document';
|
||||||
|
|
||||||
export const documentRouter = router({
|
export const documentRouter = router({
|
||||||
@ -51,4 +53,8 @@ export const documentRouter = router({
|
|||||||
find: findInboxRoute,
|
find: findInboxRoute,
|
||||||
getCount: getInboxCountRoute,
|
getCount: getInboxCountRoute,
|
||||||
}),
|
}),
|
||||||
|
attachments: {
|
||||||
|
find: findDocumentAttachmentsRoute,
|
||||||
|
set: setDocumentAttachmentsRoute,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
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),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@ -28,6 +28,7 @@ import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-action
|
|||||||
|
|
||||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
|
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
|
||||||
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
|
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
|
||||||
|
import { findTemplateAttachmentsRoute } from './find-template-attachments';
|
||||||
import {
|
import {
|
||||||
ZBulkSendTemplateMutationSchema,
|
ZBulkSendTemplateMutationSchema,
|
||||||
ZCreateDocumentFromDirectTemplateRequestSchema,
|
ZCreateDocumentFromDirectTemplateRequestSchema,
|
||||||
@ -51,8 +52,14 @@ import {
|
|||||||
ZUpdateTemplateRequestSchema,
|
ZUpdateTemplateRequestSchema,
|
||||||
ZUpdateTemplateResponseSchema,
|
ZUpdateTemplateResponseSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
import { setTemplateAttachmentsRoute } from './set-template-attachments';
|
||||||
|
|
||||||
export const templateRouter = router({
|
export const templateRouter = router({
|
||||||
|
attachments: {
|
||||||
|
find: findTemplateAttachmentsRoute,
|
||||||
|
set: setTemplateAttachmentsRoute,
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @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),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
|
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||||
|
|||||||
@ -216,7 +216,12 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input className="bg-background" {...field} maxLength={255} onBlur={handleAutoSave} />
|
<Input
|
||||||
|
className="bg-background"
|
||||||
|
{...field}
|
||||||
|
maxLength={255}
|
||||||
|
onBlur={handleAutoSave}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -624,7 +629,12 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input className="bg-background" {...field} maxLength={255} onBlur={handleAutoSave} />
|
<Input
|
||||||
|
className="bg-background"
|
||||||
|
{...field}
|
||||||
|
maxLength={255}
|
||||||
|
onBlur={handleAutoSave}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -715,7 +725,12 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input className="bg-background" {...field} maxLength={255} onBlur={handleAutoSave} />
|
<Input
|
||||||
|
className="bg-background"
|
||||||
|
{...field}
|
||||||
|
maxLength={255}
|
||||||
|
onBlur={handleAutoSave}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
Reference in New Issue
Block a user