feat: optional email sending for api users

Introduces the ability to not send an email when sending
(publishing) a document using the API.

Additionally returns the signing link for each recipient
when working with recipient API endpoints and returns
the document object including recipients when sending
documents via API.
This commit is contained in:
Mythie
2024-05-24 23:36:28 +10:00
parent ef5d267e96
commit 6650a1d72e
3 changed files with 130 additions and 88 deletions

View File

@ -1,6 +1,7 @@
import { createNextRoute } from '@ts-rest/next';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
@ -76,7 +77,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
status: 200,
body: {
...document,
recipients,
recipients: recipients.map((recipient) => ({
...recipient,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
} catch (err) {
@ -258,6 +262,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
@ -349,6 +355,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
@ -428,6 +436,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
email: recipient.email,
token: recipient.token,
role: recipient.role,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
@ -435,6 +445,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
sendDocument: authenticatedMiddleware(async (args, user, team) => {
const { id } = args.params;
const { sendEmail = true } = args.body ?? {};
const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id });
@ -490,10 +501,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
// });
// }
await sendDocument({
const { Recipient: recipients, ...sentDocument } = await sendDocument({
documentId: Number(id),
userId: user.id,
teamId: team?.id,
sendEmail,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
@ -501,6 +513,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
status: 200,
body: {
message: 'Document sent for signing successfully',
...sentDocument,
recipients: recipients.map((recipient) => ({
...recipient,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
};
} catch (err) {
@ -585,6 +602,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
body: {
...newRecipient,
documentId: Number(documentId),
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${newRecipient.token}`,
},
};
} catch (err) {
@ -650,6 +668,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
body: {
...updatedRecipient,
documentId: Number(documentId),
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${updatedRecipient.token}`,
},
};
}),
@ -703,6 +722,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
body: {
...deletedRecipient,
documentId: Number(documentId),
signingUrl: '',
},
};
}),

View File

@ -45,7 +45,11 @@ export type TSuccessfulGetDocumentResponseSchema = z.infer<
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
export const ZSendDocumentForSigningMutationSchema = null;
export const ZSendDocumentForSigningMutationSchema = z
.object({
sendEmail: z.boolean().optional().default(true),
})
.or(z.literal('').transform(() => ({ sendEmail: true })));
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
@ -89,8 +93,12 @@ export const ZCreateDocumentMutationResponseSchema = z.object({
recipients: z.array(
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole),
signingUrl: z.string(),
}),
),
});
@ -134,6 +142,8 @@ export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingUrl: z.string(),
}),
),
});
@ -187,6 +197,8 @@ export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole),
signingUrl: z.string(),
}),
),
});
@ -229,6 +241,8 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
readStatus: z.nativeEnum(ReadStatus),
signingStatus: z.nativeEnum(SigningStatus),
sendStatus: z.nativeEnum(SendStatus),
signingUrl: z.string(),
});
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
@ -279,9 +293,11 @@ export const ZSuccessfulResponseSchema = z.object({
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
export const ZSuccessfulSigningResponseSchema = z.object({
message: z.string(),
});
export const ZSuccessfulSigningResponseSchema = z
.object({
message: z.string(),
})
.and(ZSuccessfulGetDocumentResponseSchema);
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;

View File

@ -28,6 +28,7 @@ export type SendDocumentOptions = {
documentId: number;
userId: number;
teamId?: number;
sendEmail?: boolean;
requestMetadata?: RequestMetadata;
};
@ -35,6 +36,7 @@ export const sendDocument = async ({
documentId,
userId,
teamId,
sendEmail = true,
requestMetadata,
}: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
@ -120,98 +122,102 @@ export const sendDocument = async ({
Object.assign(document, result);
}
await Promise.all(
document.Recipient.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
if (sendEmail) {
await Promise.all(
document.Recipient.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const { email, name } = recipient;
const selfSigner = email === user.email;
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
recipient.role
].actionVerb.toLowerCase()} it.`;
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
recipient.role
].actionVerb.toLowerCase()} it.`;
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': document.title,
};
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': document.title,
};
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${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(
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate,
),
role: recipient.role,
selfSigner,
});
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(
selfSigner && !customEmail?.message
? selfSignerCustomEmail
: customEmail?.message || '',
customEmailTemplate,
),
role: recipient.role,
selfSigner,
});
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
const emailSubject = selfSigner
? `Please ${actionVerb.toLowerCase()} your document`
: `Please ${actionVerb.toLowerCase()} this document`;
const emailSubject = selfSigner
? `Please ${actionVerb.toLowerCase()} your document`
: `Please ${actionVerb.toLowerCase()} this document`;
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: emailSubject,
html: render(template),
text: render(template, { plainText: true }),
});
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: false,
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
}),
});
},
{ timeout: 30_000 },
);
}),
);
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: emailSubject,
html: render(template),
text: render(template, { plainText: true }),
});
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: false,
},
}),
});
},
{ timeout: 30_000 },
);
}),
);
}
const allRecipientsHaveNoActionToTake = document.Recipient.every(
(recipient) => recipient.role === RecipientRole.CC,