fix: merge conflicts

This commit is contained in:
Ephraim Atta-Duncan
2024-11-15 10:50:31 +00:00
326 changed files with 23969 additions and 3005 deletions

View File

@ -13,6 +13,7 @@ export const getRecipientsStats = async () => {
[ReadStatus.NOT_OPENED]: 0,
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SigningStatus.REJECTED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};

View File

@ -1,11 +1,14 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendConfirmationEmailProps {
userId: number;
@ -45,6 +48,13 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
confirmationLink,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(confirmationTemplate),
renderEmailWithI18N(confirmationTemplate, { plainText: true }),
]);
const i18n = await getI18nInstance();
return mailer.sendMail({
to: {
address: user.email,
@ -54,8 +64,8 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
name: senderName,
address: senderAddress,
},
subject: 'Please confirm your email',
html: render(confirmationTemplate),
text: render(confirmationTemplate, { plainText: true }),
subject: i18n._(msg`Please confirm your email`),
html,
text,
});
};

View File

@ -1,11 +1,14 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ForgotPasswordTemplate } from '@documenso/email/templates/forgot-password';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendForgotPasswordOptions {
userId: number;
@ -39,6 +42,13 @@ export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions)
resetPasswordLink,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
const i18n = await getI18nInstance();
return await mailer.sendMail({
to: {
address: user.email,
@ -48,8 +58,8 @@ export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions)
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Forgot Password?',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Forgot Password?`),
html,
text,
});
};

View File

