mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 16:23:06 +10:00
Merge branch 'feat/refresh' into feat/completed-share-link
This commit is contained in:
65
apps/marketing/src/app/not-found.tsx
Normal file
65
apps/marketing/src/app/not-found.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import backgroundPattern from '~/assets/background-pattern.png';
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative max-w-[100vw] overflow-hidden')}>
|
||||||
|
<div className="absolute -inset-24 -z-10">
|
||||||
|
<motion.div
|
||||||
|
className="flex h-full w-full items-center justify-center"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 0.8, transition: { duration: 0.5, delay: 0.5 } }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={backgroundPattern}
|
||||||
|
alt="background pattern"
|
||||||
|
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%]"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container mx-auto flex h-full min-h-screen items-center px-6 py-32">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground font-semibold">404 Page not found</p>
|
||||||
|
|
||||||
|
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4 text-sm">
|
||||||
|
The page you are looking for was moved, removed, renamed or might never have existed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-32"
|
||||||
|
onClick={() => {
|
||||||
|
void router.back();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button className="w-32" asChild>
|
||||||
|
<Link href="/">Home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,11 +4,11 @@ import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
|||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: [
|
||||||
|
{
|
||||||
userAgent: '*',
|
userAgent: '*',
|
||||||
allow: '/*',
|
|
||||||
disallow: ['/_next/*'],
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
50
apps/web/src/app/(dashboard)/documents/empty-state.tsx
Normal file
50
apps/web/src/app/(dashboard)/documents/empty-state.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Bird, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
|
||||||
|
export type EmptyDocumentProps = { status: ExtendedDocumentStatus };
|
||||||
|
|
||||||
|
export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
icon: Icon,
|
||||||
|
} = match(status)
|
||||||
|
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
||||||
|
title: 'Nothing to do',
|
||||||
|
message:
|
||||||
|
'There are no completed documents yet. Documents that you have created or received that become completed will appear here later.',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
}))
|
||||||
|
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||||
|
title: 'No active drafts',
|
||||||
|
message:
|
||||||
|
'There are no active drafts at then current moment. You can upload a document to start drafting.',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
}))
|
||||||
|
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||||
|
title: "We're all empty",
|
||||||
|
message:
|
||||||
|
'You have not yet created or received any documents. To create a document please upload one.',
|
||||||
|
icon: Bird,
|
||||||
|
}))
|
||||||
|
.otherwise(() => ({
|
||||||
|
title: 'Nothing to do',
|
||||||
|
message:
|
||||||
|
'All documents are currently actioned. Any new documents are sent or recieved they will start to appear here.',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
|
||||||
|
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
|
|
||||||
|
<p className="mt-2 max-w-[60ch]">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -12,6 +12,7 @@ import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/ty
|
|||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
import { DocumentsDataTable } from './data-table';
|
import { DocumentsDataTable } from './data-table';
|
||||||
|
import { EmptyDocumentState } from './empty-state';
|
||||||
import { UploadDocument } from './upload-document';
|
import { UploadDocument } from './upload-document';
|
||||||
|
|
||||||
export type DocumentsPageProps = {
|
export type DocumentsPageProps = {
|
||||||
@ -96,7 +97,8 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<DocumentsDataTable results={results} />
|
{results.count > 0 && <DocumentsDataTable results={results} />}
|
||||||
|
{results.count === 0 && <EmptyDocumentState status={status} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
26
apps/web/src/app/not-found.tsx
Normal file
26
apps/web/src/app/not-found.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import NotFoundPartial from '~/components/partials/not-found';
|
||||||
|
|
||||||
|
export default async function NotFound() {
|
||||||
|
const session = await getServerComponentSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotFoundPartial>
|
||||||
|
{session && (
|
||||||
|
<Button className="w-32" asChild>
|
||||||
|
<Link href="/documents">Documents</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!session && (
|
||||||
|
<Button className="w-32" asChild>
|
||||||
|
<Link href="/signin">Sign In</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</NotFoundPartial>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||||
|
|
||||||
@ -8,12 +9,20 @@ export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
|
|||||||
documentId: number;
|
documentId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const completeDocument = async ({ documentId }: CompleteDocumentActionInput) => {
|
export const completeDocument = async ({ documentId, email }: CompleteDocumentActionInput) => {
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
const { id: userId } = await getRequiredServerComponentSession();
|
const { id: userId } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
await sendDocument({
|
if (email.message || email.subject) {
|
||||||
|
await upsertDocumentMeta({
|
||||||
|
documentId,
|
||||||
|
subject: email.subject,
|
||||||
|
message: email.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await sendDocument({
|
||||||
userId,
|
userId,
|
||||||
documentId,
|
documentId,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
export * from 'framer-motion';
|
|
||||||
|
|
||||||
export const MotionDiv = motion.div;
|
|
||||||
66
apps/web/src/components/partials/not-found.tsx
Normal file
66
apps/web/src/components/partials/not-found.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import backgroundPattern from '~/assets/background-pattern.png';
|
||||||
|
|
||||||
|
export type NotFoundPartialProps = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotFoundPartial({ children }: NotFoundPartialProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative max-w-[100vw] overflow-hidden')}>
|
||||||
|
<div className="absolute -inset-24 -z-10">
|
||||||
|
<motion.div
|
||||||
|
className="flex h-full w-full items-center justify-center"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 0.8, transition: { duration: 0.5, delay: 0.5 } }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={backgroundPattern}
|
||||||
|
alt="background pattern"
|
||||||
|
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%]"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container mx-auto flex h-full min-h-screen items-center px-6 py-32">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground font-semibold">404 Page not found</p>
|
||||||
|
|
||||||
|
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4 text-sm">
|
||||||
|
The page you are looking for was moved, removed, renamed or might never have existed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-32"
|
||||||
|
onClick={() => {
|
||||||
|
void router.back();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
|
import { Button, Column, Img, Row, Section, Tailwind, Text } from '@react-email/components';
|
||||||
|
|
||||||
import * as config from '@documenso/tailwind-config';
|
import * as config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
@ -29,11 +29,23 @@ export const TemplateDocumentCompleted = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Section className="flex-row items-center justify-center">
|
<Section>
|
||||||
<div className="flex items-center justify-center p-4">
|
<Row className="table-fixed">
|
||||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
<Column />
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<Column>
|
||||||
|
<Img
|
||||||
|
className="h-42 mx-auto"
|
||||||
|
src={getAssetUrl('/static/document.png')}
|
||||||
|
alt="Documenso"
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column />
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
|
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
|
||||||
<Img src={getAssetUrl('/static/completed.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
<Img src={getAssetUrl('/static/completed.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
||||||
Completed
|
Completed
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
|
import { Button, Column, Img, Row, Section, Tailwind, Text } from '@react-email/components';
|
||||||
|
|
||||||
import * as config from '@documenso/tailwind-config';
|
import * as config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
@ -30,13 +30,26 @@ export const TemplateDocumentInvite = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Section className="mt-4 flex-row items-center justify-center">
|
<Section className="mt-4">
|
||||||
<div className="flex items-center justify-center p-4">
|
<Row className="table-fixed">
|
||||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
<Column />
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<Column>
|
||||||
|
<Img
|
||||||
|
className="h-42 mx-auto"
|
||||||
|
src={getAssetUrl('/static/document.png')}
|
||||||
|
alt="Documenso"
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column />
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||||
{inviterName} has invited you to sign "{documentName}"
|
{inviterName} has invited you to sign
|
||||||
|
<br />"{documentName}"
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="my-1 text-center text-base text-slate-400">
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Img, Section, Tailwind, Text } from '@react-email/components';
|
import { Column, Img, Row, Section, Tailwind, Text } from '@react-email/components';
|
||||||
|
|
||||||
import * as config from '@documenso/tailwind-config';
|
import * as config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
@ -25,11 +25,23 @@ export const TemplateDocumentPending = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Section className="flex-row items-center justify-center">
|
<Section>
|
||||||
<div className="flex items-center justify-center p-4">
|
<Row className="table-fixed">
|
||||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
<Column />
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<Column>
|
||||||
|
<Img
|
||||||
|
className="h-42 mx-auto"
|
||||||
|
src={getAssetUrl('/static/document.png')}
|
||||||
|
alt="Documenso"
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column />
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-blue-500">
|
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-blue-500">
|
||||||
<Img src={getAssetUrl('/static/clock.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
<Img src={getAssetUrl('/static/clock.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
||||||
Waiting for others
|
Waiting for others
|
||||||
|
|||||||
@ -20,7 +20,9 @@ import {
|
|||||||
} from '../template-components/template-document-invite';
|
} from '../template-components/template-document-invite';
|
||||||
import TemplateFooter from '../template-components/template-footer';
|
import TemplateFooter from '../template-components/template-footer';
|
||||||
|
|
||||||
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps>;
|
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
||||||
|
customBody?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const DocumentInviteEmailTemplate = ({
|
export const DocumentInviteEmailTemplate = ({
|
||||||
inviterName = 'Lucas Smith',
|
inviterName = 'Lucas Smith',
|
||||||
@ -28,6 +30,7 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
documentName = 'Open Source Pledge.pdf',
|
documentName = 'Open Source Pledge.pdf',
|
||||||
signDocumentLink = 'https://documenso.com',
|
signDocumentLink = 'https://documenso.com',
|
||||||
assetBaseUrl = 'http://localhost:3002',
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
|
customBody,
|
||||||
}: DocumentInviteEmailTemplateProps) => {
|
}: DocumentInviteEmailTemplateProps) => {
|
||||||
const previewText = `Completed Document`;
|
const previewText = `Completed Document`;
|
||||||
|
|
||||||
@ -78,7 +81,11 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="mt-2 text-base text-slate-400">
|
<Text className="mt-2 text-base text-slate-400">
|
||||||
{inviterName} has invited you to sign the document "{documentName}".
|
{customBody ? (
|
||||||
|
<pre className="font-sans text-base text-slate-400">{customBody}</pre>
|
||||||
|
) : (
|
||||||
|
`${inviterName} has invited you to sign the document "${documentName}".`
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type CreateDocumentMetaOptions = {
|
||||||
|
documentId: number;
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertDocumentMeta = async ({
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
documentId,
|
||||||
|
}: CreateDocumentMetaOptions) => {
|
||||||
|
return await prisma.documentMeta.upsert({
|
||||||
|
where: {
|
||||||
|
documentId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
documentId,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -4,6 +4,7 @@ import { prisma } from '@documenso/prisma';
|
|||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { sealDocument } from './seal-document';
|
import { sealDocument } from './seal-document';
|
||||||
|
import { sendPendingEmail } from './send-pending-email';
|
||||||
|
|
||||||
export type CompleteDocumentWithTokenOptions = {
|
export type CompleteDocumentWithTokenOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
@ -69,6 +70,19 @@ export const completeDocumentWithToken = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pendingRecipients = await prisma.recipient.count({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
signingStatus: {
|
||||||
|
not: SigningStatus.SIGNED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingRecipients > 0) {
|
||||||
|
await sendPendingEmail({ documentId, recipientId: recipient.id });
|
||||||
|
}
|
||||||
|
|
||||||
const documents = await prisma.document.updateMany({
|
const documents = await prisma.document.updateMany({
|
||||||
where: {
|
where: {
|
||||||
id: document.id,
|
id: document.id,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) =>
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
documentData: true,
|
documentData: true,
|
||||||
|
documentMeta: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
|||||||
import { getFile } from '../../universal/upload/get-file';
|
import { getFile } from '../../universal/upload/get-file';
|
||||||
import { putFile } from '../../universal/upload/put-file';
|
import { putFile } from '../../universal/upload/put-file';
|
||||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||||
|
import { sendCompletedEmail } from './send-completed-email';
|
||||||
|
|
||||||
export type SealDocumentOptions = {
|
export type SealDocumentOptions = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
@ -86,4 +87,6 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
|||||||
data: newData,
|
data: newData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await sendCompletedEmail({ documentId });
|
||||||
};
|
};
|
||||||
|
|||||||
57
packages/lib/server-only/document/send-completed-email.ts
Normal file
57
packages/lib/server-only/document/send-completed-email.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { render } from '@documenso/email/render';
|
||||||
|
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface SendDocumentOptions {
|
||||||
|
documentId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) => {
|
||||||
|
const document = await prisma.document.findUnique({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.Recipient.length === 0) {
|
||||||
|
throw new Error('Document has no recipients');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
document.Recipient.map(async (recipient) => {
|
||||||
|
const { email, name, token } = recipient;
|
||||||
|
|
||||||
|
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
const template = createElement(DocumentCompletedEmailTemplate, {
|
||||||
|
documentName: document.title,
|
||||||
|
assetBaseUrl,
|
||||||
|
downloadLink: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${token}/complete`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: email,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||||
|
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||||
|
},
|
||||||
|
subject: 'Signing Complete!',
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
@ -3,13 +3,14 @@ import { createElement } from 'react';
|
|||||||
import { mailer } from '@documenso/email/mailer';
|
import { mailer } from '@documenso/email/mailer';
|
||||||
import { render } from '@documenso/email/render';
|
import { render } from '@documenso/email/render';
|
||||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||||
|
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export interface SendDocumentOptions {
|
export type SendDocumentOptions = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
|
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
@ -25,9 +26,12 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
Recipient: true,
|
Recipient: true,
|
||||||
|
documentMeta: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const customEmail = document?.documentMeta;
|
||||||
|
|
||||||
if (!document) {
|
if (!document) {
|
||||||
throw new Error('Document not found');
|
throw new Error('Document not found');
|
||||||
}
|
}
|
||||||
@ -44,6 +48,12 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
document.Recipient.map(async (recipient) => {
|
document.Recipient.map(async (recipient) => {
|
||||||
const { email, name } = recipient;
|
const { email, name } = recipient;
|
||||||
|
|
||||||
|
const customEmailTemplate = {
|
||||||
|
'signer.name': name,
|
||||||
|
'signer.email': email,
|
||||||
|
'document.name': document.title,
|
||||||
|
};
|
||||||
|
|
||||||
if (recipient.sendStatus === SendStatus.SENT) {
|
if (recipient.sendStatus === SendStatus.SENT) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -57,6 +67,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
inviterEmail: user.email,
|
inviterEmail: user.email,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
|
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||||
});
|
});
|
||||||
|
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
@ -68,7 +79,9 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||||
},
|
},
|
||||||
subject: 'Please sign this document',
|
subject: customEmail?.subject
|
||||||
|
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||||
|
: 'Please sign this document',
|
||||||
html: render(template),
|
html: render(template),
|
||||||
text: render(template, { plainText: true }),
|
text: render(template, { plainText: true }),
|
||||||
});
|
});
|
||||||
|
|||||||
64
packages/lib/server-only/document/send-pending-email.ts
Normal file
64
packages/lib/server-only/document/send-pending-email.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { render } from '@documenso/email/render';
|
||||||
|
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface SendPendingEmailOptions {
|
||||||
|
documentId: number;
|
||||||
|
recipientId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingEmailOptions) => {
|
||||||
|
const document = await prisma.document.findFirst({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
Recipient: {
|
||||||
|
some: {
|
||||||
|
id: recipientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: {
|
||||||
|
where: {
|
||||||
|
id: recipientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.Recipient.length === 0) {
|
||||||
|
throw new Error('Document has no recipients');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [recipient] = document.Recipient;
|
||||||
|
|
||||||
|
const { email, name } = recipient;
|
||||||
|
|
||||||
|
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
const template = createElement(DocumentPendingEmailTemplate, {
|
||||||
|
documentName: document.title,
|
||||||
|
assetBaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: email,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||||
|
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||||
|
},
|
||||||
|
subject: 'Waiting for others to complete signing.',
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
});
|
||||||
|
};
|
||||||
21
packages/lib/server-only/document/update-document.ts
Normal file
21
packages/lib/server-only/document/update-document.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type UpdateDocumentOptions = {
|
||||||
|
documentId: number;
|
||||||
|
data: Prisma.DocumentUpdateInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateDocument = async ({ documentId, data }: UpdateDocumentOptions) => {
|
||||||
|
return await prisma.document.update({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -50,10 +50,10 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
let imageWidth = image.width;
|
let imageWidth = image.width;
|
||||||
let imageHeight = image.height;
|
let imageHeight = image.height;
|
||||||
|
|
||||||
const initialDimensions = {
|
// const initialDimensions = {
|
||||||
width: imageWidth,
|
// width: imageWidth,
|
||||||
height: imageHeight,
|
// height: imageHeight,
|
||||||
};
|
// };
|
||||||
|
|
||||||
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
||||||
|
|
||||||
@ -76,10 +76,10 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
|
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
|
||||||
const textHeight = font.heightAtSize(fontSize);
|
const textHeight = font.heightAtSize(fontSize);
|
||||||
|
|
||||||
const initialDimensions = {
|
// const initialDimensions = {
|
||||||
width: textWidth,
|
// width: textWidth,
|
||||||
height: textHeight,
|
// height: textHeight,
|
||||||
};
|
// };
|
||||||
|
|
||||||
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
||||||
|
|
||||||
|
|||||||
12
packages/lib/utils/render-custom-email-template.ts
Normal file
12
packages/lib/utils/render-custom-email-template.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export const renderCustomEmailTemplate = <T extends Record<string, string>>(
|
||||||
|
template: string,
|
||||||
|
variables: T,
|
||||||
|
): string => {
|
||||||
|
return template.replace(/\{(\S+)\}/g, (_, key) => {
|
||||||
|
if (key in variables) {
|
||||||
|
return variables[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Document" ADD COLUMN "documentMetaId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DocumentMeta" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"customEmailSubject" TEXT,
|
||||||
|
"customEmailBody" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "DocumentMeta_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Document" ADD CONSTRAINT "Document_documentMetaId_fkey" FOREIGN KEY ("documentMetaId") REFERENCES "DocumentMeta"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[documentMetaId]` on the table `Document` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Document_documentMetaId_key" ON "Document"("documentMetaId");
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `documentMetaId` on the `Document` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `customEmailBody` on the `DocumentMeta` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `customEmailSubject` on the `DocumentMeta` table. All the data in the column will be lost.
|
||||||
|
- A unique constraint covering the columns `[documentId]` on the table `DocumentMeta` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `documentId` to the `DocumentMeta` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Document" DROP CONSTRAINT "Document_documentMetaId_fkey";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Document_documentMetaId_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DocumentMeta"
|
||||||
|
ADD COLUMN "documentId" INTEGER,
|
||||||
|
ADD COLUMN "message" TEXT,
|
||||||
|
ADD COLUMN "subject" TEXT;
|
||||||
|
|
||||||
|
-- Migrate data
|
||||||
|
UPDATE "DocumentMeta" SET "documentId" = (
|
||||||
|
SELECT "id" FROM "Document" WHERE "Document"."documentMetaId" = "DocumentMeta"."id"
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Migrate data
|
||||||
|
UPDATE "DocumentMeta" SET "message" = "customEmailBody";
|
||||||
|
|
||||||
|
-- Migrate data
|
||||||
|
UPDATE "DocumentMeta" SET "subject" = "customEmailSubject";
|
||||||
|
|
||||||
|
-- Prune data
|
||||||
|
DELETE FROM "DocumentMeta" WHERE "documentId" IS NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Document" DROP COLUMN "documentMetaId";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DocumentMeta"
|
||||||
|
DROP COLUMN "customEmailBody",
|
||||||
|
DROP COLUMN "customEmailSubject";
|
||||||
|
|
||||||
|
-- AlterColumn
|
||||||
|
ALTER TABLE "DocumentMeta" ALTER COLUMN "documentId" SET NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DocumentMeta_documentId_key" ON "DocumentMeta"("documentId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DocumentMeta" ADD CONSTRAINT "DocumentMeta_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -111,6 +111,7 @@ model Document {
|
|||||||
ShareLink DocumentShareLink[]
|
ShareLink DocumentShareLink[]
|
||||||
documentDataId String
|
documentDataId String
|
||||||
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
|
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
|
||||||
|
documentMeta DocumentMeta?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
@ -131,6 +132,14 @@ model DocumentData {
|
|||||||
Document Document?
|
Document Document?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model DocumentMeta {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
subject String?
|
||||||
|
message String?
|
||||||
|
documentId Int @unique
|
||||||
|
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
enum ReadStatus {
|
enum ReadStatus {
|
||||||
NOT_OPENED
|
NOT_OPENED
|
||||||
OPENED
|
OPENED
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Document, DocumentData } from '@documenso/prisma/client';
|
import { Document, DocumentData, DocumentMeta } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export type DocumentWithData = Document & {
|
export type DocumentWithData = Document & {
|
||||||
documentData?: DocumentData | null;
|
documentData?: DocumentData | null;
|
||||||
|
documentMeta?: DocumentMeta | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { Document, DocumentStatus, Field, Recipient } from '@documenso/prisma/client';
|
import { DocumentStatus, Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
@ -21,7 +22,7 @@ export type AddSubjectFormProps = {
|
|||||||
documentFlow: DocumentFlowStep;
|
documentFlow: DocumentFlowStep;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
document: Document;
|
document: DocumentWithData;
|
||||||
numberOfSteps: number;
|
numberOfSteps: number;
|
||||||
onSubmit: (_data: TAddSubjectFormSchema) => void;
|
onSubmit: (_data: TAddSubjectFormSchema) => void;
|
||||||
};
|
};
|
||||||
@ -41,8 +42,8 @@ export const AddSubjectFormPartial = ({
|
|||||||
} = useForm<TAddSubjectFormSchema>({
|
} = useForm<TAddSubjectFormSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: {
|
email: {
|
||||||
subject: '',
|
subject: document.documentMeta?.subject ?? '',
|
||||||
message: '',
|
message: document.documentMeta?.message ?? '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user