mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 00:32:43 +10:00
Merge branch 'main' into feat/add-kysely
This commit is contained in:
@ -7,6 +7,7 @@ import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sealDocument } from './seal-document';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
@ -14,6 +15,8 @@ import { sendPendingEmail } from './send-pending-email';
|
||||
export type CompleteDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
userId?: number;
|
||||
authOptions?: TRecipientActionAuth;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
@ -46,8 +49,8 @@ export const completeDocumentWithToken = async ({
|
||||
|
||||
const document = await getDocument({ token, documentId });
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
throw new Error(`Document ${document.id} has already been completed`);
|
||||
if (document.status !== DocumentStatus.PENDING) {
|
||||
throw new Error(`Document ${document.id} must be pending`);
|
||||
}
|
||||
|
||||
if (document.Recipient.length === 0) {
|
||||
@ -71,32 +74,54 @@ export const completeDocumentWithToken = async ({
|
||||
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||
}
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
signedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Document reauth for completing documents is currently not required.
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
// const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
// documentAuth: document.authOptions,
|
||||
// recipientAuth: recipient.authOptions,
|
||||
// });
|
||||
|
||||
// const isValid = await isRecipientAuthorized({
|
||||
// type: 'ACTION',
|
||||
// document: document,
|
||||
// recipient: recipient,
|
||||
// userId,
|
||||
// authOptions,
|
||||
// });
|
||||
|
||||
// if (!isValid) {
|
||||
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
|
||||
// }
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
signedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
// actionAuth: derivedRecipientActionAuth || undefined,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const pendingRecipients = await prisma.recipient.count({
|
||||
@ -112,7 +137,7 @@ export const completeDocumentWithToken = async ({
|
||||
await sendPendingEmail({ documentId, recipientId: recipient.id });
|
||||
}
|
||||
|
||||
const documents = await prisma.document.updateMany({
|
||||
const haveAllRecipientsSigned = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: document.id,
|
||||
Recipient: {
|
||||
@ -121,13 +146,9 @@ export const completeDocumentWithToken = async ({
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (documents.count > 0) {
|
||||
if (haveAllRecipientsSigned) {
|
||||
await sealDocument({ documentId: document.id, requestMetadata });
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ export type CreateDocumentOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
documentDataId: string;
|
||||
formValues?: Record<string, string | number | boolean>;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
@ -22,6 +23,7 @@ export const createDocument = async ({
|
||||
title,
|
||||
documentDataId,
|
||||
teamId,
|
||||
formValues,
|
||||
requestMetadata,
|
||||
}: CreateDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@ -51,6 +53,7 @@ export const createDocument = async ({
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
formValues,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ 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 { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
@ -27,110 +28,180 @@ export const deleteDocument = async ({
|
||||
teamId,
|
||||
requestMetadata,
|
||||
}: DeleteDocumentOptions) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
User: true,
|
||||
team: {
|
||||
select: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
if (!document || (teamId !== undefined && teamId !== document.teamId)) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const { status, User: user } = document;
|
||||
const isUserOwner = document.userId === userId;
|
||||
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
|
||||
const userRecipient = document.Recipient.find((recipient) => recipient.email === user.email);
|
||||
|
||||
// if the document is a draft, hard-delete
|
||||
if (status === DocumentStatus.DRAFT) {
|
||||
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
|
||||
throw new Error('Not allowed');
|
||||
}
|
||||
|
||||
// Handle hard or soft deleting the actual document if user has permission.
|
||||
if (isUserOwner || isUserTeamMember) {
|
||||
await handleDocumentOwnerDelete({
|
||||
document,
|
||||
user,
|
||||
requestMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
// Continue to hide the document from the user if they are a recipient.
|
||||
// Dirty way of doing this but it's faster than refetching the document.
|
||||
if (userRecipient?.documentDeletedAt === null) {
|
||||
await prisma.recipient
|
||||
.update({
|
||||
where: {
|
||||
id: userRecipient.id,
|
||||
},
|
||||
data: {
|
||||
documentDeletedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
// Do nothing.
|
||||
});
|
||||
}
|
||||
|
||||
// Return partial document for API v1 response.
|
||||
return {
|
||||
id: document.id,
|
||||
userId: document.userId,
|
||||
teamId: document.teamId,
|
||||
title: document.title,
|
||||
status: document.status,
|
||||
documentDataId: document.documentDataId,
|
||||
createdAt: document.createdAt,
|
||||
updatedAt: document.updatedAt,
|
||||
completedAt: document.completedAt,
|
||||
};
|
||||
};
|
||||
|
||||
type HandleDocumentOwnerDeleteOptions = {
|
||||
document: Document & {
|
||||
Recipient: Recipient[];
|
||||
documentMeta: DocumentMeta | null;
|
||||
};
|
||||
user: User;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
const handleDocumentOwnerDelete = async ({
|
||||
document,
|
||||
user,
|
||||
requestMetadata,
|
||||
}: HandleDocumentOwnerDeleteOptions) => {
|
||||
if (document.deletedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Soft delete completed documents.
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Currently redundant since deleting a document will delete the audit logs.
|
||||
// However may be useful if we disassociate audit lgos and documents if required.
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: id,
|
||||
documentId: document.id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'HARD',
|
||||
type: 'SOFT',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
|
||||
return await tx.document.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// if the document is pending, send cancellation emails to all recipients
|
||||
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName: document.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document Cancelled',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// If the document is not a draft, only soft-delete.
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Hard delete draft and pending documents.
|
||||
const deletedDocument = await prisma.$transaction(async (tx) => {
|
||||
// Currently redundant since deleting a document will delete the audit logs.
|
||||
// However may be useful if we disassociate audit logs and documents if required.
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: id,
|
||||
documentId: document.id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'SOFT',
|
||||
type: 'HARD',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return await tx.document.update({
|
||||
return await tx.document.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date().toISOString(),
|
||||
id: document.id,
|
||||
status: {
|
||||
not: DocumentStatus.COMPLETED,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Send cancellation emails to recipients.
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName: document.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document Cancelled',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return deletedDocument;
|
||||
};
|
||||
|
||||
@ -112,24 +112,65 @@ export const findDocuments = async ({
|
||||
};
|
||||
}
|
||||
|
||||
const whereClause: Prisma.DocumentWhereInput = {
|
||||
...termFilters,
|
||||
...filters,
|
||||
let deletedFilter: Prisma.DocumentWhereInput = {
|
||||
AND: {
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: {
|
||||
not: ExtendedDocumentStatus.COMPLETED,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
if (team) {
|
||||
deletedFilter = {
|
||||
AND: {
|
||||
OR: team.teamEmail
|
||||
? [
|
||||
{
|
||||
teamId: team.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
User: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
Recipient: {
|
||||
some: {
|
||||
email: team.teamEmail.email,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
teamId: team.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const whereClause: Prisma.DocumentWhereInput = {
|
||||
...termFilters,
|
||||
...filters,
|
||||
...deletedFilter,
|
||||
};
|
||||
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
|
||||
|
||||
@ -1,16 +1,64 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||
|
||||
export interface GetDocumentAndSenderByTokenOptions {
|
||||
token: string;
|
||||
userId?: number;
|
||||
accessAuth?: TDocumentAuthMethods;
|
||||
|
||||
/**
|
||||
* Whether we enforce the access requirement.
|
||||
*
|
||||
* Defaults to true.
|
||||
*/
|
||||
requireAccessAuth?: boolean;
|
||||
}
|
||||
|
||||
export interface GetDocumentAndRecipientByTokenOptions {
|
||||
token: string;
|
||||
userId?: number;
|
||||
accessAuth?: TDocumentAuthMethods;
|
||||
|
||||
/**
|
||||
* Whether we enforce the access requirement.
|
||||
*
|
||||
* Defaults to true.
|
||||
*/
|
||||
requireAccessAuth?: boolean;
|
||||
}
|
||||
export type GetDocumentByTokenOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const result = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
Recipient: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export type DocumentAndSender = Awaited<ReturnType<typeof getDocumentAndSenderByToken>>;
|
||||
|
||||
export const getDocumentAndSenderByToken = async ({
|
||||
token,
|
||||
userId,
|
||||
accessAuth,
|
||||
requireAccessAuth = true,
|
||||
}: GetDocumentAndSenderByTokenOptions) => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
@ -28,12 +76,40 @@ export const getDocumentAndSenderByToken = async ({
|
||||
User: true,
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
Recipient: {
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
const { password: _password, ...User } = result.User;
|
||||
|
||||
const recipient = result.Recipient[0];
|
||||
|
||||
// Sanity check, should not be possible.
|
||||
if (!recipient) {
|
||||
throw new Error('Missing recipient');
|
||||
}
|
||||
|
||||
let documentAccessValid = true;
|
||||
|
||||
if (requireAccessAuth) {
|
||||
documentAccessValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS',
|
||||
document: result,
|
||||
recipient,
|
||||
userId,
|
||||
authOptions: accessAuth,
|
||||
});
|
||||
}
|
||||
|
||||
if (!documentAccessValid) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
User,
|
||||
@ -45,6 +121,9 @@ export const getDocumentAndSenderByToken = async ({
|
||||
*/
|
||||
export const getDocumentAndRecipientByToken = async ({
|
||||
token,
|
||||
userId,
|
||||
accessAuth,
|
||||
requireAccessAuth = true,
|
||||
}: GetDocumentAndRecipientByTokenOptions): Promise<DocumentWithRecipient> => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
@ -68,6 +147,29 @@ export const getDocumentAndRecipientByToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = result.Recipient[0];
|
||||
|
||||
// Sanity check, should not be possible.
|
||||
if (!recipient) {
|
||||
throw new Error('Missing recipient');
|
||||
}
|
||||
|
||||
let documentAccessValid = true;
|
||||
|
||||
if (requireAccessAuth) {
|
||||
documentAccessValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS',
|
||||
document: result,
|
||||
recipient,
|
||||
userId,
|
||||
authOptions: accessAuth,
|
||||
});
|
||||
}
|
||||
|
||||
if (!documentAccessValid) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
Recipient: result.Recipient,
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../types/document-audit-logs';
|
||||
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type GetDocumentCertificateAuditLogsOptions = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export const getDocumentCertificateAuditLogs = async ({
|
||||
id,
|
||||
}: GetDocumentCertificateAuditLogsOptions) => {
|
||||
const rawAuditLogs = await prisma.documentAuditLog.findMany({
|
||||
where: {
|
||||
documentId: id,
|
||||
type: {
|
||||
in: [
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const auditLogs = rawAuditLogs.map((log) => parseDocumentAuditLogData(log));
|
||||
|
||||
const groupedAuditLogs = {
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter(
|
||||
(log) =>
|
||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT &&
|
||||
log.data.emailType !== DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED,
|
||||
),
|
||||
} as const;
|
||||
|
||||
return groupedAuditLogs;
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
|
||||
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export type GetDocumentWithDetailsByIdOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const getDocumentWithDetailsById = async ({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
}: GetDocumentWithDetailsByIdOptions): Promise<DocumentWithDetails> => {
|
||||
const documentWhereInput = await getDocumentWhereInput({
|
||||
documentId: id,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
Recipient: true,
|
||||
Field: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -72,6 +72,7 @@ type GetCountsOption = {
|
||||
|
||||
const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
||||
return Promise.all([
|
||||
// Owner counts.
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
@ -84,6 +85,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
// Not signed counts.
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
@ -95,12 +97,13 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
createdAt,
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
// Has signed counts.
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
@ -120,9 +123,9 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
@ -130,6 +133,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -198,6 +202,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
@ -219,6 +224,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
@ -229,6 +235,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
|
||||
213
packages/lib/server-only/document/is-recipient-authorized.ts
Normal file
213
packages/lib/server-only/document/is-recipient-authorized.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
|
||||
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { DocumentAuth } from '../../types/document-auth';
|
||||
import type { TAuthenticationResponseJSONSchema } from '../../types/webauthn';
|
||||
import { getAuthenticatorOptions } from '../../utils/authenticator';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
|
||||
type IsRecipientAuthorizedOptions = {
|
||||
type: 'ACCESS' | 'ACTION';
|
||||
document: Document;
|
||||
recipient: Recipient;
|
||||
|
||||
/**
|
||||
* The ID of the user who initiated the request.
|
||||
*/
|
||||
userId?: number;
|
||||
|
||||
/**
|
||||
* The auth details to check.
|
||||
*
|
||||
* Optional because there are scenarios where no auth options are required such as
|
||||
* using the user ID.
|
||||
*/
|
||||
authOptions?: TDocumentAuthMethods;
|
||||
};
|
||||
|
||||
const getUserByEmail = async (email: string) => {
|
||||
return await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the recipient is authorized to perform the requested operation on a
|
||||
* document, given the provided auth options.
|
||||
*
|
||||
* @returns True if the recipient can perform the requested operation.
|
||||
*/
|
||||
export const isRecipientAuthorized = async ({
|
||||
type,
|
||||
document,
|
||||
recipient,
|
||||
userId,
|
||||
authOptions,
|
||||
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
|
||||
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
const authMethod: TDocumentAuth | null =
|
||||
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
|
||||
|
||||
// Early true return when auth is not required.
|
||||
if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create auth options when none are passed for account.
|
||||
if (!authOptions && authMethod === DocumentAuth.ACCOUNT) {
|
||||
authOptions = {
|
||||
type: DocumentAuth.ACCOUNT,
|
||||
};
|
||||
}
|
||||
|
||||
// Authentication required does not match provided method.
|
||||
if (!authOptions || authOptions.type !== authMethod || !userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await match(authOptions)
|
||||
.with({ type: DocumentAuth.ACCOUNT }, async () => {
|
||||
const recipientUser = await getUserByEmail(recipient.email);
|
||||
|
||||
if (!recipientUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return recipientUser.id === userId;
|
||||
})
|
||||
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
|
||||
return await isPasskeyAuthValid({
|
||||
userId,
|
||||
authenticationResponse,
|
||||
tokenReference,
|
||||
});
|
||||
})
|
||||
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Should not be possible.
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
|
||||
}
|
||||
|
||||
return await verifyTwoFactorAuthenticationToken({
|
||||
user,
|
||||
totpCode: token,
|
||||
});
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
type VerifyPasskeyOptions = {
|
||||
/**
|
||||
* The ID of the user who initiated the request.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The secondary ID of the verification token.
|
||||
*/
|
||||
tokenReference: string;
|
||||
|
||||
/**
|
||||
* The response from the passkey authenticator.
|
||||
*/
|
||||
authenticationResponse: TAuthenticationResponseJSONSchema;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the provided passkey authenticator response is valid and the user is
|
||||
* authenticated.
|
||||
*/
|
||||
const isPasskeyAuthValid = async (options: VerifyPasskeyOptions): Promise<boolean> => {
|
||||
return verifyPasskey(options)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies whether the provided passkey authenticator is valid and the user is
|
||||
* authenticated.
|
||||
*
|
||||
* Will throw an error if the user should not be authenticated.
|
||||
*/
|
||||
const verifyPasskey = async ({
|
||||
userId,
|
||||
tokenReference,
|
||||
authenticationResponse,
|
||||
}: VerifyPasskeyOptions): Promise<void> => {
|
||||
const passkey = await prisma.passkey.findFirst({
|
||||
where: {
|
||||
credentialId: Buffer.from(authenticationResponse.id, 'base64'),
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!passkey) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
|
||||
}
|
||||
|
||||
const verificationToken = await prisma.verificationToken
|
||||
.delete({
|
||||
where: {
|
||||
userId,
|
||||
secondaryId: tokenReference,
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!verificationToken) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Token not found');
|
||||
}
|
||||
|
||||
if (verificationToken.expires < new Date()) {
|
||||
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Token expired');
|
||||
}
|
||||
|
||||
const { rpId, origin } = getAuthenticatorOptions();
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: authenticationResponse,
|
||||
expectedChallenge: verificationToken.token,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
authenticator: {
|
||||
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
|
||||
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
|
||||
counter: Number(passkey.counter),
|
||||
},
|
||||
}).catch(() => null); // May want to log this for insights.
|
||||
|
||||
if (verification?.verified !== true) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'User is not authorized');
|
||||
}
|
||||
|
||||
await prisma.passkey.update({
|
||||
where: {
|
||||
id: passkey.id,
|
||||
},
|
||||
data: {
|
||||
lastUsedAt: new Date(),
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -88,6 +88,11 @@ export const resendDocument = async ({
|
||||
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
|
||||
|
||||
const { email, name } = recipient;
|
||||
const selfSigner = email === user.email;
|
||||
|
||||
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
|
||||
recipient.role
|
||||
].actionVerb.toLowerCase()} it.`;
|
||||
|
||||
const customEmailTemplate = {
|
||||
'signer.name': name,
|
||||
@ -104,12 +109,20 @@ export const resendDocument = async ({
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||
customBody: renderCustomEmailTemplate(
|
||||
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
|
||||
customEmailTemplate,
|
||||
),
|
||||
role: recipient.role,
|
||||
selfSigner,
|
||||
});
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
|
||||
const emailSubject = selfSigner
|
||||
? `Reminder: Please ${actionVerb.toLowerCase()} your document`
|
||||
: `Reminder: Please ${actionVerb.toLowerCase()} this document`;
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
@ -122,8 +135,8 @@ export const resendDocument = async ({
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
? renderCustomEmailTemplate(`Reminder: ${customEmail.subject}`, customEmailTemplate)
|
||||
: emailSubject,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'node:path';
|
||||
import { PDFDocument, PDFSignature, rectangle } from 'pdf-lib';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
@ -14,8 +14,12 @@ import { signPdf } from '@documenso/signing';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { putFile } from '../../universal/upload/put-file';
|
||||
import { putPdfFile } from '../../universal/upload/put-file';
|
||||
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
||||
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
||||
import { flattenForm } from '../pdf/flatten-form';
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sendCompletedEmail } from './send-completed-email';
|
||||
|
||||
@ -37,6 +41,11 @@ export const sealDocument = async ({
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
Recipient: {
|
||||
every: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
@ -50,10 +59,6 @@ export const sealDocument = async ({
|
||||
throw new Error(`Document ${document.id} has no document data`);
|
||||
}
|
||||
|
||||
if (document.status !== DocumentStatus.COMPLETED) {
|
||||
throw new Error(`Document ${document.id} has not been completed`);
|
||||
}
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
@ -89,34 +94,25 @@ 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 })
|
||||
.then(async (doc) => PDFDocument.load(doc))
|
||||
.catch(() => null);
|
||||
|
||||
const doc = await PDFDocument.load(pdfData);
|
||||
|
||||
const form = doc.getForm();
|
||||
// Normalize and flatten layers that could cause issues with the signature
|
||||
normalizeSignatureAppearances(doc);
|
||||
flattenForm(doc);
|
||||
flattenAnnotations(doc);
|
||||
|
||||
// Remove old signatures
|
||||
for (const field of form.getFields()) {
|
||||
if (field instanceof PDFSignature) {
|
||||
field.acroField.getWidgets().forEach((widget) => {
|
||||
widget.ensureAP();
|
||||
if (certificate) {
|
||||
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
|
||||
|
||||
try {
|
||||
widget.getNormalAppearance();
|
||||
} catch (e) {
|
||||
const { context } = widget.dict;
|
||||
|
||||
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
|
||||
|
||||
const streamRef = context.register(xobj);
|
||||
|
||||
widget.setNormalAppearance(streamRef);
|
||||
}
|
||||
});
|
||||
}
|
||||
certificatePages.forEach((page) => {
|
||||
doc.addPage(page);
|
||||
});
|
||||
}
|
||||
|
||||
// Flatten the form to stop annotation layers from appearing above documenso fields
|
||||
form.flatten();
|
||||
|
||||
for (const field of fields) {
|
||||
await insertFieldInPDF(doc, field);
|
||||
}
|
||||
@ -127,7 +123,7 @@ export const sealDocument = async ({
|
||||
|
||||
const { name, ext } = path.parse(document.title);
|
||||
|
||||
const { data: newData } = await putFile({
|
||||
const { data: newData } = await putPdfFile({
|
||||
name: `${name}_signed${ext}`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
||||
@ -146,6 +142,16 @@ export const sealDocument = async ({
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.document.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentData.update({
|
||||
where: {
|
||||
id: documentData.id,
|
||||
@ -172,9 +178,19 @@ export const sealDocument = async ({
|
||||
await sendCompletedEmail({ documentId, requestMetadata });
|
||||
}
|
||||
|
||||
const updatedDocument = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||
data: document,
|
||||
data: updatedDocument,
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
});
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
|
||||
export type SearchDocumentsWithKeywordOptions = {
|
||||
query: string;
|
||||
@ -79,12 +78,19 @@ export const searchDocumentsWithKeyword = async ({
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const maskedDocuments = documents.map((document) =>
|
||||
maskRecipientTokensForDocument({
|
||||
document,
|
||||
user,
|
||||
}),
|
||||
);
|
||||
const isOwner = (document: Document, user: User) => document.userId === user.id;
|
||||
const getSigningLink = (recipients: Recipient[], user: User) =>
|
||||
`/sign/${recipients.find((r) => r.email === user.email)?.token}`;
|
||||
|
||||
const maskedDocuments = documents.map((document) => {
|
||||
const { Recipient, ...documentWithoutRecipient } = document;
|
||||
|
||||
return {
|
||||
...documentWithoutRecipient,
|
||||
path: isOwner(document, user) ? `/documents/${document.id}` : getSigningLink(Recipient, user),
|
||||
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
||||
};
|
||||
});
|
||||
|
||||
return maskedDocuments;
|
||||
};
|
||||
|
||||
@ -80,7 +80,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
text: render(template, { plainText: true }),
|
||||
attachments: [
|
||||
{
|
||||
filename: document.title,
|
||||
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
|
||||
content: Buffer.from(completedDocument),
|
||||
},
|
||||
],
|
||||
@ -95,7 +95,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
data: {
|
||||
emailType: 'DOCUMENT_COMPLETED',
|
||||
recipientEmail: owner.email,
|
||||
recipientName: owner.name,
|
||||
recipientName: owner.name ?? '',
|
||||
recipientId: owner.id,
|
||||
recipientRole: 'OWNER',
|
||||
isResending: false,
|
||||
@ -130,7 +130,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
text: render(template, { plainText: true }),
|
||||
attachments: [
|
||||
{
|
||||
filename: document.title,
|
||||
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
|
||||
content: Buffer.from(completedDocument),
|
||||
},
|
||||
],
|
||||
|
||||
52
packages/lib/server-only/document/send-delete-email.ts
Normal file
52
packages/lib/server-only/document/send-delete-email.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
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 { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
export interface SendDeleteEmailOptions {
|
||||
documentId: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOptions) => {
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const { email, name } = document.User;
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentSuperDeleteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
reason,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name: name || '',
|
||||
},
|
||||
from: {
|
||||
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 }),
|
||||
});
|
||||
};
|
||||
@ -4,8 +4,11 @@ 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 { sealDocument } from '@documenso/lib/server-only/document/seal-document';
|
||||
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -17,12 +20,15 @@ import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
} from '../../constants/recipient-roles';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type SendDocumentOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
sendEmail?: boolean;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
@ -30,6 +36,7 @@ export const sendDocument = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
sendEmail = true,
|
||||
requestMetadata,
|
||||
}: SendDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@ -65,6 +72,7 @@ export const sendDocument = async ({
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -82,86 +90,160 @@ export const sendDocument = async ({
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||
return;
|
||||
}
|
||||
const { documentData } = document;
|
||||
|
||||
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
|
||||
if (!documentData.data) {
|
||||
throw new Error('Document data not found');
|
||||
}
|
||||
|
||||
const { email, name } = recipient;
|
||||
if (document.formValues) {
|
||||
const file = await getFile(documentData);
|
||||
|
||||
const customEmailTemplate = {
|
||||
'signer.name': name,
|
||||
'signer.email': email,
|
||||
'document.name': document.title,
|
||||
};
|
||||
const prefilled = await insertFormValuesInPdf({
|
||||
pdf: Buffer.from(file),
|
||||
formValues: document.formValues as Record<string, string | number | boolean>,
|
||||
});
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||
const newDocumentData = await putPdfFile({
|
||||
name: document.title,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||
});
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||
role: recipient.role,
|
||||
});
|
||||
const result = await prisma.document.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
data: {
|
||||
documentDataId: newDocumentData.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
Object.assign(document, result);
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
if (sendEmail) {
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: false,
|
||||
const { email, name } = recipient;
|
||||
const selfSigner = email === user.email;
|
||||
|
||||
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
|
||||
recipient.role
|
||||
].actionVerb.toLowerCase()} it.`;
|
||||
|
||||
const customEmailTemplate = {
|
||||
'signer.name': name,
|
||||
'signer.email': email,
|
||||
'document.name': document.title,
|
||||
};
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(
|
||||
selfSigner && !customEmail?.message
|
||||
? selfSignerCustomEmail
|
||||
: customEmail?.message || '',
|
||||
customEmailTemplate,
|
||||
),
|
||||
role: recipient.role,
|
||||
selfSigner,
|
||||
});
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
|
||||
const emailSubject = selfSigner
|
||||
? `Please ${actionVerb.toLowerCase()} your document`
|
||||
: `Please ${actionVerb.toLowerCase()} this document`;
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}),
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: emailSubject,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
await tx.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const allRecipientsHaveNoActionToTake = document.Recipient.every(
|
||||
(recipient) => recipient.role === RecipientRole.CC,
|
||||
);
|
||||
|
||||
if (allRecipientsHaveNoActionToTake) {
|
||||
const updatedDocument = await updateDocument({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
data: { status: DocumentStatus.COMPLETED },
|
||||
});
|
||||
|
||||
await sealDocument({ documentId: updatedDocument.id, requestMetadata });
|
||||
|
||||
// Keep the return type the same for the `sendDocument` method
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedDocument = await prisma.$transaction(async (tx) => {
|
||||
if (document.status === DocumentStatus.DRAFT) {
|
||||
await tx.documentAuditLog.create({
|
||||
|
||||
85
packages/lib/server-only/document/super-delete-document.ts
Normal file
85
packages/lib/server-only/document/super-delete-document.ts
Normal file
@ -0,0 +1,85 @@
|
||||
'use server';
|
||||
|
||||
import { createElement } from 'react';
|
||||
|
||||
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 } from '@documenso/prisma/client';
|
||||
|
||||
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 type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type SuperDeleteDocumentOptions = {
|
||||
id: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDocumentOptions) => {
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const { status, User: user } = document;
|
||||
|
||||
// if the document is pending, send cancellation emails to all recipients
|
||||
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName: document.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document Cancelled',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// always hard delete if deleted from admin
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'HARD',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return await tx.document.delete({ where: { id } });
|
||||
});
|
||||
};
|
||||
178
packages/lib/server-only/document/update-document-settings.ts
Normal file
178
packages/lib/server-only/document/update-document-settings.ts
Normal file
@ -0,0 +1,178 @@
|
||||
'use server';
|
||||
|
||||
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 { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
|
||||
export type UpdateDocumentSettingsOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
documentId: number;
|
||||
data: {
|
||||
title?: string;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||
globalActionAuth?: TDocumentActionAuthTypes | null;
|
||||
};
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const updateDocumentSettings = async ({
|
||||
userId,
|
||||
teamId,
|
||||
documentId,
|
||||
data,
|
||||
requestMetadata,
|
||||
}: UpdateDocumentSettingsOptions) => {
|
||||
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { documentAuthOption } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
||||
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
|
||||
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
|
||||
|
||||
// If the new global auth values aren't passed in, fallback to the current document values.
|
||||
const newGlobalAccessAuth =
|
||||
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
|
||||
const newGlobalActionAuth =
|
||||
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (newGlobalActionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have permission to set the action auth',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isTitleSame = data.title === document.title;
|
||||
const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth;
|
||||
const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth;
|
||||
|
||||
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
|
||||
|
||||
if (!isTitleSame && document.status !== DocumentStatus.DRAFT) {
|
||||
throw new AppError(
|
||||
AppErrorCode.INVALID_BODY,
|
||||
'You cannot update the title if the document has been sent',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isTitleSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
from: document.title,
|
||||
to: data.title || '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isGlobalAccessSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED,
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
from: documentGlobalAccessAuth,
|
||||
to: newGlobalAccessAuth,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isGlobalActionSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED,
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
from: documentGlobalActionAuth,
|
||||
to: newGlobalActionAuth,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Early return if nothing is required.
|
||||
if (auditLogs.length === 0) {
|
||||
return document;
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const authOptions = createDocumentAuthOptions({
|
||||
globalAccessAuth: newGlobalAccessAuth,
|
||||
globalActionAuth: newGlobalActionAuth,
|
||||
});
|
||||
|
||||
const updatedDocument = await tx.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
data: {
|
||||
title: data.title,
|
||||
authOptions,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.createMany({
|
||||
data: auditLogs,
|
||||
});
|
||||
|
||||
return updatedDocument;
|
||||
});
|
||||
};
|
||||
@ -5,15 +5,21 @@ import { prisma } from '@documenso/prisma';
|
||||
import { ReadStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import type { TDocumentAccessAuthTypes } from '../../types/document-auth';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getDocumentAndRecipientByToken } from './get-document-by-token';
|
||||
|
||||
export type ViewedDocumentOptions = {
|
||||
token: string;
|
||||
recipientAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentOptions) => {
|
||||
export const viewedDocument = async ({
|
||||
token,
|
||||
recipientAccessAuth,
|
||||
requestMetadata,
|
||||
}: ViewedDocumentOptions) => {
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
@ -51,12 +57,13 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO
|
||||
recipientId: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
accessAuth: recipientAccessAuth || undefined,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const document = await getDocumentAndRecipientByToken({ token });
|
||||
const document = await getDocumentAndRecipientByToken({ token, requireAccessAuth: false });
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_OPENED,
|
||||
|
||||
Reference in New Issue
Block a user