feat: enable resend email menu (#496)

This commit is contained in:
Nafees Nazik
2023-11-16 07:35:45 +05:30
committed by GitHub
parent 34527c1842
commit 51938293f1
27 changed files with 407 additions and 74 deletions

View File

@ -0,0 +1,35 @@
'use server';
import { cache } from 'react';
import { getServerSession as getNextAuthServerSession } from 'next-auth';
import { prisma } from '@documenso/prisma';
import { NEXT_AUTH_OPTIONS } from './auth-options';
export const getServerComponentSession = cache(async () => {
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
if (!session || !session.user?.email) {
return { user: null, session: null };
}
const user = await prisma.user.findFirstOrThrow({
where: {
email: session.user.email,
},
});
return { user, session };
});
export const getRequiredServerComponentSession = cache(async () => {
const { user, session } = await getServerComponentSession();
if (!user || !session) {
throw new Error('No session found');
}
return { user, session };
});

View File

@ -1,6 +1,6 @@
import { cache } from 'react';
'use server';
import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
import type { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
import { getServerSession as getNextAuthServerSession } from 'next-auth';
@ -28,29 +28,3 @@ export const getServerSession = async ({ req, res }: GetServerSessionOptions) =>
return { user, session };
};
export const getServerComponentSession = cache(async () => {
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
if (!session || !session.user?.email) {
return { user: null, session: null };
}
const user = await prisma.user.findFirstOrThrow({
where: {
email: session.user.email,
},
});
return { user, session };
});
export const getRequiredServerComponentSession = cache(async () => {
const { user, session } = await getServerComponentSession();
if (!user || !session) {
throw new Error('No session found');
}
return { user, session };
});

View File

@ -0,0 +1,99 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
export type ResendDocumentOptions = {
documentId: number;
userId: number;
recipients: number[];
};
export const resendDocument = async ({ documentId, userId, recipients }: ResendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const document = await prisma.document.findUnique({
where: {
id: documentId,
userId,
},
include: {
Recipient: {
where: {
id: {
in: recipients,
},
signingStatus: SigningStatus.NOT_SIGNED,
},
},
documentMeta: true,
},
});
const customEmail = document?.documentMeta;
if (!document) {
throw new Error('Document not found');
}
if (document.Recipient.length === 0) {
throw new Error('Document has no recipients');
}
if (document.status === DocumentStatus.DRAFT) {
throw new Error('Can not send draft document');
}
if (document.status === DocumentStatus.COMPLETED) {
throw new Error('Can not send completed document');
}
await Promise.all([
document.Recipient.map(async (recipient) => {
const { email, name } = recipient;
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': document.title,
};
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
});
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: 'Please sign this document',
html: render(template),
text: render(template, { plainText: true }),
});
}),
]);
};

View File

@ -10,7 +10,7 @@ import slugify from '@sindresorhus/slugify';
import path from 'node:path';
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
import { getServerComponentSession } from '../../next-auth/get-server-session';
import { getServerComponentSession } from '../../next-auth/get-server-component-session';
import { alphaid } from '../id';
export const getPresignPostUrl = async (fileName: string, contentType: string) => {

View File

@ -6,6 +6,7 @@ import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-
import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
@ -16,6 +17,7 @@ import {
ZDeleteDraftDocumentMutationSchema,
ZGetDocumentByIdQuerySchema,
ZGetDocumentByTokenQuerySchema,
ZResendDocumentMutationSchema,
ZSendDocumentMutationSchema,
ZSetFieldsForDocumentMutationSchema,
ZSetRecipientsForDocumentMutationSchema,
@ -174,6 +176,27 @@ export const documentRouter = router({
}
}),
resendDocument: authenticatedProcedure
.input(ZResendDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, recipients } = input;
return await resendDocument({
userId: ctx.user.id,
documentId,
recipients,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to resend this document. Please try again later.',
});
}
}),
duplicateDocument: authenticatedProcedure
.input(ZGetDocumentByIdQuerySchema)
.mutation(async ({ input, ctx }) => {

View File

@ -60,6 +60,11 @@ export const ZSendDocumentMutationSchema = z.object({
documentId: z.number(),
});
export const ZResendDocumentMutationSchema = z.object({
documentId: z.number(),
recipients: z.array(z.number()).min(1),
});
export type TSendDocumentMutationSchema = z.infer<typeof ZSendDocumentMutationSchema>;
export const ZDeleteDraftDocumentMutationSchema = z.object({

View File

@ -9,8 +9,10 @@ import { cn } from '../lib/utils';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
checkClassName?: string;
}
>(({ className, checkClassName, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
@ -19,8 +21,10 @@ const Checkbox = React.forwardRef<
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('text-primary flex items-center justify-center')}>
<Check className="h-4 w-4" />
<CheckboxPrimitive.Indicator
className={cn('text-primary flex items-center justify-center', checkClassName)}
>
<Check className="h-3 w-3 stroke-[3px]" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));

View File

@ -11,6 +11,8 @@ const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close;
const DialogPortal = ({
children,
position = 'start',
@ -51,8 +53,9 @@ const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
position?: 'start' | 'end' | 'center';
hideClose?: boolean;
}
>(({ className, children, position = 'start', ...props }, ref) => (
>(({ className, children, position = 'start', hideClose = false, ...props }, ref) => (
<DialogPortal position={position}>
<DialogOverlay />
<DialogPrimitive.Content
@ -64,10 +67,12 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{!hideClose && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));
@ -125,4 +130,5 @@ export {
DialogTitle,
DialogDescription,
DialogPortal,
DialogClose,
};