fix: merge conflicts

This commit is contained in:
Ephraim Atta-Duncan
2025-02-06 11:47:44 +00:00
783 changed files with 20849 additions and 22470 deletions

View File

@ -1,5 +1,6 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import {
@ -8,11 +9,15 @@ import {
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendPendingEmail } from './send-pending-email';
@ -29,7 +34,7 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
return await prisma.document.findFirstOrThrow({
where: {
id: documentId,
Recipient: {
recipients: {
some: {
token,
},
@ -37,7 +42,7 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
},
include: {
documentMeta: true,
Recipient: {
recipients: {
where: {
token,
},
@ -57,11 +62,11 @@ export const completeDocumentWithToken = async ({
throw new Error(`Document ${document.id} must be pending`);
}
if (document.Recipient.length === 0) {
if (document.recipients.length === 0) {
throw new Error(`Document ${document.id} has no recipient with token ${token}`);
}
const [recipient] = document.Recipient;
const [recipient] = document.recipients;
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
@ -84,7 +89,7 @@ export const completeDocumentWithToken = async ({
},
});
if (fields.some((field) => !field.inserted)) {
if (fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
@ -138,6 +143,14 @@ export const completeDocumentWithToken = async ({
});
});
await jobs.triggerJob({
name: 'send.recipient.signed.email',
payload: {
documentId: document.id,
recipientId: recipient.id,
},
});
const pendingRecipients = await prisma.recipient.findMany({
select: {
id: true,
@ -185,7 +198,7 @@ export const completeDocumentWithToken = async ({
const haveAllRecipientsSigned = await prisma.document.findFirst({
where: {
id: document.id,
Recipient: {
recipients: {
every: {
OR: [{ signingStatus: SigningStatus.SIGNED }, { role: RecipientRole.CC }],
},
@ -203,11 +216,19 @@ export const completeDocumentWithToken = async ({
});
}
const updatedDocument = await getDocument({ token, documentId });
const updatedDocument = await prisma.document.findFirstOrThrow({
where: {
id: document.id,
},
include: {
documentMeta: true,
recipients: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
data: updatedDocument,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined,
});

View File

@ -0,0 +1,248 @@
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { DocumentVisibility, TemplateMeta } from '@documenso/prisma/client';
import {
DocumentSource,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import type { TCreateDocumentV2Request } from '@documenso/trpc/server/document-router/schema';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
userId: number;
teamId?: number;
documentDataId: string;
normalizePdf?: boolean;
data: {
title: string;
externalId?: string;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes;
globalActionAuth?: TDocumentActionAuthTypes;
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata: ApiRequestMetadata;
};
export const createDocumentV2 = async ({
userId,
teamId,
documentDataId,
normalizePdf,
data,
meta,
requestMetadata,
}: CreateDocumentOptions) => {
const { title, formValues } = data;
const team = teamId
? await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
teamGlobalSettings: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
})
: null;
if (teamId !== undefined && !team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
id: documentDataId,
},
});
if (documentData) {
const buffer = await getFile(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const newDocumentData = await putPdfFile({
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),
});
// eslint-disable-next-line require-atomic-updates
documentDataId = newDocumentData.id;
}
}
const authOptions = createDocumentAuthOptions({
globalAccessAuth: data?.globalAccessAuth || null,
globalActionAuth: data?.globalActionAuth || null,
});
const recipientsHaveActionAuth = data.recipients?.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (authOptions.globalActionAuth || recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const visibility = determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
team?.members[0].role ?? TeamMemberRole.MEMBER,
);
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
title,
externalId: data.externalId,
documentDataId,
userId,
teamId,
authOptions,
visibility,
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: {
...meta,
signingOrder: meta?.signingOrder || undefined,
emailSettings: meta?.emailSettings || undefined,
language: meta?.language || team?.teamGlobalSettings?.documentLanguage,
typedSignatureEnabled:
meta?.typedSignatureEnabled ?? team?.teamGlobalSettings?.typedSignatureEnabled,
},
},
},
});
await Promise.all(
(data.recipients || []).map(async (recipient) => {
const recipientAuthOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null,
actionAuth: recipient.actionAuth || null,
});
await tx.recipient.create({
data: {
documentId: document.id,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions: recipientAuthOptions,
fields: {
createMany: {
data: (recipient.fields || []).map((field) => ({
documentId: document.id,
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
})),
},
},
},
});
}),
);
// Todo: Is it necessary to create a full audit log with all fields and recipients audit logs?
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
metadata: requestMetadata,
data: {
title,
source: {
type: DocumentSource.DOCUMENT,
},
},
}),
});
const createdDocument = await tx.document.findFirst({
where: {
id: document.id,
},
include: {
documentData: true,
documentMeta: true,
recipients: true,
fields: true,
},
});
if (!createdDocument) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
userId,
teamId,
});
return createdDocument;
});
};

View File

@ -1,14 +1,22 @@
'use server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentSource, DocumentVisibility, WebhookTriggerEvents } from '@documenso/prisma/client';
import { DocumentSource, WebhookTriggerEvents } from '@documenso/prisma/client';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
@ -18,7 +26,9 @@ export type CreateDocumentOptions = {
teamId?: number;
documentDataId: string;
formValues?: Record<string, string | number | boolean>;
requestMetadata?: RequestMetadata;
normalizePdf?: boolean;
timezone?: string;
requestMetadata: ApiRequestMetadata;
};
export const createDocument = async ({
@ -27,8 +37,10 @@ export const createDocument = async ({
externalId,
documentDataId,
teamId,
normalizePdf,
formValues,
requestMetadata,
timezone,
}: CreateDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@ -47,7 +59,9 @@ export const createDocument = async ({
teamId !== undefined &&
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;
@ -75,25 +89,28 @@ export const createDocument = async ({
userTeamRole = teamWithUserRole.members[0]?.role;
}
const determineVisibility = (
globalVisibility: DocumentVisibility | null | undefined,
userRole: TeamMemberRole,
): DocumentVisibility => {
const defaultVisibility = globalVisibility ?? DocumentVisibility.EVERYONE;
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
id: documentDataId,
},
});
if (userRole === TeamMemberRole.ADMIN) {
return defaultVisibility;
if (documentData) {
const buffer = await getFile(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const newDocumentData = await putPdfFile({
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),
});
// eslint-disable-next-line require-atomic-updates
documentDataId = newDocumentData.id;
}
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({
@ -103,7 +120,7 @@ export const createDocument = async ({
documentDataId,
userId,
teamId,
visibility: determineVisibility(
visibility: determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
@ -112,6 +129,8 @@ export const createDocument = async ({
documentMeta: {
create: {
language: team?.teamGlobalSettings?.documentLanguage,
typedSignatureEnabled: team?.teamGlobalSettings?.typedSignatureEnabled,
timezone: timezone,
},
},
},
@ -121,8 +140,7 @@ export const createDocument = async ({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
user,
requestMetadata,
metadata: requestMetadata,
data: {
title,
source: {
@ -132,13 +150,27 @@ export const createDocument = async ({
}),
});
const createdDocument = await tx.document.findFirst({
where: {
id: document.id,
},
include: {
documentMeta: true,
recipients: true,
},
});
if (!createdDocument) {
throw new Error('Document not found');
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: document,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
userId,
teamId,
});
return document;
return createdDocument;
});
};

View File

@ -15,23 +15,29 @@ import type {
TeamGlobalSettings,
User,
} from '@documenso/prisma/client';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
import { DocumentStatus, SendStatus, WebhookTriggerEvents } 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 { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } 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';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteDocumentOptions = {
id: number;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
requestMetadata: ApiRequestMetadata;
};
export const deleteDocument = async ({
@ -47,7 +53,9 @@ export const deleteDocument = async ({
});
if (!user) {
throw new Error('User not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const document = await prisma.document.findUnique({
@ -55,7 +63,7 @@ export const deleteDocument = async ({
id,
},
include: {
Recipient: true,
recipients: true,
documentMeta: true,
team: {
include: {
@ -67,15 +75,19 @@ export const deleteDocument = async ({
});
if (!document || (teamId !== undefined && teamId !== document.teamId)) {
throw new Error('Document not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
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);
const userRecipient = document.recipients.find((recipient) => recipient.email === user.email);
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
throw new Error('Not allowed');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Not allowed',
});
}
// Handle hard or soft deleting the actual document if user has permission.
@ -105,6 +117,13 @@ export const deleteDocument = async ({
});
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(document)),
userId,
teamId,
});
// Return partial document for API v1 response.
return {
id: document.id,
@ -121,7 +140,7 @@ export const deleteDocument = async ({
type HandleDocumentOwnerDeleteOptions = {
document: Document & {
Recipient: Recipient[];
recipients: Recipient[];
documentMeta: DocumentMeta | null;
};
team?:
@ -130,7 +149,7 @@ type HandleDocumentOwnerDeleteOptions = {
})
| null;
user: User;
requestMetadata?: RequestMetadata;
requestMetadata: ApiRequestMetadata;
};
const handleDocumentOwnerDelete = async ({
@ -150,8 +169,7 @@ const handleDocumentOwnerDelete = async ({
data: createDocumentAuditLogData({
documentId: document.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
metadata: requestMetadata,
data: {
type: 'SOFT',
},
@ -177,8 +195,7 @@ const handleDocumentOwnerDelete = async ({
data: createDocumentAuditLogData({
documentId: document.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
metadata: requestMetadata,
data: {
type: 'HARD',
},
@ -205,7 +222,7 @@ const handleDocumentOwnerDelete = async ({
// Send cancellation emails to recipients.
await Promise.all(
document.Recipient.map(async (recipient) => {
document.recipients.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
return;
}

View File

@ -1,26 +1,27 @@
import { prisma } from '@documenso/prisma';
import { DocumentSource, type Prisma } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getDocumentWhereInput } from './get-document-by-id';
export interface DuplicateDocumentByIdOptions {
id: number;
export interface DuplicateDocumentOptions {
documentId: number;
userId: number;
teamId?: number;
}
export const duplicateDocumentById = async ({
id,
export const duplicateDocument = async ({
documentId,
userId,
teamId,
}: DuplicateDocumentByIdOptions) => {
}: DuplicateDocumentOptions) => {
const documentWhereInput = await getDocumentWhereInput({
documentId: id,
documentId,
userId,
teamId,
});
const document = await prisma.document.findUniqueOrThrow({
const document = await prisma.document.findFirst({
where: documentWhereInput,
select: {
title: true,
@ -45,10 +46,16 @@ export const duplicateDocumentById = async ({
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const createDocumentArguments: Prisma.DocumentCreateArgs = {
data: {
title: document.title,
User: {
user: {
connect: {
id: document.userId,
},
@ -78,5 +85,7 @@ export const duplicateDocumentById = async ({
const createdDocument = await prisma.document.create(createDocumentArguments);
return createdDocument.id;
return {
documentId: createdDocument.id,
};
};

View File

@ -1,13 +1,14 @@
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { prisma } from '@documenso/prisma';
import type { DocumentAuditLog } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import type { DocumentAuditLog, Prisma } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { FindResultResponse } from '../../types/search-params';
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
export interface FindDocumentAuditLogsOptions {
userId: number;
teamId?: number;
documentId: number;
page?: number;
perPage?: number;
@ -21,6 +22,7 @@ export interface FindDocumentAuditLogsOptions {
export const findDocumentAuditLogs = async ({
userId,
teamId,
documentId,
page = 1,
perPage = 30,
@ -31,26 +33,31 @@ export const findDocumentAuditLogs = async ({
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const documentFilter = await prisma.document.findFirstOrThrow({
const document = await prisma.document.findFirst({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const whereClause: Prisma.DocumentAuditLogWhereInput = {
documentId,
};
@ -113,5 +120,5 @@ export const findDocumentAuditLogs = async ({
perPage,
totalPages: Math.ceil(count / perPage),
nextCursor,
} satisfies FindResultSet<typeof parsedData> & { nextCursor?: string };
} satisfies FindResultResponse<typeof parsedData> & { nextCursor?: string };
};

View File

@ -1,8 +1,7 @@
import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { RecipientRole, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import type {
Document,
DocumentSource,
@ -11,10 +10,11 @@ import type {
TeamEmail,
User,
} from '@documenso/prisma/client';
import { RecipientRole, SigningStatus, TeamMemberRole } 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 { type FindResultResponse } from '../../types/search-params';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
@ -22,7 +22,6 @@ export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
export type FindDocumentsOptions = {
userId: number;
teamId?: number;
term?: string;
templateId?: number;
source?: DocumentSource;
status?: ExtendedDocumentStatus;
@ -34,13 +33,12 @@ export type FindDocumentsOptions = {
};
period?: PeriodSelectorValue;
senderIds?: number[];
search?: string;
query?: string;
};
export const findDocuments = async ({
userId,
teamId,
term,
templateId,
source,
status = ExtendedDocumentStatus.ALL,
@ -49,7 +47,7 @@ export const findDocuments = async ({
orderBy,
period,
senderIds,
search,
query,
}: FindDocumentsOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@ -87,22 +85,11 @@ export const findDocuments = async ({
const orderByDirection = orderBy?.direction ?? 'desc';
const teamMemberRole = team?.members[0].role ?? null;
const termFilters = match(term)
.with(P.string.minLength(1), () => {
return {
title: {
contains: term,
mode: 'insensitive',
},
} as const;
})
.otherwise(() => undefined);
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
{ Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
{ title: { contains: query, mode: 'insensitive' } },
{ recipients: { some: { name: { contains: query, mode: 'insensitive' } } } },
{ recipients: { some: { email: { contains: query, mode: 'insensitive' } } } },
],
};
@ -126,7 +113,7 @@ export const findDocuments = async ({
{
OR: [
{
Recipient: {
recipients: {
some: {
email: user.email,
},
@ -163,7 +150,7 @@ export const findDocuments = async ({
deletedAt: null,
},
{
Recipient: {
recipients: {
some: {
email: user.email,
documentDeletedAt: null,
@ -184,13 +171,13 @@ export const findDocuments = async ({
deletedAt: null,
},
{
User: {
user: {
email: team.teamEmail.email,
},
deletedAt: null,
},
{
Recipient: {
recipients: {
some: {
email: team.teamEmail.email,
documentDeletedAt: null,
@ -209,7 +196,6 @@ export const findDocuments = async ({
}
const whereAndClause: Prisma.DocumentWhereInput['AND'] = [
{ ...termFilters },
{ ...filters },
{ ...deletedFilter },
{ ...searchFilter },
@ -256,14 +242,14 @@ export const findDocuments = async ({
[orderByColumn]: orderByDirection,
},
include: {
User: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
Recipient: true,
recipients: true,
team: {
select: {
id: true,
@ -290,7 +276,7 @@ export const findDocuments = async ({
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
} satisfies FindResultResponse<typeof data>;
};
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
@ -303,7 +289,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
},
{
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
recipients: {
some: {
email: user.email,
},
@ -311,7 +297,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
},
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
recipients: {
some: {
email: user.email,
},
@ -323,7 +309,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.NOT_SIGNED,
@ -347,7 +333,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
},
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
@ -368,7 +354,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
},
{
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
recipients: {
some: {
email: user.email,
},
@ -433,7 +419,7 @@ const findTeamDocumentsFilter = (
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
recipients: {
some: {
email: teamEmail,
},
@ -443,7 +429,7 @@ const findTeamDocumentsFilter = (
// Filter to display all documents that have been sent by the team email.
filter.OR.push({
User: {
user: {
email: teamEmail,
},
OR: visibilityFilters,
@ -462,7 +448,7 @@ const findTeamDocumentsFilter = (
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
recipients: {
some: {
email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED,
@ -488,7 +474,7 @@ const findTeamDocumentsFilter = (
if (teamEmail && filter.OR) {
filter.OR.push({
status: ExtendedDocumentStatus.DRAFT,
User: {
user: {
email: teamEmail,
},
OR: visibilityFilters,
@ -513,7 +499,7 @@ const findTeamDocumentsFilter = (
status: ExtendedDocumentStatus.PENDING,
OR: [
{
Recipient: {
recipients: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
@ -525,7 +511,7 @@ const findTeamDocumentsFilter = (
OR: visibilityFilters,
},
{
User: {
user: {
email: teamEmail,
},
OR: visibilityFilters,
@ -550,7 +536,7 @@ const findTeamDocumentsFilter = (
if (teamEmail && filter.OR) {
filter.OR.push(
{
Recipient: {
recipients: {
some: {
email: teamEmail,
},
@ -558,7 +544,7 @@ const findTeamDocumentsFilter = (
OR: visibilityFilters,
},
{
User: {
user: {
email: teamEmail,
},
OR: visibilityFilters,

View File

@ -4,35 +4,36 @@ import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentVisibility } from '../../types/document-visibility';
import { getTeamById } from '../team/get-team';
export type GetDocumentByIdOptions = {
id: number;
documentId: number;
userId: number;
teamId?: number;
};
export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOptions) => {
export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumentByIdOptions) => {
const documentWhereInput = await getDocumentWhereInput({
documentId: id,
documentId,
userId,
teamId,
});
return await prisma.document.findFirstOrThrow({
const document = await prisma.document.findFirst({
where: documentWhereInput,
include: {
documentData: true,
documentMeta: true,
User: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
Recipient: {
recipients: {
select: {
email: true,
},
@ -45,6 +46,14 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
},
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document could not be found',
});
}
return document;
};
export type GetDocumentWhereInputOptions = {
@ -110,14 +119,14 @@ export const getDocumentWhereInput = async ({
if (team.teamEmail) {
documentWhereInput.OR.push(
{
Recipient: {
recipients: {
some: {
email: team.teamEmail.email,
},
},
},
{
User: {
user: {
email: team.teamEmail.email,
},
},
@ -145,7 +154,7 @@ export const getDocumentWhereInput = async ({
{
OR: [
{
Recipient: {
recipients: {
some: {
email: user.email,
},

View File

@ -41,7 +41,7 @@ export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) =
const result = await prisma.document.findFirstOrThrow({
where: {
Recipient: {
recipients: {
some: {
token,
},
@ -66,28 +66,39 @@ export const getDocumentAndSenderByToken = async ({
const result = await prisma.document.findFirstOrThrow({
where: {
Recipient: {
recipients: {
some: {
token,
},
},
},
include: {
User: true,
user: true,
documentData: true,
documentMeta: true,
Recipient: {
recipients: {
where: {
token,
},
},
team: {
select: {
name: true,
teamEmail: true,
teamGlobalSettings: {
select: {
includeSenderDetails: true,
},
},
},
},
},
});
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const { password: _password, ...User } = result.User;
const { password: _password, ...user } = result.user;
const recipient = result.Recipient[0];
const recipient = result.recipients[0];
// Sanity check, should not be possible.
if (!recipient) {
@ -107,12 +118,14 @@ export const getDocumentAndSenderByToken = async ({
}
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid access values',
});
}
return {
...result,
User,
user,
};
};
@ -131,14 +144,14 @@ export const getDocumentAndRecipientByToken = async ({
const result = await prisma.document.findFirstOrThrow({
where: {
Recipient: {
recipients: {
some: {
token,
},
},
},
include: {
Recipient: {
recipients: {
where: {
token,
},
@ -147,7 +160,7 @@ export const getDocumentAndRecipientByToken = async ({
},
});
const [recipient] = result.Recipient;
const [recipient] = result.recipients;
// Sanity check, should not be possible.
if (!recipient) {
@ -167,11 +180,10 @@ export const getDocumentAndRecipientByToken = async ({
}
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid access values',
});
}
return {
...result,
Recipient: result.Recipient,
};
return result;
};

View File

@ -1,32 +1,40 @@
import { prisma } from '@documenso/prisma';
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getDocumentWhereInput } from './get-document-by-id';
export type GetDocumentWithDetailsByIdOptions = {
id: number;
documentId: number;
userId: number;
teamId?: number;
};
export const getDocumentWithDetailsById = async ({
id,
documentId,
userId,
teamId,
}: GetDocumentWithDetailsByIdOptions): Promise<DocumentWithDetails> => {
}: GetDocumentWithDetailsByIdOptions) => {
const documentWhereInput = await getDocumentWhereInput({
documentId: id,
documentId,
userId,
teamId,
});
return await prisma.document.findFirstOrThrow({
const document = await prisma.document.findFirst({
where: documentWhereInput,
include: {
documentData: true,
documentMeta: true,
Recipient: true,
Field: true,
recipients: true,
fields: true,
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
return document;
};

View File

@ -85,8 +85,8 @@ 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' } } } },
{ recipients: { some: { name: { contains: search, mode: 'insensitive' } } } },
{ recipients: { some: { email: { contains: search, mode: 'insensitive' } } } },
],
};
@ -113,7 +113,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
},
where: {
status: ExtendedDocumentStatus.PENDING,
Recipient: {
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.NOT_SIGNED,
@ -132,7 +132,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
},
where: {
createdAt,
User: {
user: {
email: {
not: user.email,
},
@ -140,7 +140,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
OR: [
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
@ -150,7 +150,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
},
{
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
@ -191,8 +191,8 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
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' } } } },
{ recipients: { some: { name: { contains: options.search, mode: 'insensitive' } } } },
{ recipients: { some: { email: { contains: options.search, mode: 'insensitive' } } } },
],
};
@ -234,7 +234,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
{
OR: [
{ userId: options.userId },
{ Recipient: { some: { email: options.currentUserEmail } } },
{ recipients: { some: { email: options.currentUserEmail } } },
],
},
],
@ -257,7 +257,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
teamId,
},
{
User: {
user: {
email: teamEmail,
},
},
@ -274,7 +274,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
userId: userIdWhereClause,
createdAt,
status: ExtendedDocumentStatus.PENDING,
Recipient: {
recipients: {
some: {
email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED,
@ -296,7 +296,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
OR: [
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
recipients: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
@ -307,7 +307,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
{
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
recipients: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,

View File

@ -106,7 +106,9 @@ export const isRecipientAuthorized = async ({
// Should not be possible.
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
return await verifyTwoFactorAuthenticationToken({
@ -164,7 +166,9 @@ const verifyPasskey = async ({
});
if (!passkey) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Passkey not found',
});
}
const verificationToken = await prisma.verificationToken
@ -177,11 +181,15 @@ const verifyPasskey = async ({
.catch(() => null);
if (!verificationToken) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Token not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Token not found',
});
}
if (verificationToken.expires < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Token expired');
throw new AppError(AppErrorCode.EXPIRED_CODE, {
message: 'Token expired',
});
}
const { rpId, origin } = getAuthenticatorOptions();
@ -199,7 +207,9 @@ const verifyPasskey = async ({
}).catch(() => null); // May want to log this for insights.
if (verification?.verified !== true) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'User is not authorized');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User is not authorized',
});
}
await prisma.passkey.update({

View File

@ -1,8 +1,7 @@
import { TRPCError } from '@trpc/server';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
@ -10,7 +9,7 @@ export type MoveDocumentToTeamOptions = {
documentId: number;
teamId: number;
userId: number;
requestMetadata?: RequestMetadata;
requestMetadata: ApiRequestMetadata;
};
export const moveDocumentToTeam = async ({
@ -20,10 +19,6 @@ export const moveDocumentToTeam = async ({
requestMetadata,
}: MoveDocumentToTeamOptions) => {
return await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({
where: { id: userId },
});
const document = await tx.document.findFirst({
where: {
id: documentId,
@ -33,8 +28,7 @@ export const moveDocumentToTeam = async ({
});
if (!document) {
throw new TRPCError({
code: 'NOT_FOUND',
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found or already associated with a team.',
});
}
@ -51,9 +45,8 @@ export const moveDocumentToTeam = async ({
});
if (!team) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not a member of this team.',
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'This team does not exist, or you are not a member of this team.',
});
}
@ -62,12 +55,11 @@ export const moveDocumentToTeam = async ({
data: { teamId },
});
const log = await tx.documentAuditLog.create({
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM,
documentId: updatedDocument.id,
user,
requestMetadata,
metadata: requestMetadata,
data: {
movedByUserId: userId,
fromPersonalAccount: true,

View File

@ -1,12 +1,18 @@
import { SigningStatus } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type RejectDocumentWithTokenOptions = {
token: string;
@ -28,25 +34,24 @@ export async function rejectDocumentWithToken({
documentId,
},
include: {
Document: {
document: {
include: {
User: true,
user: true,
recipients: true,
documentMeta: true,
},
},
},
});
const document = recipient?.Document;
const document = recipient?.document;
if (!recipient || !document) {
throw new TRPCError({
code: 'NOT_FOUND',
throw new AppError(AppErrorCode.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({
@ -88,5 +93,28 @@ export async function rejectDocumentWithToken({
},
});
// Get the updated document with all recipients
const updatedDocument = await prisma.document.findFirst({
where: {
id: document.id,
},
include: {
recipients: true,
documentMeta: true,
},
});
if (!updatedDocument) {
throw new Error('Document not found after update');
}
// Trigger webhook for document rejection
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REJECTED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
userId: document.userId,
teamId: document.teamId ?? undefined,
});
return updatedRecipient;
}

View File

@ -10,12 +10,12 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '@documenso/lib/constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@ -29,7 +29,7 @@ export type ResendDocumentOptions = {
userId: number;
recipients: number[];
teamId?: number;
requestMetadata: RequestMetadata;
requestMetadata: ApiRequestMetadata;
};
export const resendDocument = async ({
@ -38,7 +38,7 @@ export const resendDocument = async ({
recipients,
teamId,
requestMetadata,
}: ResendDocumentOptions) => {
}: ResendDocumentOptions): Promise<void> => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@ -54,7 +54,7 @@ export const resendDocument = async ({
const document = await prisma.document.findUnique({
where: documentWhereInput,
include: {
Recipient: {
recipients: {
where: {
id: {
in: recipients,
@ -80,7 +80,7 @@ export const resendDocument = async ({
throw new Error('Document not found');
}
if (document.Recipient.length === 0) {
if (document.recipients.length === 0) {
throw new Error('Document has no recipients');
}
@ -101,7 +101,7 @@ export const resendDocument = async ({
}
await Promise.all(
document.Recipient.map(async (recipient) => {
document.recipients.map(async (recipient) => {
if (recipient.role === RecipientRole.CC) {
return;
}
@ -134,7 +134,7 @@ export const resendDocument = async ({
emailMessage =
customEmail?.message ||
i18n._(
msg`${user.name} on behalf of ${document.team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
msg`${user.name} on behalf of "${document.team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".`,
);
}
@ -201,8 +201,7 @@ export const resendDocument = async ({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
metadata: requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,

View File

@ -6,14 +6,22 @@ import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-po
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import {
DocumentStatus,
RecipientRole,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
import { flattenForm } from '../pdf/flatten-form';
@ -38,7 +46,7 @@ export const sealDocument = async ({
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
Recipient: {
recipients: {
every: {
signingStatus: SigningStatus.SIGNED,
},
@ -47,7 +55,16 @@ export const sealDocument = async ({
include: {
documentData: true,
documentMeta: true,
Recipient: true,
recipients: true,
team: {
select: {
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
},
},
},
},
},
});
@ -75,12 +92,12 @@ export const sealDocument = async ({
documentId: document.id,
},
include: {
Signature: true,
signature: true,
},
});
if (fields.some((field) => !field.inserted)) {
throw new Error(`Document ${document.id} has unsigned fields`);
if (fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${document.id} has unsigned required fields`);
}
if (isResealing) {
@ -92,11 +109,13 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData);
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
const certificate = await getCertificatePdf({ documentId, language: documentLanguage })
.then(async (doc) => PDFDocument.load(doc))
.catch(() => null);
const certificateData =
(document.team?.teamGlobalSettings?.includeSigningCertificate ?? true)
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
: null;
const doc = await PDFDocument.load(pdfData);
@ -105,7 +124,9 @@ export const sealDocument = async ({
flattenForm(doc);
flattenAnnotations(doc);
if (certificate) {
if (certificateData) {
const certificate = await PDFDocument.load(certificateData);
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
certificatePages.forEach((page) => {
@ -124,10 +145,10 @@ export const sealDocument = async ({
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
const { name, ext } = path.parse(document.title);
const { name } = path.parse(document.title);
const { data: newData } = await putPdfFile({
name: `${name}_signed${ext}`,
name: `${name}_signed.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),
});
@ -187,13 +208,14 @@ export const sealDocument = async ({
},
include: {
documentData: true,
Recipient: true,
documentMeta: true,
recipients: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
data: updatedDocument,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
userId: document.userId,
teamId: document.teamId ?? undefined,
});

View File

@ -35,7 +35,7 @@ export const searchDocumentsWithKeyword = async ({
deletedAt: null,
},
{
Recipient: {
recipients: {
some: {
email: {
contains: query,
@ -48,7 +48,7 @@ export const searchDocumentsWithKeyword = async ({
},
{
status: DocumentStatus.COMPLETED,
Recipient: {
recipients: {
some: {
email: user.email,
},
@ -60,7 +60,7 @@ export const searchDocumentsWithKeyword = async ({
},
{
status: DocumentStatus.PENDING,
Recipient: {
recipients: {
some: {
email: user.email,
},
@ -91,7 +91,7 @@ export const searchDocumentsWithKeyword = async ({
],
},
include: {
Recipient: true,
recipients: true,
team: {
select: {
url: true,
@ -140,7 +140,7 @@ export const searchDocumentsWithKeyword = async ({
return canAccessDocument;
})
.map((document) => {
const { Recipient, ...documentWithoutRecipient } = document;
const { recipients, ...documentWithoutRecipient } = document;
let documentPath;
@ -149,13 +149,13 @@ export const searchDocumentsWithKeyword = async ({
} else if (document.teamId && document.team) {
documentPath = `${formatDocumentsPath(document.team.url)}/${document.id}`;
} else {
documentPath = getSigningLink(Recipient, user);
documentPath = getSigningLink(recipients, user);
}
return {
...documentWithoutRecipient,
path: documentPath,
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
value: [document.id, document.title, ...document.recipients.map((r) => r.email)].join(' '),
};
});

View File

@ -1,5 +1,5 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { ApiRequestMetadata } 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 { prisma } from '@documenso/prisma';
@ -13,7 +13,7 @@ export type SelfSignDocumentOptions = {
documentId: number;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
requestMetadata?: ApiRequestMetadata;
};
export const selfSignDocument = async ({
@ -53,7 +53,7 @@ export const selfSignDocument = async ({
}),
},
include: {
Recipient: {
recipients: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
},
documentMeta: true,
@ -65,11 +65,11 @@ export const selfSignDocument = async ({
throw new Error('Document not found');
}
if (document.Recipient.length === 0) {
if (document.recipients.length === 0) {
throw new Error('Document has no recipients');
}
if (document.Recipient.length !== 1 || document.Recipient[0].email !== user.email) {
if (document.recipients.length !== 1 || document.recipients[0].email !== user.email) {
throw new Error('Invalid document for self-signing');
}
@ -111,15 +111,15 @@ export const selfSignDocument = async ({
}
const recipientHasNoActionToTake =
document.Recipient[0].role === RecipientRole.CC ||
document.Recipient[0].signingStatus === SigningStatus.SIGNED;
document.recipients[0].role === RecipientRole.CC ||
document.recipients[0].signingStatus === SigningStatus.SIGNED;
if (recipientHasNoActionToTake) {
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId,
requestMetadata,
requestMetadata: requestMetadata?.requestMetadata,
},
});
@ -128,7 +128,7 @@ export const selfSignDocument = async ({
id: documentId,
},
include: {
Recipient: true,
recipients: true,
},
});
}
@ -139,13 +139,13 @@ export const selfSignDocument = async ({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.SELF_SIGN,
documentId: document.id,
requestMetadata,
requestMetadata: requestMetadata?.requestMetadata,
user,
data: {
recipientId: document.Recipient[0].id,
recipientEmail: document.Recipient[0].email,
recipientName: document.Recipient[0].name,
recipientRole: document.Recipient[0].role,
recipientId: document.recipients[0].id,
recipientEmail: document.recipients[0].email,
recipientName: document.recipients[0].name,
recipientRole: document.recipients[0].role,
},
}),
});
@ -153,7 +153,7 @@ export const selfSignDocument = async ({
await tx.recipient.update({
where: {
id: document.Recipient[0].id,
id: document.recipients[0].id,
},
data: {
sendStatus: SendStatus.SENT,
@ -168,7 +168,7 @@ export const selfSignDocument = async ({
status: DocumentStatus.PENDING,
},
include: {
Recipient: true,
recipients: true,
},
});
});

View File

@ -32,8 +32,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
include: {
documentData: true,
documentMeta: true,
Recipient: true,
User: true,
recipients: true,
user: true,
team: {
select: {
id: true,
@ -50,11 +50,11 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
const isDirectTemplate = document?.source === DocumentSource.TEMPLATE_DIRECT_LINK;
if (document.Recipient.length === 0) {
if (document.recipients.length === 0) {
throw new Error('Document has no recipients');
}
const { User: owner } = document;
const { user: owner } = document;
const completedDocument = await getFile(document.documentData);
@ -72,14 +72,19 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
const i18n = await getI18nInstance(document.documentMeta?.language);
const isDocumentCompletedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentCompleted;
const emailSettings = extractDerivedDocumentEmailSettings(document.documentMeta);
const isDocumentCompletedEmailEnabled = emailSettings.documentCompleted;
const isOwnerDocumentCompletedEmailEnabled = emailSettings.ownerDocumentCompleted;
// If the document owner is not a recipient, OR recipient emails are disabled, then send the email to them separately.
// Send email to document owner if:
// 1. Owner document completed emails are enabled AND
// 2. Either:
// - The owner is not a recipient, OR
// - Recipient emails are disabled
if (
!document.Recipient.find((recipient) => recipient.email === owner.email) ||
!isDocumentCompletedEmailEnabled
isOwnerDocumentCompletedEmailEnabled &&
(!document.recipients.find((recipient) => recipient.email === owner.email) ||
!isDocumentCompletedEmailEnabled)
) {
const template = createElement(DocumentCompletedEmailTemplate, {
documentName: document.title,
@ -145,7 +150,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
}
await Promise.all(
document.Recipient.map(async (recipient) => {
document.recipients.map(async (recipient) => {
const customEmailTemplate = {
'signer.name': recipient.name,
'signer.email': recipient.email,

View File

@ -8,6 +8,7 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
@ -23,7 +24,7 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
id: documentId,
},
include: {
User: true,
user: true,
documentMeta: true,
team: {
include: {
@ -34,7 +35,9 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
});
if (!document) {
throw new Error('Document not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
@ -45,7 +48,7 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
return;
}
const { email, name } = document.User;
const { email, name } = document.user;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';

View File

@ -1,5 +1,5 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { ApiRequestMetadata } 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 { prisma } from '@documenso/prisma';
@ -9,11 +9,15 @@ import {
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
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 +27,7 @@ export type SendDocumentOptions = {
userId: number;
teamId?: number;
sendEmail?: boolean;
requestMetadata?: RequestMetadata;
requestMetadata: ApiRequestMetadata;
};
export const sendDocument = async ({
@ -33,17 +37,6 @@ export const sendDocument = async ({
sendEmail,
requestMetadata,
}: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
const document = await prisma.document.findUnique({
where: {
id: documentId,
@ -64,7 +57,7 @@ export const sendDocument = async ({
}),
},
include: {
Recipient: {
recipients: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
},
documentMeta: true,
@ -76,7 +69,7 @@ export const sendDocument = async ({
throw new Error('Document not found');
}
if (document.Recipient.length === 0) {
if (document.recipients.length === 0) {
throw new Error('Document has no recipients');
}
@ -86,13 +79,13 @@ export const sendDocument = async ({
const signingOrder = document.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
let recipientsToNotify = document.Recipient;
let recipientsToNotify = document.recipients;
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);
recipientsToNotify = document.recipients
.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.
@ -114,8 +107,14 @@ export const sendDocument = async ({
formValues: document.formValues as Record<string, string | number | boolean>,
});
let fileName = document.title;
if (!document.title.endsWith('.pdf')) {
fileName = `${document.title}.pdf`;
}
const newDocumentData = await putPdfFile({
name: document.title,
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
});
@ -177,14 +176,14 @@ export const sendDocument = async ({
userId,
documentId,
recipientId: recipient.id,
requestMetadata,
requestMetadata: requestMetadata?.requestMetadata,
},
});
}),
);
}
const allRecipientsHaveNoActionToTake = document.Recipient.every(
const allRecipientsHaveNoActionToTake = document.recipients.every(
(recipient) =>
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
);
@ -194,7 +193,7 @@ export const sendDocument = async ({
name: 'internal.seal-document',
payload: {
documentId,
requestMetadata,
requestMetadata: requestMetadata?.requestMetadata,
},
});
@ -204,7 +203,8 @@ export const sendDocument = async ({
id: documentId,
},
include: {
Recipient: true,
documentMeta: true,
recipients: true,
},
});
}
@ -215,8 +215,7 @@ export const sendDocument = async ({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
documentId: document.id,
requestMetadata,
user,
metadata: requestMetadata,
data: {},
}),
});
@ -230,14 +229,15 @@ export const sendDocument = async ({
status: DocumentStatus.PENDING,
},
include: {
Recipient: true,
documentMeta: true,
recipients: true,
},
});
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SENT,
data: updatedDocument,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
userId,
teamId,
});

View File

@ -21,14 +21,14 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
const document = await prisma.document.findFirst({
where: {
id: documentId,
Recipient: {
recipients: {
some: {
id: recipientId,
},
},
},
include: {
Recipient: {
recipients: {
where: {
id: recipientId,
},
@ -46,7 +46,7 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
throw new Error('Document not found');
}
if (document.Recipient.length === 0) {
if (document.recipients.length === 0) {
throw new Error('Document has no recipients');
}
@ -58,7 +58,7 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
return;
}
const [recipient] = document.Recipient;
const [recipient] = document.recipients;
const { email, name } = recipient;

View File

@ -12,6 +12,7 @@ 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 { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
@ -30,9 +31,9 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
id,
},
include: {
Recipient: true,
recipients: true,
documentMeta: true,
User: true,
user: true,
team: {
include: {
teamGlobalSettings: true,
@ -42,10 +43,12 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
});
if (!document) {
throw new Error('Document not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const { status, User: user } = document;
const { status, user } = document;
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
@ -54,11 +57,11 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
// if the document is pending, send cancellation emails to all recipients
if (
status === DocumentStatus.PENDING &&
document.Recipient.length > 0 &&
document.recipients.length > 0 &&
isDocumentDeletedEmailEnabled
) {
await Promise.all(
document.Recipient.map(async (recipient) => {
document.recipients.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
return;
}

View File

@ -1,274 +0,0 @@
'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 { 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';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
export type UpdateDocumentSettingsOptions = {
userId: number;
teamId?: number;
documentId: number;
data: {
title?: string;
externalId?: string | null;
visibility?: DocumentVisibility | null;
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,
}),
},
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,
});
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 === 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[] = [];
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 (!isExternalIdSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.externalId,
to: data.externalId || '',
},
}),
);
}
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,
},
}),
);
}
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;
}
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,
externalId: data.externalId,
visibility: data.visibility as DocumentVisibility,
authOptions,
},
});
await tx.documentAuditLog.createMany({
data: auditLogs,
});
return updatedDocument;
});
};

View File

@ -1,23 +1,40 @@
'use server';
import type { Prisma } from '@prisma/client';
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 { ApiRequestMetadata } 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 { 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';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
export type UpdateDocumentOptions = {
documentId: number;
data: Prisma.DocumentUpdateInput;
userId: number;
teamId?: number;
documentId: number;
data?: {
title?: string;
externalId?: string | null;
visibility?: DocumentVisibility | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
};
requestMetadata: ApiRequestMetadata;
};
export const updateDocument = async ({
documentId,
userId,
teamId,
documentId,
data,
requestMetadata,
}: UpdateDocumentOptions) => {
return await prisma.document.update({
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
@ -36,8 +53,215 @@ export const updateDocument = async ({
teamId: null,
}),
},
data: {
...data,
include: {
team: {
select: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
},
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (teamId) {
const currentUserRole = document.team?.members[0]?.role;
const isDocumentOwner = document.userId === userId;
const requestedVisibility = data?.visibility;
if (!isDocumentOwner) {
match(currentUserRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => {
const allowedVisibilities: DocumentVisibility[] = [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
];
if (
!allowedVisibilities.includes(document.visibility) ||
(requestedVisibility && !allowedVisibilities.includes(requestedVisibility))
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
});
}
})
.with(TeamMemberRole.MEMBER, () => {
if (
document.visibility !== DocumentVisibility.EVERYONE ||
(requestedVisibility && requestedVisibility !== DocumentVisibility.EVERYONE)
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
});
}
})
.otherwise(() => {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document',
});
});
}
}
// If no data just return the document since this function is normally chained after a meta update.
if (!data || Object.values(data).length === 0) {
return document;
}
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, {
message: 'You do not have permission to set the action auth',
});
}
}
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[] = [];
if (!isTitleSame && document.status !== DocumentStatus.DRAFT) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: '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,
metadata: requestMetadata,
data: {
from: document.title,
to: data.title || '',
},
}),
);
}
if (!isExternalIdSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED,
documentId,
metadata: requestMetadata,
data: {
from: document.externalId,
to: data.externalId || '',
},
}),
);
}
if (!isGlobalAccessSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED,
documentId,
metadata: requestMetadata,
data: {
from: documentGlobalAccessAuth,
to: newGlobalAccessAuth,
},
}),
);
}
if (!isGlobalActionSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED,
documentId,
metadata: requestMetadata,
data: {
from: documentGlobalActionAuth,
to: newGlobalActionAuth,
},
}),
);
}
if (!isDocumentVisibilitySame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED,
documentId,
metadata: requestMetadata,
data: {
from: document.visibility,
to: data.visibility || '',
},
}),
);
}
// 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,
externalId: data.externalId,
visibility: data.visibility as DocumentVisibility,
authOptions,
},
});
await tx.documentAuditLog.createMany({
data: auditLogs,
});
return updatedDocument;
});
};

View File

@ -45,7 +45,9 @@ export const validateFieldAuth = async ({
});
if (!isValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid authentication values',
});
}
return derivedRecipientActionAuth;

View File

@ -2,12 +2,15 @@ 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 { ReadStatus } from '@documenso/prisma/client';
import { ReadStatus, SendStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import type { TDocumentAccessAuthTypes } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getDocumentAndRecipientByToken } from './get-document-by-token';
export type ViewedDocumentOptions = {
token: string;
@ -39,6 +42,8 @@ export const viewedDocument = async ({
id: recipient.id,
},
data: {
// This handles cases where distribution is done manually
sendStatus: SendStatus.SENT,
readStatus: ReadStatus.OPENED,
},
});
@ -63,11 +68,23 @@ export const viewedDocument = async ({
});
});
const document = await getDocumentAndRecipientByToken({ token, requireAccessAuth: false });
const document = await prisma.document.findFirst({
where: {
id: documentId,
},
include: {
documentMeta: true,
recipients: true,
},
});
if (!document) {
throw new Error('Document not found');
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_OPENED,
data: document,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(document)),
userId: document.userId,
teamId: document.teamId ?? undefined,
});