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:
@@ -1,6 +1,7 @@
|
|||||||
import { createNextRoute } from '@ts-rest/next';
|
import { createNextRoute } from '@ts-rest/next';
|
||||||
|
|
||||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
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 { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||||
@@ -76,7 +77,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
...document,
|
...document,
|
||||||
recipients,
|
recipients: recipients.map((recipient) => ({
|
||||||
|
...recipient,
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -258,6 +262,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
|
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -349,6 +355,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
|
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -428,6 +436,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
role: recipient.role,
|
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) => {
|
sendDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||||
const { id } = args.params;
|
const { id } = args.params;
|
||||||
|
const { sendEmail = true } = args.body ?? {};
|
||||||
|
|
||||||
const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id });
|
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),
|
documentId: Number(id),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
|
sendEmail,
|
||||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -501,6 +513,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
message: 'Document sent for signing successfully',
|
message: 'Document sent for signing successfully',
|
||||||
|
...sentDocument,
|
||||||
|
recipients: recipients.map((recipient) => ({
|
||||||
|
...recipient,
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -585,6 +602,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
body: {
|
body: {
|
||||||
...newRecipient,
|
...newRecipient,
|
||||||
documentId: Number(documentId),
|
documentId: Number(documentId),
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${newRecipient.token}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -650,6 +668,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
body: {
|
body: {
|
||||||
...updatedRecipient,
|
...updatedRecipient,
|
||||||
documentId: Number(documentId),
|
documentId: Number(documentId),
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${updatedRecipient.token}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -703,6 +722,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
body: {
|
body: {
|
||||||
...deletedRecipient,
|
...deletedRecipient,
|
||||||
documentId: Number(documentId),
|
documentId: Number(documentId),
|
||||||
|
signingUrl: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ export type TSuccessfulGetDocumentResponseSchema = z.infer<
|
|||||||
|
|
||||||
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
|
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;
|
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
||||||
|
|
||||||
@@ -89,8 +93,12 @@ export const ZCreateDocumentMutationResponseSchema = z.object({
|
|||||||
recipients: z.array(
|
recipients: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
recipientId: z.number(),
|
recipientId: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string().email().min(1),
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
|
||||||
|
signingUrl: z.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -134,6 +142,8 @@ export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
|
|||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
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),
|
email: z.string().email().min(1),
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
|
||||||
|
signingUrl: z.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -229,6 +241,8 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
|
|||||||
readStatus: z.nativeEnum(ReadStatus),
|
readStatus: z.nativeEnum(ReadStatus),
|
||||||
signingStatus: z.nativeEnum(SigningStatus),
|
signingStatus: z.nativeEnum(SigningStatus),
|
||||||
sendStatus: z.nativeEnum(SendStatus),
|
sendStatus: z.nativeEnum(SendStatus),
|
||||||
|
|
||||||
|
signingUrl: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
|
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
|
||||||
@@ -279,9 +293,11 @@ export const ZSuccessfulResponseSchema = z.object({
|
|||||||
|
|
||||||
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
|
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
|
||||||
|
|
||||||
export const ZSuccessfulSigningResponseSchema = z.object({
|
export const ZSuccessfulSigningResponseSchema = z
|
||||||
message: z.string(),
|
.object({
|
||||||
});
|
message: z.string(),
|
||||||
|
})
|
||||||
|
.and(ZSuccessfulGetDocumentResponseSchema);
|
||||||
|
|
||||||
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;
|
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type SendDocumentOptions = {
|
|||||||
documentId: number;
|
documentId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
|
sendEmail?: boolean;
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ export const sendDocument = async ({
|
|||||||
documentId,
|
documentId,
|
||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
|
sendEmail = true,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: SendDocumentOptions) => {
|
}: SendDocumentOptions) => {
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
@@ -120,98 +122,102 @@ export const sendDocument = async ({
|
|||||||
Object.assign(document, result);
|
Object.assign(document, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
if (sendEmail) {
|
||||||
document.Recipient.map(async (recipient) => {
|
await Promise.all(
|
||||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
document.Recipient.map(async (recipient) => {
|
||||||
return;
|
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 { email, name } = recipient;
|
||||||
const selfSigner = email === user.email;
|
const selfSigner = email === user.email;
|
||||||
|
|
||||||
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
|
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
|
||||||
recipient.role
|
recipient.role
|
||||||
].actionVerb.toLowerCase()} it.`;
|
].actionVerb.toLowerCase()} it.`;
|
||||||
|
|
||||||
const customEmailTemplate = {
|
const customEmailTemplate = {
|
||||||
'signer.name': name,
|
'signer.name': name,
|
||||||
'signer.email': email,
|
'signer.email': email,
|
||||||
'document.name': document.title,
|
'document.name': document.title,
|
||||||
};
|
};
|
||||||
|
|
||||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||||
|
|
||||||
const template = createElement(DocumentInviteEmailTemplate, {
|
const template = createElement(DocumentInviteEmailTemplate, {
|
||||||
documentName: document.title,
|
documentName: document.title,
|
||||||
inviterName: user.name || undefined,
|
inviterName: user.name || undefined,
|
||||||
inviterEmail: user.email,
|
inviterEmail: user.email,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
customBody: renderCustomEmailTemplate(
|
customBody: renderCustomEmailTemplate(
|
||||||
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
|
selfSigner && !customEmail?.message
|
||||||
customEmailTemplate,
|
? selfSignerCustomEmail
|
||||||
),
|
: customEmail?.message || '',
|
||||||
role: recipient.role,
|
customEmailTemplate,
|
||||||
selfSigner,
|
),
|
||||||
});
|
role: recipient.role,
|
||||||
|
selfSigner,
|
||||||
|
});
|
||||||
|
|
||||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||||
|
|
||||||
const emailSubject = selfSigner
|
const emailSubject = selfSigner
|
||||||
? `Please ${actionVerb.toLowerCase()} your document`
|
? `Please ${actionVerb.toLowerCase()} your document`
|
||||||
: `Please ${actionVerb.toLowerCase()} this document`;
|
: `Please ${actionVerb.toLowerCase()} this document`;
|
||||||
|
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
to: {
|
to: {
|
||||||
address: email,
|
address: email,
|
||||||
name,
|
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,
|
|
||||||
},
|
},
|
||||||
}),
|
from: {
|
||||||
});
|
name: FROM_NAME,
|
||||||
},
|
address: FROM_ADDRESS,
|
||||||
{ timeout: 30_000 },
|
},
|
||||||
);
|
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(
|
const allRecipientsHaveNoActionToTake = document.Recipient.every(
|
||||||
(recipient) => recipient.role === RecipientRole.CC,
|
(recipient) => recipient.role === RecipientRole.CC,
|
||||||
|
|||||||
Reference in New Issue
Block a user