mirror of
https://github.com/documenso/documenso.git
synced 2025-11-21 20:21:38 +10:00
Merge branch 'main' into feat/automated-fields-signature
This commit is contained in:
@ -1,21 +1,25 @@
|
||||
import { base32 } from '@scure/base';
|
||||
import { TOTPController } from 'oslo/otp';
|
||||
import { generateHOTP } from 'oslo/otp';
|
||||
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricDecrypt } from '../../universal/crypto';
|
||||
|
||||
const totp = new TOTPController();
|
||||
|
||||
type VerifyTwoFactorAuthenticationTokenOptions = {
|
||||
user: User;
|
||||
totpCode: string;
|
||||
// The number of windows to look back
|
||||
window?: number;
|
||||
// The duration that the token is valid for in seconds
|
||||
period?: number;
|
||||
};
|
||||
|
||||
export const verifyTwoFactorAuthenticationToken = async ({
|
||||
user,
|
||||
totpCode,
|
||||
window = 1,
|
||||
period = 30_000,
|
||||
}: VerifyTwoFactorAuthenticationTokenOptions) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
@ -27,7 +31,21 @@ export const verifyTwoFactorAuthenticationToken = async ({
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const isValidToken = await totp.verify(totpCode, base32.decode(secret));
|
||||
const decodedSecret = base32.decode(secret);
|
||||
|
||||
return isValidToken;
|
||||
let now = Date.now();
|
||||
|
||||
for (let i = 0; i < window; i++) {
|
||||
const counter = Math.floor(now / period);
|
||||
|
||||
const hotp = await generateHOTP(decodedSecret, counter);
|
||||
|
||||
if (totpCode === hotp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
now -= period;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -7,6 +7,10 @@ import {
|
||||
diffDocumentMetaChanges,
|
||||
} from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
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;
|
||||
@ -16,6 +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;
|
||||
};
|
||||
@ -29,6 +38,11 @@ export const upsertDocumentMeta = async ({
|
||||
password,
|
||||
userId,
|
||||
redirectUrl,
|
||||
signingOrder,
|
||||
emailSettings,
|
||||
distributionMethod,
|
||||
typedSignatureEnabled,
|
||||
language,
|
||||
requestMetadata,
|
||||
}: CreateDocumentMetaOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@ -78,6 +92,11 @@ export const upsertDocumentMeta = async ({
|
||||
timezone,
|
||||
documentId,
|
||||
redirectUrl,
|
||||
signingOrder,
|
||||
emailSettings,
|
||||
distributionMethod,
|
||||
typedSignatureEnabled,
|
||||
language,
|
||||
},
|
||||
update: {
|
||||
subject,
|
||||
@ -86,6 +105,11 @@ export const upsertDocumentMeta = async ({
|
||||
dateFormat,
|
||||
timezone,
|
||||
redirectUrl,
|
||||
signingOrder,
|
||||
emailSettings,
|
||||
distributionMethod,
|
||||
typedSignatureEnabled,
|
||||
language,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -2,11 +2,18 @@ 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 { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { jobs } from '../../jobs/client';
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
|
||||
@ -29,6 +36,7 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
|
||||
},
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
Recipient: {
|
||||
where: {
|
||||
token,
|
||||
@ -59,6 +67,16 @@ export const completeDocumentWithToken = async ({
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
||||
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
throw new Error(
|
||||
`Recipient ${recipient.id} attempted to complete the document before it was their turn`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
@ -120,17 +138,48 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
});
|
||||
|
||||
const pendingRecipients = await prisma.recipient.count({
|
||||
const pendingRecipients = await prisma.recipient.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
signingOrder: true,
|
||||
},
|
||||
where: {
|
||||
documentId: document.id,
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
// Composite sort so our next recipient is always the one with the lowest signing order or id
|
||||
// if there is a tie.
|
||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||
});
|
||||
|
||||
if (pendingRecipients > 0) {
|
||||
if (pendingRecipients.length > 0) {
|
||||
await sendPendingEmail({ documentId, recipientId: recipient.id });
|
||||
|
||||
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
const [nextRecipient] = pendingRecipients;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.recipient.update({
|
||||
where: { id: nextRecipient.id },
|
||||
data: { sendStatus: SendStatus.SENT },
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId: document.userId,
|
||||
documentId: document.id,
|
||||
recipientId: nextRecipient.id,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const haveAllRecipientsSigned = await prisma.document.findFirst({
|
||||
@ -138,7 +187,7 @@ export const completeDocumentWithToken = async ({
|
||||
id: document.id,
|
||||
Recipient: {
|
||||
every: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
OR: [{ signingStatus: SigningStatus.SIGNED }, { role: RecipientRole.CC }],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
],
|
||||
|
||||
@ -2,10 +2,18 @@ import { DateTime } from 'luxon';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Document, Prisma, Team, TeamEmail, User } from '@documenso/prisma/client';
|
||||
import { RecipientRole, SigningStatus, TeamMemberRole } 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';
|
||||
import type { FindResultSet } from '../../types/find-result-set';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
|
||||
@ -15,6 +23,8 @@ export type FindDocumentsOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
term?: string;
|
||||
templateId?: number;
|
||||
source?: DocumentSource;
|
||||
status?: ExtendedDocumentStatus;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
@ -24,52 +34,58 @@ export type FindDocumentsOptions = {
|
||||
};
|
||||
period?: PeriodSelectorValue;
|
||||
senderIds?: number[];
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export const findDocuments = async ({
|
||||
userId,
|
||||
teamId,
|
||||
term,
|
||||
templateId,
|
||||
source,
|
||||
status = ExtendedDocumentStatus.ALL,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
period,
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
let team = null;
|
||||
|
||||
if (teamId !== undefined) {
|
||||
team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
teamEmail: true,
|
||||
},
|
||||
include: {
|
||||
teamEmail: true,
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
team,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
const teamMemberRole = team?.members[0].role ?? null;
|
||||
|
||||
const termFilters = match(term)
|
||||
.with(P.string.minLength(1), () => {
|
||||
@ -82,7 +98,52 @@ export const findDocuments = async ({
|
||||
})
|
||||
.otherwise(() => undefined);
|
||||
|
||||
const filters = team ? findTeamDocumentsFilter(status, team) : findDocumentsFilter(status, user);
|
||||
const searchFilter: Prisma.DocumentWhereInput = {
|
||||
OR: [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
{ Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
|
||||
],
|
||||
};
|
||||
|
||||
const visibilityFilters = [
|
||||
match(teamMemberRole)
|
||||
.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: DocumentVisibility.EVERYONE })),
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user);
|
||||
|
||||
if (team) {
|
||||
filters = findTeamDocumentsFilter(status, team, visibilityFilters);
|
||||
}
|
||||
|
||||
if (filters === null) {
|
||||
return {
|
||||
@ -147,10 +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 = {
|
||||
...termFilters,
|
||||
...filters,
|
||||
...deletedFilter,
|
||||
AND: whereAndClause,
|
||||
};
|
||||
|
||||
if (period) {
|
||||
@ -333,6 +411,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
const findTeamDocumentsFilter = (
|
||||
status: ExtendedDocumentStatus,
|
||||
team: Team & { teamEmail: TeamEmail | null },
|
||||
visibilityFilters: Prisma.DocumentWhereInput[],
|
||||
) => {
|
||||
const teamEmail = team.teamEmail?.email ?? null;
|
||||
|
||||
@ -343,6 +422,7 @@ const findTeamDocumentsFilter = (
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -358,6 +438,7 @@ const findTeamDocumentsFilter = (
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
});
|
||||
|
||||
// Filter to display all documents that have been sent by the team email.
|
||||
@ -365,6 +446,7 @@ const findTeamDocumentsFilter = (
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
});
|
||||
}
|
||||
|
||||
@ -389,6 +471,7 @@ const findTeamDocumentsFilter = (
|
||||
},
|
||||
},
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
};
|
||||
})
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => {
|
||||
@ -397,6 +480,7 @@ const findTeamDocumentsFilter = (
|
||||
{
|
||||
teamId: team.id,
|
||||
status: ExtendedDocumentStatus.DRAFT,
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -407,6 +491,7 @@ const findTeamDocumentsFilter = (
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
});
|
||||
}
|
||||
|
||||
@ -418,6 +503,7 @@ const findTeamDocumentsFilter = (
|
||||
{
|
||||
teamId: team.id,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -436,11 +522,13 @@ const findTeamDocumentsFilter = (
|
||||
},
|
||||
},
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
{
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
],
|
||||
});
|
||||
@ -454,6 +542,7 @@ const findTeamDocumentsFilter = (
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -466,11 +555,13 @@ const findTeamDocumentsFilter = (
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
{
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type GetDocumentByIdOptions = {
|
||||
@ -28,6 +32,11 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
Recipient: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
@ -115,5 +124,42 @@ export const getDocumentWhereInput = async ({
|
||||
);
|
||||
}
|
||||
|
||||
return documentWhereInput;
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const visibilityFilters = [
|
||||
...match(team.currentTeamMember?.role)
|
||||
.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 }]),
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
...documentWhereInput,
|
||||
OR: [...visibilityFilters],
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
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';
|
||||
|
||||
@ -11,9 +14,10 @@ export type GetStatsInput = {
|
||||
user: User;
|
||||
team?: Omit<GetTeamCountsOption, 'createdAt'>;
|
||||
period?: PeriodSelectorValue;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
|
||||
export const getStats = async ({ user, period, search, ...options }: GetStatsInput) => {
|
||||
let createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
|
||||
if (period) {
|
||||
@ -27,8 +31,14 @@ export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
|
||||
}
|
||||
|
||||
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
|
||||
? getTeamCounts({ ...options.team, createdAt })
|
||||
: getCounts({ user, createdAt }));
|
||||
? getTeamCounts({
|
||||
...options.team,
|
||||
createdAt,
|
||||
currentUserEmail: user.email,
|
||||
userId: user.id,
|
||||
search,
|
||||
})
|
||||
: getCounts({ user, createdAt, search }));
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
@ -68,9 +78,18 @@ export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
|
||||
type GetCountsOption = {
|
||||
user: User;
|
||||
createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
search?: string;
|
||||
};
|
||||
|
||||
const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
||||
const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
|
||||
const searchFilter: Prisma.DocumentWhereInput = {
|
||||
OR: [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
{ Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
|
||||
],
|
||||
};
|
||||
|
||||
return Promise.all([
|
||||
// Owner counts.
|
||||
prisma.document.groupBy({
|
||||
@ -83,6 +102,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
||||
createdAt,
|
||||
teamId: null,
|
||||
deletedAt: null,
|
||||
AND: [searchFilter],
|
||||
},
|
||||
}),
|
||||
// Not signed counts.
|
||||
@ -101,6 +121,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
||||
},
|
||||
},
|
||||
createdAt,
|
||||
AND: [searchFilter],
|
||||
},
|
||||
}),
|
||||
// Has signed counts.
|
||||
@ -138,6 +159,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: [searchFilter],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
@ -147,7 +169,11 @@ type GetTeamCountsOption = {
|
||||
teamId: number;
|
||||
teamEmail?: string;
|
||||
senderIds?: number[];
|
||||
currentUserEmail: string;
|
||||
userId: number;
|
||||
createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
currentTeamMemberRole?: TeamMemberRole;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
@ -162,6 +188,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const searchFilter: Prisma.DocumentWhereInput = {
|
||||
OR: [
|
||||
{ title: { contains: options.search, mode: 'insensitive' } },
|
||||
{ Recipient: { some: { name: { contains: options.search, mode: 'insensitive' } } } },
|
||||
{ Recipient: { some: { email: { contains: options.search, mode: 'insensitive' } } } },
|
||||
],
|
||||
};
|
||||
|
||||
let ownerCountsWhereInput: Prisma.DocumentWhereInput = {
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
@ -172,6 +206,48 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
let notSignedCountsGroupByArgs = null;
|
||||
let hasSignedCountsGroupByArgs = null;
|
||||
|
||||
const visibilityFiltersWhereInput: Prisma.DocumentWhereInput = {
|
||||
AND: [
|
||||
{ deletedAt: null },
|
||||
{
|
||||
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 } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
ownerCountsWhereInput = {
|
||||
...ownerCountsWhereInput,
|
||||
...visibilityFiltersWhereInput,
|
||||
...searchFilter,
|
||||
};
|
||||
|
||||
if (teamEmail) {
|
||||
ownerCountsWhereInput = {
|
||||
userId: userIdWhereClause,
|
||||
|
||||
@ -112,6 +112,7 @@ export const isRecipientAuthorized = async ({
|
||||
return await verifyTwoFactorAuthenticationToken({
|
||||
user,
|
||||
totpCode: token,
|
||||
window: 10, // 5 minutes worth of tokens
|
||||
});
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -3,10 +3,17 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
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';
|
||||
@ -23,7 +30,7 @@ export const sendDocument = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
sendEmail = true,
|
||||
sendEmail,
|
||||
requestMetadata,
|
||||
}: SendDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@ -57,7 +64,9 @@ export const sendDocument = async ({
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
Recipient: {
|
||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||
},
|
||||
documentMeta: true,
|
||||
documentData: true,
|
||||
},
|
||||
@ -75,6 +84,21 @@ export const sendDocument = async ({
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
const signingOrder = document.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
|
||||
|
||||
let recipientsToNotify = document.Recipient;
|
||||
|
||||
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
// Get the currently active recipient.
|
||||
recipientsToNotify = document.Recipient.filter(
|
||||
(r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC,
|
||||
).slice(0, 1);
|
||||
|
||||
// Secondary filter so we aren't resending if the current active recipient has already
|
||||
// received the document.
|
||||
recipientsToNotify.filter((r) => r.sendStatus !== SendStatus.SENT);
|
||||
}
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
if (!documentData.data) {
|
||||
@ -133,9 +157,16 @@ 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(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
recipientsToNotify.map(async (recipient) => {
|
||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@ -1,12 +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 { 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';
|
||||
@ -19,6 +22,7 @@ export type UpdateDocumentSettingsOptions = {
|
||||
data: {
|
||||
title?: string;
|
||||
externalId?: string | null;
|
||||
visibility?: DocumentVisibility | null;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||
globalActionAuth?: TDocumentActionAuthTypes | null;
|
||||
};
|
||||
@ -61,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,
|
||||
});
|
||||
@ -91,10 +149,14 @@ export const updateDocumentSettings = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const isTitleSame = data.title === document.title;
|
||||
const isExternalIdSame = data.externalId === document.externalId;
|
||||
const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth;
|
||||
const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth;
|
||||
const isTitleSame = data.title === undefined || data.title === document.title;
|
||||
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
|
||||
const isGlobalAccessSame =
|
||||
documentGlobalAccessAuth === undefined || documentGlobalAccessAuth === newGlobalAccessAuth;
|
||||
const isGlobalActionSame =
|
||||
documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth;
|
||||
const isDocumentVisibilitySame =
|
||||
data.visibility === undefined || data.visibility === document.visibility;
|
||||
|
||||
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
|
||||
|
||||
@ -165,6 +227,21 @@ export const updateDocumentSettings = async ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDocumentVisibilitySame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED,
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
from: document.visibility,
|
||||
to: data.visibility || '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Early return if nothing is required.
|
||||
if (auditLogs.length === 0) {
|
||||
return document;
|
||||
@ -182,7 +259,8 @@ export const updateDocumentSettings = async ({
|
||||
},
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
externalId: data.externalId,
|
||||
visibility: data.visibility as DocumentVisibility,
|
||||
authOptions,
|
||||
},
|
||||
});
|
||||
|
||||
@ -5,7 +5,11 @@ import { getToken } from 'next-auth/jwt';
|
||||
import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
|
||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||
|
||||
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
|
||||
NEXT_PUBLIC_MARKETING_URL,
|
||||
NEXT_PUBLIC_WEBAPP_URL,
|
||||
} from '../../constants/app';
|
||||
import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
|
||||
|
||||
/**
|
||||
@ -46,6 +50,10 @@ export default async function handlerFeatureFlagAll(req: Request) {
|
||||
if (origin.startsWith(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001')) {
|
||||
res.headers.set('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
|
||||
if (origin.startsWith(NEXT_PRIVATE_INTERNAL_WEBAPP_URL ?? 'http://localhost:3000')) {
|
||||
res.headers.set('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
|
||||
@ -7,7 +7,11 @@ import { getToken } from 'next-auth/jwt';
|
||||
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||
|
||||
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
|
||||
NEXT_PUBLIC_MARKETING_URL,
|
||||
NEXT_PUBLIC_WEBAPP_URL,
|
||||
} from '../../constants/app';
|
||||
|
||||
/**
|
||||
* Evaluate a single feature flag based on the current user if possible.
|
||||
@ -67,6 +71,10 @@ export default async function handleFeatureFlagGet(req: Request) {
|
||||
if (origin.startsWith(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001')) {
|
||||
res.headers.set('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
|
||||
if (origin.startsWith(NEXT_PRIVATE_INTERNAL_WEBAPP_URL ?? 'http://localhost:3000')) {
|
||||
res.headers.set('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { isDeepEqual } from 'remeda';
|
||||
|
||||
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
|
||||
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
||||
@ -20,22 +22,15 @@ import {
|
||||
} from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Field } from '@documenso/prisma/client';
|
||||
import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
|
||||
export interface SetFieldsForDocumentOptions {
|
||||
userId: number;
|
||||
documentId: number;
|
||||
fields: {
|
||||
id?: number | null;
|
||||
type: FieldType;
|
||||
signerEmail: string;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
fieldMeta?: FieldMeta;
|
||||
}[];
|
||||
fields: FieldData[];
|
||||
requestMetadata?: RequestMetadata;
|
||||
}
|
||||
|
||||
@ -63,6 +58,9 @@ export const setFieldsForDocument = async ({
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@ -97,21 +95,36 @@ export const setFieldsForDocument = async ({
|
||||
(existingField) => !fields.find((field) => field.id === existingField.id),
|
||||
);
|
||||
|
||||
const linkedFields = fields
|
||||
.map((field) => {
|
||||
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
||||
const linkedFields = fields.map((field) => {
|
||||
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
||||
|
||||
return {
|
||||
...field,
|
||||
_persisted: existing,
|
||||
};
|
||||
})
|
||||
.filter((field) => {
|
||||
return (
|
||||
field._persisted?.Recipient?.sendStatus !== SendStatus.SENT &&
|
||||
field._persisted?.Recipient?.signingStatus !== SigningStatus.SIGNED
|
||||
const recipient = document.Recipient.find(
|
||||
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
|
||||
);
|
||||
|
||||
// Each field MUST have a recipient associated with it.
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, `Recipient not found for field ${field.id}`);
|
||||
}
|
||||
|
||||
// Check whether the existing field can be modified.
|
||||
if (
|
||||
existing &&
|
||||
hasFieldBeenChanged(existing, field) &&
|
||||
!canRecipientFieldsBeModified(recipient, existingFields)
|
||||
) {
|
||||
throw new AppError(
|
||||
AppErrorCode.INVALID_REQUEST,
|
||||
'Cannot modify a field where the recipient has already interacted with the document',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
_persisted: existing,
|
||||
_recipient: recipient,
|
||||
};
|
||||
});
|
||||
|
||||
const persistedFields = await prisma.$transaction(async (tx) => {
|
||||
return await Promise.all(
|
||||
@ -322,3 +335,33 @@ export const setFieldsForDocument = async ({
|
||||
|
||||
return [...filteredFields, ...persistedFields];
|
||||
};
|
||||
|
||||
/**
|
||||
* If you change this you MUST update the `hasFieldBeenChanged` function.
|
||||
*/
|
||||
type FieldData = {
|
||||
id?: number | null;
|
||||
type: FieldType;
|
||||
signerEmail: string;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
fieldMeta?: FieldMeta;
|
||||
};
|
||||
|
||||
const hasFieldBeenChanged = (field: Field, newFieldData: FieldData) => {
|
||||
const currentFieldMeta = field.fieldMeta || null;
|
||||
const newFieldMeta = newFieldData.fieldMeta || null;
|
||||
|
||||
return (
|
||||
field.type !== newFieldData.type ||
|
||||
field.page !== newFieldData.pageNumber ||
|
||||
field.positionX.toNumber() !== newFieldData.pageX ||
|
||||
field.positionY.toNumber() !== newFieldData.pageY ||
|
||||
field.width.toNumber() !== newFieldData.pageWidth ||
|
||||
field.height.toNumber() !== newFieldData.pageHeight ||
|
||||
!isDeepEqual(currentFieldMeta, newFieldMeta)
|
||||
);
|
||||
};
|
||||
|
||||
@ -107,7 +107,10 @@ export const setFieldsForTemplate = async ({
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.CHECKBOX && field.fieldMeta) {
|
||||
if (field.type === FieldType.CHECKBOX) {
|
||||
if (!field.fieldMeta) {
|
||||
throw new Error('Checkbox field is missing required metadata');
|
||||
}
|
||||
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateCheckboxField(
|
||||
checkboxFieldParsedMeta?.values?.map((item) => item.value) ?? [],
|
||||
@ -118,7 +121,10 @@ export const setFieldsForTemplate = async ({
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.RADIO && field.fieldMeta) {
|
||||
if (field.type === FieldType.RADIO) {
|
||||
if (!field.fieldMeta) {
|
||||
throw new Error('Radio field is missing required metadata');
|
||||
}
|
||||
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
|
||||
const checkedRadioFieldValue = radioFieldParsedMeta.values?.find(
|
||||
(option) => option.checked,
|
||||
@ -129,7 +135,10 @@ export const setFieldsForTemplate = async ({
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DROPDOWN && field.fieldMeta) {
|
||||
if (field.type === FieldType.DROPDOWN) {
|
||||
if (!field.fieldMeta) {
|
||||
throw new Error('Dropdown field is missing required metadata');
|
||||
}
|
||||
const dropdownFieldParsedMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateDropdownField(undefined, dropdownFieldParsedMeta);
|
||||
if (errors.length > 0) {
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export const getLocale = () => {
|
||||
const headerItems = headers();
|
||||
|
||||
const locales = headerItems.get('accept-language') ?? 'en-US';
|
||||
|
||||
const [locale] = locales.split(',');
|
||||
|
||||
return locale;
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -4,5 +4,8 @@ import { cookies } from 'next/headers';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
export const switchI18NLanguage = async (lang: string) => {
|
||||
cookies().set('i18n', lang);
|
||||
// Two year expiry.
|
||||
const maxAge = 60 * 60 * 24 * 365 * 2;
|
||||
|
||||
cookies().set('language', lang, { maxAge });
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { PDFDocument, RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
|
||||
import type { PDFDocument } from 'pdf-lib';
|
||||
import { RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
@ -13,7 +14,16 @@ import { FieldType } from '@documenso/prisma/client';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
|
||||
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDateFieldMeta,
|
||||
ZEmailFieldMeta,
|
||||
ZInitialsFieldMeta,
|
||||
ZNameFieldMeta,
|
||||
ZNumberFieldMeta,
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '../../types/field-meta';
|
||||
|
||||
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||
const fontCaveat = await fetch(process.env.FONT_CAVEAT_URI).then(async (res) =>
|
||||
@ -32,7 +42,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
|
||||
const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
|
||||
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
|
||||
let fontSize = maxFontSize;
|
||||
|
||||
const page = pages.at(field.page - 1);
|
||||
|
||||
@ -207,16 +216,33 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
}
|
||||
})
|
||||
.otherwise((field) => {
|
||||
const fieldMetaParsers = {
|
||||
[FieldType.TEXT]: ZTextFieldMeta,
|
||||
[FieldType.NUMBER]: ZNumberFieldMeta,
|
||||
[FieldType.DATE]: ZDateFieldMeta,
|
||||
[FieldType.EMAIL]: ZEmailFieldMeta,
|
||||
[FieldType.NAME]: ZNameFieldMeta,
|
||||
[FieldType.INITIALS]: ZInitialsFieldMeta,
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const Parser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
|
||||
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
|
||||
|
||||
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
|
||||
const longestLineInTextForWidth = field.customText
|
||||
.split('\n')
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
|
||||
let fontSize = customFontSize || maxFontSize;
|
||||
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
const textHeight = font.heightAtSize(fontSize);
|
||||
|
||||
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
||||
if (!customFontSize) {
|
||||
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
||||
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
||||
}
|
||||
|
||||
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
||||
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
|
||||
let textX = fieldX + (fieldWidth - textWidth) / 2;
|
||||
@ -250,17 +276,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
return pdf;
|
||||
};
|
||||
|
||||
export const insertFieldInPDFBytes = async (
|
||||
pdf: ArrayBuffer | Uint8Array | string,
|
||||
field: FieldWithSignature,
|
||||
) => {
|
||||
const pdfDoc = await PDFDocument.load(pdf);
|
||||
|
||||
await insertFieldInPDF(pdfDoc, field);
|
||||
|
||||
return await pdfDoc.save();
|
||||
};
|
||||
|
||||
const adjustPositionForRotation = (
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
|
||||
@ -10,6 +10,7 @@ export async function insertTextInPDF(
|
||||
positionY: number,
|
||||
page = 0,
|
||||
useHandwritingFont = true,
|
||||
customFontSize?: number,
|
||||
): Promise<string> {
|
||||
// Fetch the font file from the public URL.
|
||||
const fontResponse = await fetch(CAVEAT_FONT_PATH());
|
||||
@ -24,7 +25,7 @@ export async function insertTextInPDF(
|
||||
const pages = pdfDoc.getPages();
|
||||
const pdfPage = pages[page];
|
||||
|
||||
const textSize = useHandwritingFont ? 50 : 15;
|
||||
const textSize = customFontSize || (useHandwritingFont ? 50 : 15);
|
||||
const textWidth = font.widthOfTextAtSize(text, textSize);
|
||||
const textHeight = font.heightAtSize(textSize);
|
||||
const fieldSize = { width: 250, height: 64 };
|
||||
|
||||
@ -51,7 +51,7 @@ export const createApiToken = async ({
|
||||
name: tokenName,
|
||||
token: hashedToken,
|
||||
expires: expiresIn ? DateTime.now().plus(timeConstantsRecords[expiresIn]).toJSDate() : null,
|
||||
userId: teamId ? null : userId,
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
@ -25,8 +25,7 @@ export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOpt
|
||||
return await prisma.apiToken.delete({
|
||||
where: {
|
||||
id,
|
||||
userId: teamId ? null : userId,
|
||||
teamId,
|
||||
teamId: teamId ?? null,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -8,6 +8,7 @@ export const getUserTokens = async ({ userId }: GetUserTokensOptions) => {
|
||||
return await prisma.apiToken.findMany({
|
||||
where: {
|
||||
userId,
|
||||
teamId: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@ -23,7 +23,8 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
throw new Error('Expired token');
|
||||
}
|
||||
|
||||
if (apiToken.team) {
|
||||
// Handle a silly choice from many moons ago
|
||||
if (apiToken.team && !apiToken.user) {
|
||||
apiToken.user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: apiToken.team.ownerUserId,
|
||||
@ -33,9 +34,13 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
|
||||
const { user } = apiToken;
|
||||
|
||||
// This will never happen but we need to narrow types
|
||||
if (!user) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
return { ...apiToken, user };
|
||||
return {
|
||||
...apiToken,
|
||||
user,
|
||||
};
|
||||
};
|
||||
|
||||
46
packages/lib/server-only/recipient/get-is-recipient-turn.ts
Normal file
46
packages/lib/server-only/recipient/get-is-recipient-turn.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentSigningOrder, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type GetIsRecipientTurnOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOptions) {
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
Recipient: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
Recipient: {
|
||||
orderBy: {
|
||||
signingOrder: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (document.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const recipients = document.Recipient;
|
||||
|
||||
const currentRecipientIndex = recipients.findIndex((r) => r.token === token);
|
||||
|
||||
if (currentRecipientIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < currentRecipientIndex; i++) {
|
||||
if (recipients[i].signingStatus !== SigningStatus.SIGNED) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@ -1,4 +1,10 @@
|
||||
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 RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import {
|
||||
type TRecipientActionAuthTypes,
|
||||
@ -16,19 +22,20 @@ 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;
|
||||
teamId?: number;
|
||||
documentId: number;
|
||||
recipients: {
|
||||
id?: number | null;
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
actionAuth?: TRecipientActionAuthTypes | null;
|
||||
}[];
|
||||
recipients: RecipientData[];
|
||||
requestMetadata?: RequestMetadata;
|
||||
}
|
||||
|
||||
@ -58,6 +65,15 @@ export const setRecipientsForDocument = async ({
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
Field: true,
|
||||
documentMeta: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@ -115,25 +131,28 @@ export const setRecipientsForDocument = async ({
|
||||
),
|
||||
);
|
||||
|
||||
const linkedRecipients = normalizedRecipients
|
||||
.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
);
|
||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
);
|
||||
|
||||
return {
|
||||
...recipient,
|
||||
_persisted: existing,
|
||||
};
|
||||
})
|
||||
.filter((recipient) => {
|
||||
return (
|
||||
recipient._persisted?.role === RecipientRole.CC ||
|
||||
(recipient._persisted?.sendStatus !== SendStatus.SENT &&
|
||||
recipient._persisted?.signingStatus !== SigningStatus.SIGNED)
|
||||
if (
|
||||
existing &&
|
||||
hasRecipientBeenChanged(existing, recipient) &&
|
||||
!canRecipientBeModified(existing, document.Field)
|
||||
) {
|
||||
throw new AppError(
|
||||
AppErrorCode.INVALID_REQUEST,
|
||||
'Cannot modify a recipient who has already interacted with the document',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...recipient,
|
||||
_persisted: existing,
|
||||
};
|
||||
});
|
||||
|
||||
const persistedRecipients = await prisma.$transaction(async (tx) => {
|
||||
return await Promise.all(
|
||||
@ -156,6 +175,7 @@ export const setRecipientsForDocument = async ({
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
documentId,
|
||||
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||
signingStatus:
|
||||
@ -166,6 +186,7 @@ export const setRecipientsForDocument = async ({
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
documentId,
|
||||
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||
@ -265,6 +286,52 @@ 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 || !isRecipientRemovedEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(RecipientRemovedFromDocumentTemplate, {
|
||||
documentName: document.title,
|
||||
inviterName: user.name || undefined,
|
||||
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,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: i18n._(msg`You have been removed from a document`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Filter out recipients that have been removed or have been updated.
|
||||
@ -281,3 +348,27 @@ export const setRecipientsForDocument = async ({
|
||||
|
||||
return [...filteredRecipients, ...persistedRecipients];
|
||||
};
|
||||
|
||||
/**
|
||||
* If you change this you MUST update the `hasRecipientBeenChanged` function.
|
||||
*/
|
||||
type RecipientData = {
|
||||
id?: number | null;
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
actionAuth?: TRecipientActionAuthTypes | null;
|
||||
};
|
||||
|
||||
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
|
||||
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
||||
|
||||
return (
|
||||
recipient.email !== newRecipientData.email ||
|
||||
recipient.name !== newRecipientData.name ||
|
||||
recipient.role !== newRecipientData.role ||
|
||||
recipient.signingOrder !== newRecipientData.signingOrder ||
|
||||
authOptions.actionAuth !== newRecipientData.actionAuth
|
||||
);
|
||||
};
|
||||
|
||||
@ -24,6 +24,7 @@ export type SetRecipientsForTemplateOptions = {
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
actionAuth?: TRecipientActionAuthTypes | null;
|
||||
}[];
|
||||
};
|
||||
@ -162,6 +163,7 @@ export const setRecipientsForTemplate = async ({
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
templateId,
|
||||
authOptions,
|
||||
},
|
||||
@ -169,6 +171,7 @@ export const setRecipientsForTemplate = async ({
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
templateId,
|
||||
authOptions,
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { RecipientRole, Team } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import {
|
||||
type TRecipientActionAuthTypes,
|
||||
ZRecipientAuthOptionsSchema,
|
||||
} from '../../types/document-auth';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
|
||||
import { createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
|
||||
export type UpdateRecipientOptions = {
|
||||
documentId: number;
|
||||
@ -11,6 +18,8 @@ export type UpdateRecipientOptions = {
|
||||
email?: string;
|
||||
name?: string;
|
||||
role?: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
actionAuth?: TRecipientActionAuthTypes | null;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
@ -22,6 +31,8 @@ export const updateRecipient = async ({
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
signingOrder,
|
||||
actionAuth,
|
||||
userId,
|
||||
teamId,
|
||||
requestMetadata,
|
||||
@ -48,6 +59,9 @@ export const updateRecipient = async ({
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Document: true,
|
||||
},
|
||||
});
|
||||
|
||||
let team: Team | null = null;
|
||||
@ -75,6 +89,22 @@ export const updateRecipient = async ({
|
||||
throw new Error('Recipient not found');
|
||||
}
|
||||
|
||||
if (actionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have permission to set the action auth',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
||||
|
||||
const updatedRecipient = await prisma.$transaction(async (tx) => {
|
||||
const persisted = await prisma.recipient.update({
|
||||
where: {
|
||||
@ -84,6 +114,11 @@ export const updateRecipient = async ({
|
||||
email: email?.toLowerCase() ?? recipient.email,
|
||||
name: name ?? recipient.name,
|
||||
role: role ?? recipient.role,
|
||||
signingOrder,
|
||||
authOptions: createRecipientAuthOptions({
|
||||
accessAuth: recipientAuthOptions.accessAuth,
|
||||
actionAuth: actionAuth ?? null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -6,6 +6,8 @@ export type GetTeamByIdOptions = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export type GetTeamResponse = Awaited<ReturnType<typeof getTeamById>>;
|
||||
|
||||
/**
|
||||
* Get a team given a teamId.
|
||||
*
|
||||
@ -28,6 +30,7 @@ export const getTeamById = async ({ userId, teamId }: GetTeamByIdOptions) => {
|
||||
where: whereFilter,
|
||||
include: {
|
||||
teamEmail: true,
|
||||
teamGlobalSettings: true,
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
@ -87,6 +90,7 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
|
||||
},
|
||||
},
|
||||
subscription: true,
|
||||
teamGlobalSettings: true,
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
@ -17,9 +18,11 @@ import {
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} 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';
|
||||
@ -36,9 +39,12 @@ 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';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CreateDocumentFromDirectTemplateOptions = {
|
||||
directRecipientName?: string;
|
||||
@ -86,6 +92,11 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
templateDocumentData: true,
|
||||
templateMeta: true,
|
||||
User: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -140,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(
|
||||
@ -230,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,
|
||||
@ -254,6 +269,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
recipient.role === RecipientRole.CC
|
||||
? SigningStatus.SIGNED
|
||||
: SigningStatus.NOT_SIGNED,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
};
|
||||
}),
|
||||
@ -265,6 +281,9 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
dateFormat: metaDateFormat,
|
||||
message: metaEmailMessage,
|
||||
subject: metaEmailSubject,
|
||||
language: metaLanguage,
|
||||
signingOrder: metaSigningOrder,
|
||||
distributionMethod: template.templateMeta?.distributionMethod,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -328,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 }) => ({
|
||||
@ -522,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: [
|
||||
{
|
||||
@ -533,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 {
|
||||
@ -553,6 +584,23 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
teamId: template.teamId || undefined,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
const updatedDocument = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
|
||||
data: updatedDocument,
|
||||
userId: updatedDocument.userId,
|
||||
teamId: updatedDocument.teamId ?? undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CREATE_DOCUMENT_FROM_DIRECT_TEMPLATE]:', err);
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ export type CreateDocumentFromTemplateLegacyOptions = {
|
||||
name?: string;
|
||||
email: string;
|
||||
role?: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
@ -45,6 +46,12 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
Recipient: true,
|
||||
Field: true,
|
||||
templateDocumentData: true,
|
||||
templateMeta: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -67,15 +74,29 @@ 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) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
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: {
|
||||
@ -129,12 +150,14 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
},
|
||||
create: {
|
||||
documentId: document.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Field } from '@documenso/prisma/client';
|
||||
import type { DocumentDistributionMethod } from '@documenso/prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentSource,
|
||||
type Field,
|
||||
type Recipient,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
@ -10,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';
|
||||
@ -23,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[];
|
||||
};
|
||||
@ -41,6 +47,7 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
id: number;
|
||||
name?: string;
|
||||
email: string;
|
||||
signingOrder?: number | null;
|
||||
}[];
|
||||
|
||||
/**
|
||||
@ -54,6 +61,9 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
password?: string;
|
||||
dateFormat?: string;
|
||||
redirectUrl?: string;
|
||||
signingOrder?: DocumentSigningOrder;
|
||||
language?: SupportedLanguageCodes;
|
||||
distributionMethod?: DocumentDistributionMethod;
|
||||
};
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
@ -100,6 +110,11 @@ export const createDocumentFromTemplate = async ({
|
||||
},
|
||||
templateDocumentData: true,
|
||||
templateMeta: true,
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -134,6 +149,7 @@ export const createDocumentFromTemplate = async ({
|
||||
name: foundRecipient ? foundRecipient.name ?? '' : templateRecipient.name,
|
||||
email: foundRecipient ? foundRecipient.email : templateRecipient.email,
|
||||
role: templateRecipient.role,
|
||||
signingOrder: foundRecipient?.signingOrder ?? templateRecipient.signingOrder,
|
||||
authOptions: templateRecipient.authOptions,
|
||||
};
|
||||
});
|
||||
@ -150,7 +166,7 @@ export const createDocumentFromTemplate = async ({
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
source: DocumentSource.TEMPLATE,
|
||||
externalId,
|
||||
externalId: externalId || template.externalId,
|
||||
templateId: template.id,
|
||||
userId,
|
||||
teamId: template.teamId,
|
||||
@ -160,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,
|
||||
@ -168,6 +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: {
|
||||
@ -189,6 +217,7 @@ export const createDocumentFromTemplate = async ({
|
||||
recipient.role === RecipientRole.CC
|
||||
? SigningStatus.SIGNED
|
||||
: SigningStatus.NOT_SIGNED,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
};
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -51,6 +51,12 @@ export const findTemplates = async ({
|
||||
},
|
||||
Field: true,
|
||||
Recipient: true,
|
||||
templateMeta: {
|
||||
select: {
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
},
|
||||
},
|
||||
directLink: {
|
||||
select: {
|
||||
token: true,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ export const updateTemplateSettings = async ({
|
||||
meta,
|
||||
data,
|
||||
}: UpdateTemplateSettingsOptions) => {
|
||||
if (Object.values(data).length === 0) {
|
||||
if (Object.values(data).length === 0 && Object.keys(meta ?? {}).length === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
|
||||
}
|
||||
|
||||
@ -100,7 +100,7 @@ export const updateTemplateSettings = async ({
|
||||
},
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
externalId: data.externalId,
|
||||
type: data.type,
|
||||
publicDescription: data.publicDescription,
|
||||
publicTitle: data.publicTitle,
|
||||
@ -112,9 +112,11 @@ export const updateTemplateSettings = async ({
|
||||
},
|
||||
create: {
|
||||
...meta,
|
||||
emailSettings: meta?.emailSettings || undefined,
|
||||
},
|
||||
update: {
|
||||
...meta,
|
||||
emailSettings: meta?.emailSettings || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../../constants/app';
|
||||
import { sign } from '../../crypto/sign';
|
||||
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
|
||||
|
||||
@ -29,7 +29,7 @@ export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWeb
|
||||
const signature = sign(body);
|
||||
|
||||
await Promise.race([
|
||||
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/webhook/trigger`, {
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL}/api/webhook/trigger`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
|
||||
Reference in New Issue
Block a user