@ -1,11 +1,11 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendResetPasswordOptions {
userId: number;
@ -26,6 +26,11 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
userName: user.name || '',
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
return await mailer.sendMail({
to: {
address: user.email,
@ -36,7 +41,7 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Password Reset Success!',
html: render(template),
text: render(template, { plainText: true }),
html,
text,
});
};

View File

@ -7,7 +7,10 @@ import {
diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { DocumentSigningOrder } from '@documenso/prisma/client';
import type { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import type { TDocumentEmailSettings } from '../../types/document-email';
export type CreateDocumentMetaOptions = {
documentId: number;
@ -17,8 +20,11 @@ export type CreateDocumentMetaOptions = {
password?: string;
dateFormat?: string;
redirectUrl?: string;
emailSettings?: TDocumentEmailSettings;
signingOrder?: DocumentSigningOrder;
distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean;
language?: SupportedLanguageCodes;
userId: number;
requestMetadata: RequestMetadata;
};
@ -33,7 +39,10 @@ export const upsertDocumentMeta = async ({
userId,
redirectUrl,
signingOrder,
emailSettings,
distributionMethod,
typedSignatureEnabled,
language,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const user = await prisma.user.findFirstOrThrow({
@ -84,7 +93,10 @@ export const upsertDocumentMeta = async ({
documentId,
redirectUrl,
signingOrder,
emailSettings,
distributionMethod,
typedSignatureEnabled,
language,
},
update: {
subject,
@ -94,7 +106,10 @@ export const upsertDocumentMeta = async ({
timezone,
redirectUrl,
signingOrder,
emailSettings,
distributionMethod,
typedSignatureEnabled,
language,
},
});

View File

@ -5,7 +5,9 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentSource, WebhookTriggerEvents } from '@documenso/prisma/client';
import { DocumentSource, DocumentVisibility, WebhookTriggerEvents } from '@documenso/prisma/client';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -48,6 +50,51 @@ export const createDocument = async ({
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
}
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;
let userTeamRole: TeamMemberRole | undefined;
if (teamId) {
const teamWithUserRole = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
},
include: {
teamGlobalSettings: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
});
team = teamWithUserRole;
userTeamRole = teamWithUserRole.members[0]?.role;
}
const determineVisibility = (
globalVisibility: DocumentVisibility | null | undefined,
userRole: TeamMemberRole,
): DocumentVisibility => {
const defaultVisibility = globalVisibility ?? DocumentVisibility.EVERYONE;
if (userRole === TeamMemberRole.ADMIN) {
return defaultVisibility;
}
if (userRole === TeamMemberRole.MANAGER) {
if (defaultVisibility === DocumentVisibility.ADMIN) {
return DocumentVisibility.MANAGER_AND_ABOVE;
}
return defaultVisibility;
}
return DocumentVisibility.EVERYONE;
};
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
@ -56,8 +103,17 @@ export const createDocument = async ({
documentDataId,
userId,
teamId,
visibility: determineVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: {
language: team?.teamGlobalSettings?.documentLanguage,
},
},
},
});

View File

@ -2,18 +2,30 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client';
import type {
Document,
DocumentMeta,
Recipient,
Team,
TeamGlobalSettings,
User,
} from '@documenso/prisma/client';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export type DeleteDocumentOptions = {
id: number;
@ -46,8 +58,9 @@ export const deleteDocument = async ({
Recipient: true,
documentMeta: true,
team: {
select: {
include: {
members: true,
teamGlobalSettings: true,
},
},
},
@ -70,6 +83,7 @@ export const deleteDocument = async ({
await handleDocumentOwnerDelete({
document,
user,
team: document.team,
requestMetadata,
});
}
@ -110,6 +124,11 @@ type HandleDocumentOwnerDeleteOptions = {
Recipient: Recipient[];
documentMeta: DocumentMeta | null;
};
team?:
| (Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
})
| null;
user: User;
requestMetadata?: RequestMetadata;
};
@ -117,6 +136,7 @@ type HandleDocumentOwnerDeleteOptions = {
const handleDocumentOwnerDelete = async ({
document,
user,
team,
requestMetadata,
}: HandleDocumentOwnerDeleteOptions) => {
if (document.deletedAt) {
@ -175,6 +195,14 @@ const handleDocumentOwnerDelete = async ({
});
});
const isDocumentDeleteEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentDeleted;
if (!isDocumentDeleteEmailEnabled) {
return deletedDocument;
}
// Send cancellation emails to recipients.
await Promise.all(
document.Recipient.map(async (recipient) => {
@ -191,6 +219,21 @@ const handleDocumentOwnerDelete = async ({
assetBaseUrl,
});
const branding = team?.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
await mailer.sendMail({
to: {
address: recipient.email,
@ -200,9 +243,9 @@ const handleDocumentOwnerDelete = async ({
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
}),
);

View File

@ -66,6 +66,7 @@ export const findDocumentAuditLogs = async ({
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM,
],

View File

@ -3,7 +3,14 @@ import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { RecipientRole, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import type { Document, Prisma, Team, TeamEmail, User } from '@documenso/prisma/client';
import type {
Document,
DocumentSource,
Prisma,
Team,
TeamEmail,
User,
} from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DocumentVisibility } from '../../types/document-visibility';
@ -16,6 +23,8 @@ export type FindDocumentsOptions = {
userId: number;
teamId?: number;
term?: string;
templateId?: number;
source?: DocumentSource;
status?: ExtendedDocumentStatus;
page?: number;
perPage?: number;
@ -32,6 +41,8 @@ export const findDocuments = async ({
userId,
teamId,
term,
templateId,
source,
status = ExtendedDocumentStatus.ALL,
page = 1,
perPage = 10,
@ -40,44 +51,37 @@ export const findDocuments = async ({
senderIds,
search,
}: FindDocumentsOptions) => {
const { user, team } = await prisma.$transaction(async (tx) => {
const user = await tx.user.findFirstOrThrow({
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
let team = null;
if (teamId !== undefined) {
team = await prisma.team.findFirstOrThrow({
where: {
id: userId,
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
teamEmail: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
let team = null;
if (teamId !== undefined) {
team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
teamEmail: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
}
return {
user,
team,
};
});
}
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
@ -120,11 +124,18 @@ export const findDocuments = async ({
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE })),
{
Recipient: {
some: {
email: user.email,
OR: [
{
Recipient: {
some: {
email: user.email,
},
},
},
},
{
userId: user.id,
},
],
},
];
@ -197,8 +208,27 @@ export const findDocuments = async ({
};
}
const whereAndClause: Prisma.DocumentWhereInput['AND'] = [
{ ...termFilters },
{ ...filters },
{ ...deletedFilter },
{ ...searchFilter },
];
if (templateId) {
whereAndClause.push({
templateId,
});
}
if (source) {
whereAndClause.push({
source,
});
}
const whereClause: Prisma.DocumentWhereInput = {
AND: [{ ...termFilters }, { ...filters }, { ...deletedFilter }, { ...searchFilter }],
AND: whereAndClause,
};
if (period) {

View File

@ -143,11 +143,18 @@ export const getDocumentWhereInput = async ({
])
.otherwise(() => [{ visibility: DocumentVisibility.EVERYONE }]),
{
Recipient: {
some: {
email: user.email,
OR: [
{
Recipient: {
some: {
email: user.email,
},
},
},
},
{
userId: user.id,
},
],
},
];

View File

@ -6,11 +6,10 @@ import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import type { Prisma, User } from '@documenso/prisma/client';
import { SigningStatus } from '@documenso/prisma/client';
import { DocumentVisibility } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DocumentVisibility } from '../../types/document-visibility';
export type GetStatsInput = {
user: User;
team?: Omit<GetTeamCountsOption, 'createdAt'>;
@ -207,47 +206,45 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
let notSignedCountsGroupByArgs = null;
let hasSignedCountsGroupByArgs = null;
const visibilityFilters = [
...match(options.currentTeamMemberRole)
.with(TeamMemberRole.ADMIN, () => [
{ visibility: DocumentVisibility.EVERYONE },
{ visibility: DocumentVisibility.MANAGER_AND_ABOVE },
{ visibility: DocumentVisibility.ADMIN },
])
.with(TeamMemberRole.MANAGER, () => [
{ visibility: DocumentVisibility.EVERYONE },
{ visibility: DocumentVisibility.MANAGER_AND_ABOVE },
])
.otherwise(() => [{ visibility: DocumentVisibility.EVERYONE }]),
];
ownerCountsWhereInput = {
...ownerCountsWhereInput,
OR: [
const visibilityFiltersWhereInput: Prisma.DocumentWhereInput = {
AND: [
{ deletedAt: null },
{
AND: [
{
visibility: {
in: visibilityFilters.map((filter) => filter.visibility),
},
},
{
Recipient: {
none: {
email: options.currentUserEmail,
OR: [
match(options.currentTeamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({
visibility: {
equals: DocumentVisibility.EVERYONE,
},
})),
{
OR: [
{ userId: options.userId },
{ Recipient: { some: { email: options.currentUserEmail } } },
],
},
],
},
{
Recipient: {
some: {
email: options.currentUserEmail,
},
},
},
],
};
ownerCountsWhereInput = {
...ownerCountsWhereInput,
...visibilityFiltersWhereInput,
...searchFilter,
};

View File

@ -0,0 +1,92 @@
import { SigningStatus } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type RejectDocumentWithTokenOptions = {
token: string;
documentId: number;
reason: string;
requestMetadata?: RequestMetadata;
};
export async function rejectDocumentWithToken({
token,
documentId,
reason,
requestMetadata,
}: RejectDocumentWithTokenOptions) {
// Find the recipient and document in a single query
const recipient = await prisma.recipient.findFirst({
where: {
token,
documentId,
},
include: {
Document: {
include: {
User: true,
},
},
},
});
const document = recipient?.Document;
if (!recipient || !document) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Document or recipient not found',
});
}
// Add the audit log entry before updating the recipient
// Update the recipient status to rejected
const [updatedRecipient] = await prisma.$transaction([
prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signedAt: new Date(),
signingStatus: SigningStatus.REJECTED,
rejectionReason: reason,
},
}),
prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
user: {
name: recipient.name,
email: recipient.email,
},
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
reason,
},
requestMetadata,
}),
}),
]);
// Send email notifications
await jobs.triggerJob({
name: 'send.signing.rejected.emails',
payload: {
recipientId: recipient.id,
documentId,
},
});
return updatedRecipient;
}

View File

@ -1,11 +1,12 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
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 {
RECIPIENT_ROLES_DESCRIPTION_ENG,
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '@documenso/lib/constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -16,7 +17,11 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getDocumentWhereInput } from './get-document-by-id';
export type ResendDocumentOptions = {
@ -62,6 +67,7 @@ export const resendDocument = async ({
select: {
teamEmail: true,
name: true,
teamGlobalSettings: true,
},
},
},
@ -86,31 +92,50 @@ export const resendDocument = async ({
throw new Error('Can not send completed document');
}
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
if (!isRecipientSigningRequestEmailEnabled) {
return;
}
await Promise.all(
document.Recipient.map(async (recipient) => {
if (recipient.role === RecipientRole.CC) {
return;
}
const i18n = await getI18nInstance(document.documentMeta?.language);
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const recipientActionVerb =
RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].actionVerb.toLowerCase();
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailMessage = customEmail?.message || '';
let emailSubject = `Reminder: Please ${recipientActionVerb} this document`;
let emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} this document`);
if (selfSigner) {
emailMessage = `You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`;
emailSubject = `Reminder: Please ${recipientActionVerb} your document`;
emailMessage = i18n._(
msg`You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} your document`);
}
if (isTeamDocument && document.team) {
emailSubject = `Reminder: ${document.team.name} invited you to ${recipientActionVerb} a document`;
emailMessage = `${user.name} on behalf of ${document.team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`;
emailSubject = i18n._(
msg`Reminder: ${document.team.name} invited you to ${recipientActionVerb} a document`,
);
emailMessage =
customEmail?.message ||
i18n._(
msg`${user.name} on behalf of ${document.team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
);
}
const customEmailTemplate = {
@ -135,8 +160,24 @@ export const resendDocument = async ({
teamName: document.team?.name,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
await prisma.$transaction(
async (tx) => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
}),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
address: email,
@ -147,10 +188,13 @@ export const resendDocument = async ({
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(`Reminder: ${customEmail.subject}`, customEmailTemplate)
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${customEmail.subject}`),
customEmailTemplate,
)
: emailSubject,
html: render(template),
text: render(template, { plainText: true }),
html,
text,
});
await tx.documentAuditLog.create({

View File

@ -10,6 +10,7 @@ import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file';
@ -45,6 +46,7 @@ export const sealDocument = async ({
},
include: {
documentData: true,
documentMeta: true,
Recipient: true,
},
});
@ -90,7 +92,9 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData);
const certificate = await getCertificatePdf({ documentId })
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
const certificate = await getCertificatePdf({ documentId, language: documentLanguage })
.then(async (doc) => PDFDocument.load(doc))
.catch(() => null);

View File

@ -1,6 +1,10 @@
import { match } from 'ts-pattern';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
export type SearchDocumentsWithKeywordOptions = {
query: string;
@ -67,10 +71,40 @@ export const searchDocumentsWithKeyword = async ({
},
deletedAt: null,
},
{
title: {
contains: query,
mode: 'insensitive',
},
teamId: {
not: null,
},
team: {
members: {
some: {
userId: userId,
},
},
},
deletedAt: null,
},
],
},
include: {
Recipient: true,
team: {
select: {
url: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
},
},
orderBy: {
createdAt: 'desc',
@ -82,15 +116,48 @@ export const searchDocumentsWithKeyword = async ({
const getSigningLink = (recipients: Recipient[], user: User) =>
`/sign/${recipients.find((r) => r.email === user.email)?.token}`;
const maskedDocuments = documents.map((document) => {
const { Recipient, ...documentWithoutRecipient } = document;
const maskedDocuments = documents
.filter((document) => {
if (!document.teamId || isOwner(document, user)) {
return true;
}
return {
...documentWithoutRecipient,
path: isOwner(document, user) ? `/documents/${document.id}` : getSigningLink(Recipient, user),
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
};
});
const teamMemberRole = document.team?.members[0]?.role;
if (!teamMemberRole) {
return false;
}
const canAccessDocument = match([document.visibility, teamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
return canAccessDocument;
})
.map((document) => {
const { Recipient, ...documentWithoutRecipient } = document;
let documentPath;
if (isOwner(document, user)) {
documentPath = `${formatDocumentsPath(document.team?.url)}/${document.id}`;
} else if (document.teamId && document.team) {
documentPath = `${formatDocumentsPath(document.team.url)}/${document.id}`;
} else {
documentPath = getSigningLink(Recipient, user);
}
return {
...documentWithoutRecipient,
path: documentPath,
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
};
});
return maskedDocuments;
};

View File

@ -1,17 +1,23 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
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';
import { DocumentSource } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { formatDocumentsPath } from '../../utils/teams';
export interface SendDocumentOptions {
documentId: number;
@ -32,6 +38,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
select: {
id: true,
url: true,
teamGlobalSettings: true,
},
},
},
@ -53,7 +60,9 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
let documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/documents/${document.id}`;
let documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(
document.team?.url,
)}/${document.id}`;
if (document.team?.url) {
documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/t/${document.team.url}/documents/${
@ -61,14 +70,36 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
}`;
}
// If the document owner is not a recipient then send the email to them separately
if (!document.Recipient.find((recipient) => recipient.email === owner.email)) {
const i18n = await getI18nInstance(document.documentMeta?.language);
const isDocumentCompletedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentCompleted;
// If the document owner is not a recipient, OR recipient emails are disabled, then send the email to them separately.
if (
!document.Recipient.find((recipient) => recipient.email === owner.email) ||
!isDocumentCompletedEmailEnabled
) {
const template = createElement(DocumentCompletedEmailTemplate, {
documentName: document.title,
assetBaseUrl,
downloadLink: documentOwnerDownloadLink,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: [
{
@ -80,9 +111,9 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
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 }),
subject: i18n._(msg`Signing Complete!`),
html,
text,
attachments: [
{
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
@ -109,6 +140,10 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
});
}
if (!isDocumentCompletedEmailEnabled) {
return;
}
await Promise.all(
document.Recipient.map(async (recipient) => {
const customEmailTemplate = {
@ -129,6 +164,19 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
: undefined,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: [
{
@ -143,9 +191,9 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
subject:
isDirectTemplate && document.documentMeta?.subject
? renderCustomEmailTemplate(document.documentMeta.subject, customEmailTemplate)
: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
: i18n._(msg`Signing Complete!`),
html,
text,
attachments: [
{
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',

View File

@ -1,11 +1,16 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export interface SendDeleteEmailOptions {
documentId: number;
@ -19,6 +24,12 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
},
include: {
User: true,
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -26,6 +37,14 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
throw new Error('Document not found');
}
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentDeleted;
if (!isDocumentDeletedEmailEnabled) {
return;
}
const { email, name } = document.User;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
@ -36,6 +55,21 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
assetBaseUrl,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance();
await mailer.sendMail({
to: {
address: email,
@ -45,8 +79,8 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Document Deleted!',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Document Deleted!`),
html,
text,
});
};

View File

@ -13,6 +13,7 @@ import {
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { getFile } from '../../universal/upload/get-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -29,7 +30,7 @@ export const sendDocument = async ({
documentId,
userId,
teamId,
sendEmail = true,
sendEmail,
requestMetadata,
}: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
@ -156,7 +157,14 @@ export const sendDocument = async ({
// throw new Error('Some signers have not been assigned a signature field.');
// }
if (sendEmail) {
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
// Only send email if one of the following is true:
// - It is explicitly set
// - The email is enabled for signing requests AND sendEmail is undefined
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {

View File

@ -1,11 +1,16 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
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';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export interface SendPendingEmailOptions {
documentId: number;
@ -28,6 +33,12 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
id: recipientId,
},
},
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -39,6 +50,14 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
throw new Error('Document has no recipients');
}
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentPending;
if (!isDocumentPendingEmailEnabled) {
return;
}
const [recipient] = document.Recipient;
const { email, name } = recipient;
@ -50,6 +69,21 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
assetBaseUrl,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
await mailer.sendMail({
to: {
address: email,
@ -59,8 +93,8 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
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 }),
subject: i18n._(msg`Waiting for others to complete signing.`),
html,
text,
});
};

View File

@ -2,17 +2,22 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export type SuperDeleteDocumentOptions = {
id: number;
@ -28,6 +33,11 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
Recipient: true,
documentMeta: true,
User: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -37,8 +47,16 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
const { status, User: user } = document;
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentDeleted;
// if the document is pending, send cancellation emails to all recipients
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
if (
status === DocumentStatus.PENDING &&
document.Recipient.length > 0 &&
isDocumentDeletedEmailEnabled
) {
await Promise.all(
document.Recipient.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
@ -53,6 +71,21 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
assetBaseUrl,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
await mailer.sendMail({
to: {
address: recipient.email,
@ -62,9 +95,9 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
}),
);

View File

@ -1,13 +1,15 @@
'use server';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { DocumentVisibility } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { DocumentVisibility } from '@documenso/prisma/client';
import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
@ -20,7 +22,7 @@ export type UpdateDocumentSettingsOptions = {
data: {
title?: string;
externalId?: string | null;
visibility?: string | null;
visibility?: DocumentVisibility | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
};
@ -63,8 +65,62 @@ export const updateDocumentSettings = async ({
teamId: null,
}),
},
include: {
team: {
select: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
},
},
});
if (teamId) {
const currentUserRole = document.team?.members[0]?.role;
match(currentUserRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => {
const allowedVisibilities: DocumentVisibility[] = [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
];
if (
!allowedVisibilities.includes(document.visibility) ||
(data.visibility && !allowedVisibilities.includes(data.visibility))
) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to update the document visibility',
);
}
})
.with(TeamMemberRole.MEMBER, () => {
if (
document.visibility !== DocumentVisibility.EVERYONE ||
(data.visibility && data.visibility !== DocumentVisibility.EVERYONE)
) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to update the document visibility',
);
}
})
.otherwise(() => {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to update the document',
);
});
}
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});

View File

@ -2,13 +2,15 @@ import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { encryptSecondaryData } from '../crypto/encrypt';
export type GetCertificatePdfOptions = {
documentId: number;
language?: SupportedLanguageCodes;
};
export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions) => {
export const getCertificatePdf = async ({ documentId, language }: GetCertificatePdfOptions) => {
const { chromium } = await import('playwright');
const encryptedId = encryptSecondaryData({
@ -32,7 +34,19 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
);
}
const page = await browser.newPage();
const browserContext = await browser.newContext();
const page = await browserContext.newPage();
if (language) {
await page.context().addCookies([
{
name: 'language',
value: language,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
]);
}
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
waitUntil: 'networkidle',
@ -43,6 +57,8 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
format: 'A4',
});
await browserContext.close();
void browser.close();
return result;

View File

@ -1,8 +1,9 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import {
@ -21,10 +22,14 @@ import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { canRecipientBeModified } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export interface SetRecipientsForDocumentOptions {
userId: number;
@ -62,6 +67,12 @@ export const setRecipientsForDocument = async ({
},
include: {
Field: true,
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -276,10 +287,14 @@ export const setRecipientsForDocument = async ({
});
});
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientRemoved;
// Send emails to deleted recipients.
await Promise.all(
removedRecipients.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientRemovedEmailEnabled) {
return;
}
@ -291,6 +306,17 @@ export const setRecipientsForDocument = async ({
assetBaseUrl,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
await mailer.sendMail({
to: {
address: recipient.email,
@ -300,9 +326,9 @@ export const setRecipientsForDocument = async ({
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'You have been removed from a document',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
}),
);

View File

@ -42,7 +42,16 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
});
if (teamMemberInvite.status === TeamMemberInviteStatus.ACCEPTED) {
return;
const memberExists = await tx.teamMember.findFirst({
where: {
teamId: teamMemberInvite.teamId,
userId: user.id,
},
});
if (memberExists) {
return;
}
}
const { team } = teamMemberInvite;
@ -81,7 +90,7 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
await jobs.triggerJob({
name: 'send.team-member-joined.email',
payload: {
teamId: team.id,
teamId: teamMember.teamId,
memberId: teamMember.id,
},
});

View File

@ -1,9 +1,9 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
@ -11,8 +11,13 @@ import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export type CreateTeamEmailVerificationOptions = {
userId: number;
teamId: number;
@ -45,6 +50,7 @@ export const createTeamEmailVerification = async ({
include: {
teamEmail: true,
emailVerification: true,
teamGlobalSettings: true,
},
});
@ -77,7 +83,7 @@ export const createTeamEmailVerification = async ({
},
});
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
await sendTeamEmailVerificationEmail(data.email, token, team);
},
{ timeout: 30_000 },
);
@ -109,27 +115,47 @@ export const createTeamEmailVerification = async ({
export const sendTeamEmailVerificationEmail = async (
email: string,
token: string,
teamName: string,
teamUrl: string,
team: Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
},
) => {
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const template = createElement(ConfirmTeamEmailTemplate, {
assetBaseUrl,
baseUrl: WEBAPP_BASE_URL,
teamName,
teamUrl,
teamName: team.name,
teamUrl: team.url,
token,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = team.teamGlobalSettings?.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `A request to use your email has been initiated by ${teamName} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(
msg`A request to use your email has been initiated by ${team.name} on Documenso`,
),
html,
text,
});
};

View File

@ -1,10 +1,9 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { nanoid } from 'nanoid';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import type { TeamInviteEmailProps } from '@documenso/email/templates/team-invite';
import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
@ -12,9 +11,14 @@ import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
import type { TCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export type CreateTeamMemberInvitesOptions = {
userId: number;
userName: string;
@ -56,6 +60,7 @@ export const createTeamMemberInvites = async ({
},
},
invites: true,
teamGlobalSettings: true,
},
});
@ -109,8 +114,7 @@ export const createTeamMemberInvites = async ({
sendTeamMemberInviteEmail({
email,
token,
teamName: team.name,
teamUrl: team.url,
team,
senderName: userName,
}),
),
@ -131,8 +135,13 @@ export const createTeamMemberInvites = async ({
}
};
type SendTeamMemberInviteEmailOptions = Omit<TeamInviteEmailProps, 'baseUrl' | 'assetBaseUrl'> & {
type SendTeamMemberInviteEmailOptions = {
email: string;
senderName: string;
token: string;
team: Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
};
};
/**
@ -140,22 +149,42 @@ type SendTeamMemberInviteEmailOptions = Omit<TeamInviteEmailProps, 'baseUrl' | '
*/
export const sendTeamMemberInviteEmail = async ({
email,
...emailTemplateOptions
senderName,
token,
team,
}: SendTeamMemberInviteEmailOptions) => {
const template = createElement(TeamInviteEmailTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
...emailTemplateOptions,
senderName,
token,
teamName: team.name,
teamUrl: team.url,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: team.teamGlobalSettings?.documentLanguage, branding }),
renderEmailWithI18N(template, {
lang: team.teamGlobalSettings?.documentLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(team.teamGlobalSettings?.documentLanguage);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been invited to join ${emailTemplateOptions.teamName} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`You have been invited to join ${team.name} on Documenso`),
html,
text,
});
};

View File

@ -1,13 +1,18 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export type DeleteTeamEmailOptions = {
userId: number;
userEmail: string;
@ -50,6 +55,7 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
email: true,
},
},
teamGlobalSettings: true,
},
});
@ -73,6 +79,19 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
teamUrl: team.url,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = team.teamGlobalSettings?.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: {
address: team.owner.email,
@ -82,9 +101,9 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `Team email has been revoked for ${team.name}`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Team email has been revoked for ${team.name}`),
html,
text,
});
} catch (e) {
// Todo: Teams - Alert us.

View File

@ -1,16 +1,20 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import type { TeamDeleteEmailProps } from '@documenso/email/templates/team-delete';
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { jobs } from '../../jobs/client';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export type DeleteTeamOptions = {
userId: number;
@ -38,6 +42,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
},
},
},
teamGlobalSettings: true,
},
});
@ -60,6 +65,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
name: team.name,
url: team.url,
ownerUserId: team.ownerUserId,
teamGlobalSettings: team.teamGlobalSettings,
},
members: team.members.map((member) => ({
id: member.user.id,
@ -80,29 +86,43 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
);
};
type SendTeamDeleteEmailOptions = Omit<TeamDeleteEmailProps, 'baseUrl' | 'assetBaseUrl'> & {
type SendTeamDeleteEmailOptions = {
email: string;
teamName: string;
team: Pick<Team, 'url' | 'name'> & {
teamGlobalSettings?: TeamGlobalSettings | null;
};
isOwner: boolean;
};
export const sendTeamDeleteEmail = async ({
email,
...emailTemplateOptions
}: SendTeamDeleteEmailOptions) => {
export const sendTeamDeleteEmail = async ({ email, isOwner, team }: SendTeamDeleteEmailOptions) => {
const template = createElement(TeamDeleteEmailTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
...emailTemplateOptions,
teamUrl: team.url,
isOwner,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = team.teamGlobalSettings?.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `Team "${emailTemplateOptions.teamName}" has been deleted on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Team "${team.name}" has been deleted on Documenso`),
html,
text,
});
};

View File

@ -30,6 +30,7 @@ export const getTeamById = async ({ userId, teamId }: GetTeamByIdOptions) => {
where: whereFilter,
include: {
teamEmail: true,
teamGlobalSettings: true,
members: {
where: {
userId,
@ -89,6 +90,7 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
},
},
subscription: true,
teamGlobalSettings: true,
members: {
where: {
userId,

View File

@ -1,13 +1,17 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { TeamTransferRequestTemplate } from '@documenso/email/templates/team-transfer-request';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export type RequestTeamOwnershipTransferOptions = {
/**
* The ID of the user initiating the transfer.
@ -93,15 +97,24 @@ export const requestTeamOwnershipTransfer = async ({
token,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
const i18n = await getI18nInstance();
await mailer.sendMail({
to: newOwnerUser.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(
msg`You have been requested to take ownership of team ${team.name} on Documenso`,
),
html,
text,
});
},
{ timeout: 30_000 },

View File

@ -33,6 +33,7 @@ export const resendTeamEmailVerification = async ({
},
include: {
emailVerification: true,
teamGlobalSettings: true,
},
});
@ -61,7 +62,7 @@ export const resendTeamEmailVerification = async ({
},
});
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
await sendTeamEmailVerificationEmail(emailVerification.email, token, team);
},
{ timeout: 30_000 },
);

View File

@ -49,6 +49,9 @@ export const resendTeamMemberInvitation = async ({
},
},
},
include: {
teamGlobalSettings: true,
},
});
if (!team) {
@ -69,9 +72,8 @@ export const resendTeamMemberInvitation = async ({
await sendTeamMemberInviteEmail({
email: teamMemberInvite.email,
token: teamMemberInvite.token,
teamName: team.name,
teamUrl: team.url,
senderName: userName,
team,
});
},
{ timeout: 30_000 },

View File

@ -0,0 +1,52 @@
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
export type UpdateTeamBrandingSettingsOptions = {
userId: number;
teamId: number;
settings: {
brandingEnabled: boolean;
brandingLogo: string;
brandingUrl: string;
brandingCompanyDetails: string;
};
};
export const updateTeamBrandingSettings = async ({
userId,
teamId,
settings,
}: UpdateTeamBrandingSettingsOptions) => {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = settings;
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
},
});
if (!member || member.role !== TeamMemberRole.ADMIN) {
throw new Error('You do not have permission to update this team.');
}
return await prisma.teamGlobalSettings.upsert({
where: {
teamId,
},
create: {
teamId,
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
},
update: {
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
},
});
};

View File

@ -0,0 +1,52 @@
import { prisma } from '@documenso/prisma';
import type { DocumentVisibility } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import type { SupportedLanguageCodes } from '../../constants/i18n';
export type UpdateTeamDocumentSettingsOptions = {
userId: number;
teamId: number;
settings: {
documentVisibility: DocumentVisibility;
documentLanguage: SupportedLanguageCodes;
includeSenderDetails: boolean;
};
};
export const updateTeamDocumentSettings = async ({
userId,
teamId,
settings,
}: UpdateTeamDocumentSettingsOptions) => {
const { documentVisibility, documentLanguage, includeSenderDetails } = settings;
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
},
});
if (!member || member.role !== TeamMemberRole.ADMIN) {
throw new Error('You do not have permission to update this team.');
}
return await prisma.teamGlobalSettings.upsert({
where: {
teamId,
},
create: {
teamId,
documentVisibility,
documentLanguage,
includeSenderDetails,
},
update: {
documentVisibility,
documentLanguage,
includeSenderDetails,
},
});
};

View File

@ -4,6 +4,7 @@ import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
import type { DocumentVisibility } from '@documenso/prisma/client';
export type UpdateTeamOptions = {
userId: number;
@ -11,6 +12,8 @@ export type UpdateTeamOptions = {
data: {
name?: string;
url?: string;
documentVisibility?: DocumentVisibility;
includeSenderDetails?: boolean;
};
};
@ -42,6 +45,18 @@ export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions) =>
data: {
url: data.url,
name: data.name,
teamGlobalSettings: {
upsert: {
create: {
documentVisibility: data.documentVisibility,
includeSenderDetails: data.includeSenderDetails,
},
update: {
documentVisibility: data.documentVisibility,
includeSenderDetails: data.includeSenderDetails,
},
},
},
},
});

View File

@ -1,15 +1,16 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Field, Signature } from '@documenso/prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
DocumentStatus,
FieldType,
@ -21,6 +22,7 @@ import {
} from '@documenso/prisma/client';
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
@ -37,6 +39,8 @@ import {
createRecipientAuthOptions,
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { formatDocumentsPath } from '../../utils/teams';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
@ -88,6 +92,11 @@ export const createDocumentFromDirectTemplate = async ({
templateDocumentData: true,
templateMeta: true,
User: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -142,6 +151,9 @@ export const createDocumentFromDirectTemplate = async ({
const metaDateFormat = template.templateMeta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT;
const metaEmailMessage = template.templateMeta?.message || '';
const metaEmailSubject = template.templateMeta?.subject || '';
const metaLanguage =
template.templateMeta?.language ?? template.team?.teamGlobalSettings?.documentLanguage;
const metaSigningOrder = template.templateMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
// Associate, validate and map to a query every direct template recipient field with the provided fields.
const createDirectRecipientFieldArgs = await Promise.all(
@ -232,6 +244,7 @@ export const createDocumentFromDirectTemplate = async ({
createdAt: initialRequestTime,
status: DocumentStatus.PENDING,
externalId: directTemplateExternalId,
visibility: template.team?.teamGlobalSettings?.documentVisibility,
documentDataId: documentData.id,
authOptions: createDocumentAuthOptions({
globalAccessAuth: templateAuthOptions.globalAccessAuth,
@ -256,6 +269,7 @@ export const createDocumentFromDirectTemplate = async ({
recipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
token: nanoid(),
};
}),
@ -267,6 +281,9 @@ export const createDocumentFromDirectTemplate = async ({
dateFormat: metaDateFormat,
message: metaEmailMessage,
subject: metaEmailSubject,
language: metaLanguage,
signingOrder: metaSigningOrder,
distributionMethod: template.templateMeta?.distributionMethod,
},
},
},
@ -330,6 +347,7 @@ export const createDocumentFromDirectTemplate = async ({
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
signedAt: initialRequestTime,
signingOrder: directTemplateRecipient.signingOrder,
Field: {
createMany: {
data: directTemplateNonSignatureFields.map(({ templateField, customText }) => ({
@ -524,6 +542,17 @@ export const createDocumentFromDirectTemplate = async ({
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});
const branding = template.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(template.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(metaLanguage);
await mailer.sendMail({
to: [
{
@ -535,9 +564,9 @@ export const createDocumentFromDirectTemplate = async ({
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Document created from direct template',
html: render(emailTemplate),
text: render(emailTemplate, { plainText: true }),
subject: i18n._(msg`Document created from direct template`),
html,
text,
});
return {

View File

@ -46,6 +46,12 @@ export const createDocumentFromTemplateLegacy = async ({
Recipient: true,
Field: true,
templateDocumentData: true,
templateMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -68,6 +74,7 @@ export const createDocumentFromTemplateLegacy = async ({
userId,
teamId: template.teamId,
title: template.title,
visibility: template.team?.teamGlobalSettings?.documentVisibility,
documentDataId: documentData.id,
Recipient: {
create: template.Recipient.map((recipient) => ({
@ -78,6 +85,18 @@ export const createDocumentFromTemplateLegacy = async ({
token: nanoid(),
})),
},
documentMeta: {
create: {
subject: template.templateMeta?.subject,
message: template.templateMeta?.message,
timezone: template.templateMeta?.timezone,
dateFormat: template.templateMeta?.dateFormat,
redirectUrl: template.templateMeta?.redirectUrl,
signingOrder: template.templateMeta?.signingOrder ?? undefined,
language:
template.templateMeta?.language || template.team?.teamGlobalSettings?.documentLanguage,
},
},
},
include: {

View File

@ -1,5 +1,6 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { DocumentDistributionMethod } from '@documenso/prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
@ -11,6 +12,7 @@ import {
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
@ -24,7 +26,10 @@ import {
} from '../../utils/document-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
type FinalRecipient = Pick<
Recipient,
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder'
> & {
templateRecipientId: number;
fields: Field[];
};
@ -57,6 +62,8 @@ export type CreateDocumentFromTemplateOptions = {
dateFormat?: string;
redirectUrl?: string;
signingOrder?: DocumentSigningOrder;
language?: SupportedLanguageCodes;
distributionMethod?: DocumentDistributionMethod;
};
requestMetadata?: RequestMetadata;
};
@ -103,6 +110,11 @@ export const createDocumentFromTemplate = async ({
},
templateDocumentData: true,
templateMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -164,6 +176,7 @@ export const createDocumentFromTemplate = async ({
globalAccessAuth: templateAuthOptions.globalAccessAuth,
globalActionAuth: templateAuthOptions.globalActionAuth,
}),
visibility: template.team?.teamGlobalSettings?.documentVisibility,
documentMeta: {
create: {
subject: override?.subject || template.templateMeta?.subject,
@ -172,10 +185,17 @@ export const createDocumentFromTemplate = async ({
password: override?.password || template.templateMeta?.password,
dateFormat: override?.dateFormat || template.templateMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
distributionMethod:
override?.distributionMethod || template.templateMeta?.distributionMethod,
emailSettings: template.templateMeta?.emailSettings || undefined,
signingOrder:
override?.signingOrder ||
template.templateMeta?.signingOrder ||
DocumentSigningOrder.PARALLEL,
language:
override?.language ||
template.templateMeta?.language ||
template.team?.teamGlobalSettings?.documentLanguage,
},
},
Recipient: {
@ -197,6 +217,7 @@ export const createDocumentFromTemplate = async ({
recipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
token: nanoid(),
};
}),

View File

@ -1,3 +1,5 @@
import { omit } from 'remeda';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
@ -38,6 +40,7 @@ export const duplicateTemplate = async ({
Recipient: true,
Field: true,
templateDocumentData: true,
templateMeta: true,
},
});
@ -53,6 +56,17 @@ export const duplicateTemplate = async ({
},
});
let templateMeta: Prisma.TemplateCreateArgs['data']['templateMeta'] | undefined = undefined;
if (template.templateMeta) {
templateMeta = {
create: {
...omit(template.templateMeta, ['id', 'templateId']),
emailSettings: template.templateMeta.emailSettings || undefined,
},
};
}
const duplicatedTemplate = await prisma.template.create({
data: {
userId,
@ -66,8 +80,8 @@ export const duplicateTemplate = async ({
token: nanoid(),
})),
},
templateMeta,
},
include: {
Recipient: true,
},

View File

@ -54,6 +54,7 @@ export const findTemplates = async ({
templateMeta: {
select: {
signingOrder: true,
distributionMethod: true,
},
},
directLink: {

View File

@ -42,6 +42,13 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
templateMeta: true,
Recipient: true,
Field: true,
User: {
select: {
id: true,
name: true,
email: true,
},
},
},
});

View File

@ -112,9 +112,11 @@ export const updateTemplateSettings = async ({
},
create: {
...meta,
emailSettings: meta?.emailSettings || undefined,
},
update: {
...meta,
emailSettings: meta?.emailSettings || undefined,
},
},
},

View File

@ -3,7 +3,7 @@ import { hash } from '@node-rs/bcrypt';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { prisma } from '@documenso/prisma';
import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/prisma/client';
import { IdentityProvider, TeamMemberInviteStatus } from '@documenso/prisma/client';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { SALT_ROUNDS } from '../../constants/auth';
@ -59,11 +59,11 @@ export const createUser = async ({ name, email, password, signature, url }: Crea
const acceptedTeamInvites = await prisma.teamMemberInvite.findMany({
where: {
status: TeamMemberInviteStatus.ACCEPTED,
email: {
equals: email,
mode: Prisma.QueryMode.insensitive,
mode: 'insensitive',
},
status: TeamMemberInviteStatus.ACCEPTED,
},
});

View File

@ -39,7 +39,7 @@ export const sendConfirmationToken = async ({
mostRecentToken?.createdAt &&
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
return;
// return;
}
const createdToken = await prisma.verificationToken.create({
@ -64,6 +64,7 @@ export const sendConfirmationToken = async ({
return { success: true };
} catch (err) {
console.log(err);
throw new Error(`Failed to send the confirmation email`);
}
};