Merge branch 'main' into feat/unlink-documents-deleted-org

This commit is contained in:
Catalin Pit
2025-11-24 12:16:13 +02:00
committed by GitHub
582 changed files with 77147 additions and 83710 deletions

View File

@ -78,6 +78,14 @@ export const adminFindDocuments = async ({
url: true,
},
},
envelopeItems: {
select: {
id: true,
envelopeId: true,
title: true,
order: true,
},
},
},
}),
prisma.envelope.count({

View File

@ -0,0 +1,363 @@
import type { DocumentStatus } from '@prisma/client';
import { EnvelopeType } from '@prisma/client';
import type { DateRange } from '@documenso/lib/types/search-params';
import { kyselyPrisma, sql } from '@documenso/prisma';
export type OrganisationSummary = {
totalTeams: number;
totalMembers: number;
totalDocuments: number;
activeDocuments: number;
completedDocuments: number;
volumeThisPeriod: number;
volumeAllTime: number;
};
export type OrganisationDetailedInsights = {
teams: TeamInsights[];
users: UserInsights[];
documents: DocumentInsights[];
totalPages: number;
summary?: OrganisationSummary;
};
export type TeamInsights = {
id: number;
name: string;
memberCount: number;
documentCount: number;
createdAt: Date;
};
export type UserInsights = {
id: number;
name: string;
email: string;
documentCount: number;
signedDocumentCount: number;
createdAt: Date;
};
export type DocumentInsights = {
id: string;
title: string;
status: DocumentStatus;
teamName: string;
createdAt: Date;
completedAt: Date | null;
};
export type GetOrganisationDetailedInsightsOptions = {
organisationId: string;
page?: number;
perPage?: number;
dateRange?: DateRange;
view: 'teams' | 'users' | 'documents';
};
export async function getOrganisationDetailedInsights({
organisationId,
page = 1,
perPage = 10,
dateRange = 'last30days',
view,
}: GetOrganisationDetailedInsightsOptions): Promise<OrganisationDetailedInsights> {
const offset = Math.max(page - 1, 0) * perPage;
const now = new Date();
let createdAtFrom: Date | null = null;
switch (dateRange) {
case 'last30days': {
createdAtFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
}
case 'last90days': {
createdAtFrom = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
break;
}
case 'lastYear': {
createdAtFrom = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
break;
}
case 'allTime':
default:
createdAtFrom = null;
break;
}
const summaryData = await getOrganisationSummary(organisationId, createdAtFrom);
const viewData = await (async () => {
switch (view) {
case 'teams':
return await getTeamInsights(organisationId, offset, perPage, createdAtFrom);
case 'users':
return await getUserInsights(organisationId, offset, perPage, createdAtFrom);
case 'documents':
return await getDocumentInsights(organisationId, offset, perPage, createdAtFrom);
default:
throw new Error(`Invalid view: ${view}`);
}
})();
return {
...viewData,
summary: summaryData,
};
}
async function getTeamInsights(
organisationId: string,
offset: number,
perPage: number,
createdAtFrom: Date | null,
): Promise<OrganisationDetailedInsights> {
const teamsQuery = kyselyPrisma.$kysely
.selectFrom('Team as t')
.leftJoin('Envelope as e', (join) =>
join
.onRef('t.id', '=', 'e.teamId')
.on('e.deletedAt', 'is', null)
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.leftJoin('TeamGroup as tg', 'tg.teamId', 't.id')
.leftJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId')
.leftJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id')
.leftJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId')
.where('t.organisationId', '=', organisationId)
.select([
't.id as id',
't.name as name',
't.createdAt as createdAt',
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
(createdAtFrom
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
: sql<number>`COUNT(DISTINCT e.id)`
).as('documentCount'),
])
.groupBy(['t.id', 't.name', 't.createdAt'])
.orderBy('documentCount', 'desc')
.limit(perPage)
.offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('Team as t')
.where('t.organisationId', '=', organisationId)
.select(({ fn }) => [fn.countAll().as('count')]);
const [teams, countResult] = await Promise.all([teamsQuery.execute(), countQuery.execute()]);
const count = Number(countResult[0]?.count || 0);
return {
teams: teams as TeamInsights[],
users: [],
documents: [],
totalPages: Math.ceil(Number(count) / perPage),
};
}
async function getUserInsights(
organisationId: string,
offset: number,
perPage: number,
createdAtFrom: Date | null,
): Promise<OrganisationDetailedInsights> {
const usersBase = kyselyPrisma.$kysely
.selectFrom('OrganisationMember as om')
.innerJoin('User as u', 'u.id', 'om.userId')
.where('om.organisationId', '=', organisationId)
.leftJoin('Envelope as e', (join) =>
join
.onRef('e.userId', '=', 'u.id')
.on('e.deletedAt', 'is', null)
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.leftJoin('Team as td', (join) =>
join.onRef('td.id', '=', 'e.teamId').on('td.organisationId', '=', organisationId),
)
.leftJoin('Recipient as r', (join) =>
join.onRef('r.email', '=', 'u.email').on('r.signedAt', 'is not', null),
)
.leftJoin('Envelope as se', (join) =>
join
.onRef('se.id', '=', 'r.envelopeId')
.on('se.deletedAt', 'is', null)
.on('se.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.leftJoin('Team as ts', (join) =>
join.onRef('ts.id', '=', 'se.teamId').on('ts.organisationId', '=', organisationId),
);
const usersQuery = usersBase
.select([
'u.id as id',
'u.name as name',
'u.email as email',
'u.createdAt as createdAt',
(createdAtFrom
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
: sql<number>`COUNT(DISTINCT CASE WHEN td.id IS NOT NULL THEN e.id END)`
).as('documentCount'),
(createdAtFrom
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
: sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' THEN e.id END)`
).as('signedDocumentCount'),
])
.groupBy(['u.id', 'u.name', 'u.email', 'u.createdAt'])
.orderBy('u.createdAt', 'desc')
.limit(perPage)
.offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('OrganisationMember as om')
.innerJoin('User as u', 'u.id', 'om.userId')
.where('om.organisationId', '=', organisationId)
.select(({ fn }) => [fn.countAll().as('count')]);
const [users, countResult] = await Promise.all([usersQuery.execute(), countQuery.execute()]);
const count = Number(countResult[0]?.count || 0);
return {
teams: [],
users: users as UserInsights[],
documents: [],
totalPages: Math.ceil(Number(count) / perPage),
};
}
async function getDocumentInsights(
organisationId: string,
offset: number,
perPage: number,
createdAtFrom: Date | null,
): Promise<OrganisationDetailedInsights> {
let documentsQuery = kyselyPrisma.$kysely
.selectFrom('Envelope as e')
.innerJoin('Team as t', 'e.teamId', 't.id')
.where('t.organisationId', '=', organisationId)
.where('e.deletedAt', 'is', null)
.where(() => sql`e.type = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`);
if (createdAtFrom) {
documentsQuery = documentsQuery.where('e.createdAt', '>=', createdAtFrom);
}
documentsQuery = documentsQuery
.select([
'e.id as id',
'e.title as title',
'e.status as status',
'e.createdAt as createdAt',
'e.completedAt as completedAt',
't.name as teamName',
])
.orderBy('e.createdAt', 'desc')
.limit(perPage)
.offset(offset);
let countQuery = kyselyPrisma.$kysely
.selectFrom('Envelope as e')
.innerJoin('Team as t', 'e.teamId', 't.id')
.where('t.organisationId', '=', organisationId)
.where('e.deletedAt', 'is', null)
.where(() => sql`e.type = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`);
if (createdAtFrom) {
countQuery = countQuery.where('e.createdAt', '>=', createdAtFrom);
}
countQuery = countQuery.select(({ fn }) => [fn.countAll().as('count')]);
const [documents, countResult] = await Promise.all([
documentsQuery.execute(),
countQuery.execute(),
]);
const count = Number((countResult[0] as { count: number })?.count || 0);
return {
teams: [],
users: [],
documents: documents.map((doc) => ({
...doc,
id: String((doc as { id: number }).id),
})) as DocumentInsights[],
totalPages: Math.ceil(Number(count) / perPage),
};
}
async function getOrganisationSummary(
organisationId: string,
createdAtFrom: Date | null,
): Promise<OrganisationSummary> {
const summaryQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o')
.where('o.id', '=', organisationId)
.select([
sql<number>`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as(
'totalTeams',
),
sql<number>`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as(
'totalMembers',
),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT'
)`.as('totalDocuments'),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status IN ('DRAFT', 'PENDING')
)`.as('activeDocuments'),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
)`.as('completedDocuments'),
(createdAtFrom
? sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id
AND e2."deletedAt" IS NULL
AND e2.type = 'DOCUMENT'
AND e2.status = 'COMPLETED'
AND e2."createdAt" >= ${createdAtFrom}
)`
: sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id
AND e2."deletedAt" IS NULL
AND e2.type = 'DOCUMENT'
AND e2.status = 'COMPLETED'
)`
).as('volumeThisPeriod'),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
)`.as('volumeAllTime'),
]);
const result = await summaryQuery.executeTakeFirst();
return {
totalTeams: Number(result?.totalTeams || 0),
totalMembers: Number(result?.totalMembers || 0),
totalDocuments: Number(result?.totalDocuments || 0),
activeDocuments: Number(result?.activeDocuments || 0),
completedDocuments: Number(result?.completedDocuments || 0),
volumeThisPeriod: Number(result?.volumeThisPeriod || 0),
volumeAllTime: Number(result?.volumeAllTime || 0),
};
}

View File

@ -1,13 +1,17 @@
import { DocumentStatus, EnvelopeType, SubscriptionStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import type { DateRange } from '@documenso/lib/types/search-params';
import { kyselyPrisma, sql } from '@documenso/prisma';
export type SigningVolume = {
export type OrganisationInsights = {
id: number;
name: string;
signingVolume: number;
createdAt: Date;
planId: string;
customerId: string | null;
subscriptionStatus?: string;
teamCount?: number;
memberCount?: number;
};
export type GetSigningVolumeOptions = {
@ -28,28 +32,26 @@ export async function getSigningVolume({
const offset = Math.max(page - 1, 0) * perPage;
let findQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.leftJoin('Envelope as e', (join) =>
join
.onRef('t.id', '=', 'e.teamId')
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('e.deletedAt', 'is', null),
.on('e.deletedAt', 'is', null)
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.where('e.type', '=', EnvelopeType.DOCUMENT)
.select([
's.id as id',
's.createdAt as createdAt',
's.planId as planId',
'o.id as id',
'o.createdAt as createdAt',
'o.customerId as customerId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
])
.groupBy(['s.id', 'o.name']);
.groupBy(['o.id', 'o.name', 'o.customerId']);
switch (sortBy) {
case 'name':
@ -68,19 +70,127 @@ export async function getSigningVolume({
findQuery = findQuery.limit(perPage).offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select(({ fn }) => [fn.countAll().as('count')]);
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return {
leaderboard: results,
organisations: results,
totalPages: Math.ceil(Number(count) / perPage),
};
}
export type GetOrganisationInsightsOptions = GetSigningVolumeOptions & {
dateRange?: DateRange;
startDate?: Date;
endDate?: Date;
};
export async function getOrganisationInsights({
search = '',
page = 1,
perPage = 10,
sortBy = 'signingVolume',
sortOrder = 'desc',
dateRange = 'last30days',
startDate,
endDate,
}: GetOrganisationInsightsOptions) {
const offset = Math.max(page - 1, 0) * perPage;
const now = new Date();
let dateCondition = sql`1=1`;
if (startDate && endDate) {
dateCondition = sql`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`;
} else {
switch (dateRange) {
case 'last30days': {
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
dateCondition = sql`e."createdAt" >= ${thirtyDaysAgo}`;
break;
}
case 'last90days': {
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`;
break;
}
case 'lastYear': {
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
dateCondition = sql`e."createdAt" >= ${oneYearAgo}`;
break;
}
case 'allTime':
default:
dateCondition = sql`1=1`;
break;
}
}
let findQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.leftJoin('Envelope as e', (join) =>
join
.onRef('t.id', '=', 'e.teamId')
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('e.deletedAt', 'is', null)
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.leftJoin('OrganisationMember as om', 'o.id', 'om.organisationId')
.leftJoin('Subscription as s', 'o.id', 's.organisationId')
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select([
'o.id as id',
'o.createdAt as createdAt',
'o.customerId as customerId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND ${dateCondition} THEN e.id END)`.as(
'signingVolume',
),
sql<number>`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'),
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
sql<string>`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as(
'subscriptionStatus',
),
])
.groupBy(['o.id', 'o.name', 'o.customerId', 's.status']);
switch (sortBy) {
case 'name':
findQuery = findQuery.orderBy('name', sortOrder);
break;
case 'createdAt':
findQuery = findQuery.orderBy('createdAt', sortOrder);
break;
case 'signingVolume':
findQuery = findQuery.orderBy('signingVolume', sortOrder);
break;
default:
findQuery = findQuery.orderBy('signingVolume', 'desc');
}
findQuery = findQuery.limit(perPage).offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return {
organisations: results,
totalPages: Math.ceil(Number(count) / perPage),
};
}

View File

@ -71,7 +71,7 @@ export const getMonthlyActiveUsers = async () => {
)
.as('cume_count'),
])
.where(sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`)
.where(() => sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`)
.groupBy(({ fn }) => fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']))
.orderBy('month', 'desc')
.limit(12);

View File

@ -19,7 +19,7 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import type { TRecipientAccessAuth, TRecipientActionAuth } from '../../types/document-auth';
import type { TRecipientAccessAuth } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
@ -37,7 +37,6 @@ export type CompleteDocumentWithTokenOptions = {
token: string;
id: EnvelopeIdOptions;
userId?: number;
authOptions?: TRecipientActionAuth;
accessAuthOptions?: TRecipientAccessAuth;
requestMetadata?: RequestMetadata;
nextSigner?: {

View File

@ -248,6 +248,14 @@ export const findDocuments = async ({
url: true,
},
},
envelopeItems: {
select: {
id: true,
envelopeId: true,
title: true,
order: true,
},
},
},
}),
prisma.envelope.count({

View File

@ -92,6 +92,10 @@ export const getDocumentAndSenderByToken = async ({
},
envelopeItems: {
select: {
id: true,
title: true,
order: true,
envelopeId: true,
documentData: true,
},
},
@ -99,6 +103,7 @@ export const getDocumentAndSenderByToken = async ({
select: {
name: true,
teamEmail: true,
url: true,
teamGlobalSettings: {
select: {
brandingEnabled: true,

View File

@ -63,5 +63,8 @@ export const getDocumentWithDetailsById = async ({
documentId: legacyDocumentId,
password: null,
},
envelopeItems: envelope.envelopeItems.map((envelopeItem) => ({
...envelopeItem,
})),
};
};

View File

@ -1,7 +1,5 @@
import { EnvelopeType, TeamMemberRole } from '@prisma/client';
import type { Prisma, User } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
@ -215,13 +213,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
],
};
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
teamId,
deletedAt: null,
folderId,
};
let notSignedCountsGroupByArgs = null;
@ -265,8 +264,16 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
ownerCountsWhereInput = {
...ownerCountsWhereInput,
...visibilityFiltersWhereInput,
...searchFilter,
AND: [
...(Array.isArray(visibilityFiltersWhereInput.AND)
? visibilityFiltersWhereInput.AND
: visibilityFiltersWhereInput.AND
? [visibilityFiltersWhereInput.AND]
: []),
searchFilter,
rootPageFilter,
folderId ? { folderId } : {},
],
};
if (teamEmail) {
@ -285,6 +292,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
],
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
};
notSignedCountsGroupByArgs = {
@ -296,7 +304,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
folderId,
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
@ -306,6 +313,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
},
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
} satisfies Prisma.EnvelopeGroupByArgs;
@ -318,7 +326,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
folderId,
OR: [
{
status: ExtendedDocumentStatus.PENDING,
@ -342,6 +349,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
},
],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
} satisfies Prisma.EnvelopeGroupByArgs;
}

View File

@ -44,7 +44,7 @@ export const resendDocument = async ({
recipients,
teamId,
requestMetadata,
}: ResendDocumentOptions): Promise<void> => {
}: ResendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@ -103,7 +103,7 @@ export const resendDocument = async ({
).recipientSigningRequest;
if (!isRecipientSigningRequestEmailEnabled) {
return;
return envelope;
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
@ -230,4 +230,6 @@ export const resendDocument = async ({
);
}),
);
return envelope;
};

View File

@ -1,4 +1,4 @@
import type { DocumentData, Envelope, EnvelopeItem } from '@prisma/client';
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentStatus,
@ -20,7 +20,14 @@ import { validateCheckboxLength } from '../../advanced-fields-validation/validat
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldAndMetaSchema,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
@ -174,72 +181,22 @@ export const sendDocument = async ({
const fieldsToAutoInsert: { fieldId: number; customText: string }[] = [];
// Auto insert radio and checkboxes that have default values.
// Validate and autoinsert fields for V2 envelopes.
if (envelope.internalVersion === 2) {
for (const field of envelope.fields) {
if (field.type === FieldType.RADIO) {
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
for (const unknownField of envelope.fields) {
const recipient = envelope.recipients.find((r) => r.id === unknownField.recipientId);
const checkedItemIndex = values.findIndex((value) => value.checked);
if (checkedItemIndex !== -1) {
fieldsToAutoInsert.push({
fieldId: field.id,
customText: toRadioCustomText(checkedItemIndex),
});
}
}
if (field.type === FieldType.DROPDOWN) {
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
if (defaultValue && values.some((value) => value.value === defaultValue)) {
fieldsToAutoInsert.push({
fieldId: field.id,
customText: defaultValue,
});
}
}
if (field.type === FieldType.CHECKBOX) {
const {
values = [],
validationRule,
validationLength,
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkedIndices: number[] = [];
values.forEach((value, i) => {
if (value.checked) {
checkedIndices.push(i);
}
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
let isValid = true;
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField);
if (validationRule && validationLength) {
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
if (!validation) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid checkbox validation rule',
});
}
isValid = validateCheckboxLength(
checkedIndices.length,
validation.value,
validationLength,
);
}
if (isValid) {
fieldsToAutoInsert.push({
fieldId: field.id,
customText: toCheckboxCustomText(checkedIndices),
});
}
// Only auto-insert fields if the recipient has not been sent the document yet.
if (fieldToAutoInsert && recipient.sendStatus !== SendStatus.SENT) {
fieldsToAutoInsert.push(fieldToAutoInsert);
}
}
}
@ -259,6 +216,7 @@ export const sendDocument = async ({
if (envelope.internalVersion === 2) {
const autoInsertedFields = await Promise.all(
fieldsToAutoInsert.map(async (field) => {
// Warning: Only auto-insert fields if the recipient has not been sent the document yet.
return await tx.field.update({
where: {
id: field.fieldId,
@ -371,3 +329,113 @@ const injectFormValuesIntoDocument = async (
},
});
};
/**
* Extracts the auto insertion values for a given field.
*
* If field is not auto insertable, returns `null`.
*/
export const extractFieldAutoInsertValues = (
unknownField: Field,
): { fieldId: number; customText: string } | null => {
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
if (parsedField.error) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message,
});
}
const field = parsedField.data;
const fieldId = unknownField.id;
// Auto insert text fields with prefilled values.
if (field.type === FieldType.TEXT) {
const { text } = ZTextFieldMeta.parse(field.fieldMeta);
if (text) {
return {
fieldId,
customText: text,
};
}
}
// Auto insert number fields with prefilled values.
if (field.type === FieldType.NUMBER) {
const { value } = ZNumberFieldMeta.parse(field.fieldMeta);
if (value) {
return {
fieldId,
customText: value,
};
}
}
// Auto insert radio fields with the pre-checked value.
if (field.type === FieldType.RADIO) {
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedItemIndex = values.findIndex((value) => value.checked);
if (checkedItemIndex !== -1) {
return {
fieldId,
customText: toRadioCustomText(checkedItemIndex),
};
}
}
// Auto insert dropdown fields with the default value.
if (field.type === FieldType.DROPDOWN) {
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
if (defaultValue && values.some((value) => value.value === defaultValue)) {
return {
fieldId,
customText: defaultValue,
};
}
}
// Auto insert checkbox fields with the pre-checked values.
if (field.type === FieldType.CHECKBOX) {
const {
values = [],
validationRule,
validationLength,
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkedIndices: number[] = [];
values.forEach((value, i) => {
if (value.checked) {
checkedIndices.push(i);
}
});
let isValid = true;
if (validationRule && validationLength) {
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
if (!validation) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid checkbox validation rule',
});
}
isValid = validateCheckboxLength(checkedIndices.length, validation.value, validationLength);
}
if (isValid && checkedIndices.length > 0) {
return {
fieldId,
customText: toCheckboxCustomText(checkedIndices),
};
}
}
return null;
};

View File

@ -31,26 +31,16 @@ export const viewedDocument = async ({
type: EnvelopeType.DOCUMENT,
},
},
include: {
envelope: {
include: {
documentMeta: true,
recipients: true,
},
},
},
});
if (!recipient) {
return;
}
const { envelope } = recipient;
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED,
envelopeId: envelope.id,
envelopeId: recipient.envelopeId,
user: {
name: recipient.name,
email: recipient.email,
@ -86,7 +76,7 @@ export const viewedDocument = async ({
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
envelopeId: envelope.id,
envelopeId: recipient.envelopeId,
user: {
name: recipient.name,
email: recipient.email,
@ -103,6 +93,16 @@ export const viewedDocument = async ({
});
});
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: recipient.envelopeId,
},
include: {
documentMeta: true,
recipients: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_OPENED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),

View File

@ -16,11 +16,16 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type {
TDocumentAccessAuthTypes,
TDocumentActionAuthTypes,
TRecipientAccessAuthTypes,
TRecipientActionAuthTypes,
} from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import type { TFieldAndMeta } from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
@ -34,6 +39,25 @@ import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & {
documentDataId: string;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
type CreateEnvelopeRecipientOptions = {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
fields?: CreateEnvelopeRecipientFieldOptions[];
};
export type CreateEnvelopeOptions = {
userId: number;
teamId: number;
@ -46,7 +70,6 @@ export type CreateEnvelopeOptions = {
envelopeItems: { title?: string; documentDataId: string; order?: number }[];
formValues?: TDocumentFormValues;
timezone?: string;
userTimezone?: string;
templateType?: TemplateType;
@ -56,7 +79,7 @@ export type CreateEnvelopeOptions = {
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
recipients?: TCreateEnvelopeRequest['recipients'];
recipients?: CreateEnvelopeRecipientOptions[];
folderId?: string;
};
attachments?: Array<{
@ -83,7 +106,6 @@ export const createEnvelope = async ({
title,
externalId,
formValues,
timezone,
userTimezone,
folderId,
templateType,
@ -142,6 +164,7 @@ export const createEnvelope = async ({
let envelopeItems: { title?: string; documentDataId: string; order?: number }[] =
data.envelopeItems;
// Todo: Envelopes - Remove
if (normalizePdf) {
envelopeItems = await Promise.all(
data.envelopeItems.map(async (item) => {
@ -219,7 +242,7 @@ export const createEnvelope = async ({
// userTimezone is last because it's always passed in regardless of the organisation/team settings
// for uploads from the frontend
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
const timezoneToUse = meta?.timezone || settings.documentTimezone || userTimezone;
const documentMeta = await prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, {

View File

@ -1,10 +1,12 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { extractFieldAutoInsertValues } from '../document/send-document';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@ -98,14 +100,28 @@ export const getEnvelopeForDirectTemplateSigning = async ({
});
}
const documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: envelope.authOptions,
recipient,
userId,
authOptions: accessAuth,
// Currently not using this since for direct templates "User" access means they just need to be
// logged in.
// const documentAccessValid = await isRecipientAuthorized({
// type: 'ACCESS',
// documentAuthOptions: envelope.authOptions,
// recipient,
// userId,
// authOptions: accessAuth,
// });
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
});
// Ensure typesafety when we add more options.
const documentAccessValid = derivedRecipientAccessAuth.every((auth) =>
match(auth)
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(userId))
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
.exhaustive(),
);
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid access values',
@ -128,7 +144,20 @@ export const getEnvelopeForDirectTemplateSigning = async ({
envelope,
recipient: {
...recipient,
token: envelope.directLink?.token || '',
directToken: envelope.directLink?.token || '',
fields: recipient.fields.map((field) => {
const autoInsertValue = extractFieldAutoInsertValues(field);
if (!autoInsertValue) {
return field;
}
return {
...field,
inserted: true,
customText: autoInsertValue.customText,
};
}),
},
recipientSignature: null,
isRecipientsTurn: true,

View File

@ -2,7 +2,6 @@ import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } fro
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import DocumentMetaSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeSchema';
@ -12,7 +11,7 @@ import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { ZFieldSchema } from '../../types/field';
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
import { ZRecipientLiteSchema } from '../../types/recipient';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { getTeamSettings } from '../team/get-team-settings';
@ -64,28 +63,21 @@ export const ZEnvelopeForSigningResponse = z.object({
rejectionReason: true,
})
.extend({
fields: ZFieldSchema.omit({
documentId: true,
templateId: true,
fields: ZEnvelopeFieldSchema.extend({
signature: SignatureSchema.pick({
signatureImageAsBase64: true,
typedSignature: true,
}).nullish(),
}).array(),
})
.array(),
envelopeItems: EnvelopeItemSchema.pick({
envelopeId: true,
id: true,
title: true,
documentDataId: true,
order: true,
})
.extend({
documentData: DocumentDataSchema.pick({
type: true,
id: true,
data: true,
initialData: true,
}),
})
.array(),
}).array(),
team: TeamSchema.pick({
id: true,
@ -117,6 +109,7 @@ export const ZEnvelopeForSigningResponse = z.object({
signingOrder: true,
rejectionReason: true,
}).extend({
directToken: z.string().nullish(),
fields: ZFieldSchema.omit({
documentId: true,
templateId: true,
@ -199,11 +192,7 @@ export const getEnvelopeForRecipientSigning = async ({
signingOrder: 'asc',
},
},
envelopeItems: {
include: {
documentData: true,
},
},
envelopeItems: true,
team: {
select: {
id: true,

View File

@ -54,54 +54,3 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};
export const getEnvelopeDirectTemplateRequiredAccessData = async ({ token }: { token: string }) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.TEMPLATE,
directLink: {
enabled: true,
token,
},
status: DocumentStatus.DRAFT,
},
include: {
recipients: {
where: {
token,
},
},
directLink: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const recipient = envelope.recipients.find(
(r) => r.id === envelope.directLink?.directTemplateRecipientId,
);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const recipientUserAccount = await prisma.user.findFirst({
where: {
email: recipient.email.toLowerCase(),
},
select: {
id: true,
},
});
return {
recipientEmail: recipient.email,
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};

View File

@ -26,9 +26,9 @@ export interface CreateEnvelopeFieldsOptions {
envelopeItemId?: string;
recipientId: number;
pageNumber: number;
pageX: number;
pageY: number;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
})[];
@ -122,9 +122,9 @@ export const createEnvelopeFields = async ({
const newlyCreatedFields = await tx.field.createManyAndReturn({
data: validatedFields.map((field) => ({
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',

View File

@ -11,7 +11,7 @@ export type GetFieldByIdOptions = {
userId: number;
teamId: number;
fieldId: number;
envelopeType: EnvelopeType;
envelopeType?: EnvelopeType;
};
export const getFieldById = async ({
@ -41,7 +41,7 @@ export const getFieldById = async ({
type: 'envelopeId',
id: field.envelopeId,
},
type: envelopeType,
type: envelopeType ?? null,
userId,
teamId,
});

View File

@ -158,7 +158,7 @@ export const setFieldsForDocument = async ({
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value),
String(numberFieldParsedMeta.value || ''),
numberFieldParsedMeta,
false,
);

View File

@ -129,7 +129,7 @@ export const setFieldsForTemplate = async ({
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value),
String(numberFieldParsedMeta.value || ''),
numberFieldParsedMeta,
);
if (errors.length > 0) {

View File

@ -10,18 +10,21 @@ import {
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateDocumentFieldsOptions {
export interface UpdateEnvelopeFieldsOptions {
userId: number;
teamId: number;
documentId: number;
id: EnvelopeIdOptions;
type?: EnvelopeType | null; // Only used to enforce the type.
fields: {
id: number;
type?: FieldType;
pageNumber?: number;
envelopeItemId?: string;
pageX?: number;
pageY?: number;
width?: number;
@ -31,19 +34,17 @@ export interface UpdateDocumentFieldsOptions {
requestMetadata: ApiRequestMetadata;
}
export const updateDocumentFields = async ({
export const updateEnvelopeFields = async ({
userId,
teamId,
documentId,
id,
type = null,
fields,
requestMetadata,
}: UpdateDocumentFieldsOptions) => {
}: UpdateEnvelopeFieldsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
id,
type,
userId,
teamId,
});
@ -53,18 +54,19 @@ export const updateDocumentFields = async ({
include: {
recipients: true,
fields: true,
envelopeItems: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -96,6 +98,29 @@ export const updateDocumentFields = async ({
});
}
const fieldType = field.type || originalField.type;
const fieldMetaType = field.fieldMeta?.type || originalField.fieldMeta?.type;
// Not going to mess with V1 envelopes.
if (
envelope.internalVersion === 2 &&
fieldMetaType &&
fieldMetaType.toLowerCase() !== fieldType.toLowerCase()
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Field meta type does not match the field type',
});
}
if (
field.envelopeItemId &&
!envelope.envelopeItems.some((item) => item.id === field.envelopeItemId)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item not found',
});
}
return {
originalField,
updateData: field,
@ -118,27 +143,30 @@ export const updateDocumentFields = async ({
width: updateData.width,
height: updateData.height,
fieldMeta: updateData.fieldMeta,
envelopeItemId: updateData.envelopeItemId,
},
});
const changes = diffFieldChanges(originalField, updatedField);
// Handle field updated audit log.
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: updatedField.secondaryId,
fieldRecipientEmail: recipientEmail,
fieldRecipientId: updatedField.recipientId,
fieldType: updatedField.type,
changes,
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
const changes = diffFieldChanges(originalField, updatedField);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: updatedField.secondaryId,
fieldRecipientEmail: recipientEmail,
fieldRecipientId: updatedField.recipientId,
fieldType: updatedField.type,
changes,
},
}),
});
}
}
return updatedField;

View File

@ -1,116 +0,0 @@
import { EnvelopeType, type FieldType } from '@prisma/client';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateTemplateFieldsOptions {
userId: number;
teamId: number;
templateId: number;
fields: {
id: number;
type?: FieldType;
pageNumber?: number;
pageX?: number;
pageY?: number;
width?: number;
height?: number;
fieldMeta?: TFieldMetaSchema;
}[];
}
export const updateTemplateFields = async ({
userId,
teamId,
templateId,
fields,
}: UpdateTemplateFieldsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
fields: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const fieldsToUpdate = fields.map((field) => {
const originalField = envelope.fields.find((existingField) => existingField.id === field.id);
if (!originalField) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Field with id ${field.id} not found`,
});
}
const recipient = envelope.recipients.find(
(recipient) => recipient.id === originalField.recipientId,
);
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient attached to field ${field.id} not found`,
});
}
// Check whether the recipient associated with the field can be modified.
if (!canRecipientFieldsBeModified(recipient, envelope.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Cannot modify a field where the recipient has already interacted with the document',
});
}
return {
updateData: field,
};
});
const updatedFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
fieldsToUpdate.map(async ({ updateData }) => {
const updatedField = await tx.field.update({
where: {
id: updateData.id,
},
data: {
type: updateData.type,
page: updateData.pageNumber,
positionX: updateData.pageX,
positionY: updateData.pageY,
width: updateData.width,
height: updateData.height,
fieldMeta: updateData.fieldMeta,
},
});
return updatedField;
}),
);
});
return {
fields: updatedFields.map((field) => mapFieldToLegacyField(field, envelope)),
};
};

View File

@ -1,7 +1,11 @@
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
NEXT_PUBLIC_WEBAPP_URL,
USE_INTERNAL_URL_BROWSERLESS,
} from '../../constants/app';
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
import { env } from '../../utils/env';
import { encryptSecondaryData } from '../crypto/encrypt';
@ -48,14 +52,19 @@ export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfO
{
name: 'language',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
url: USE_INTERNAL_URL_BROWSERLESS()
? NEXT_PUBLIC_WEBAPP_URL()
: NEXT_PRIVATE_INTERNAL_WEBAPP_URL(),
},
]);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
await page.goto(
`${USE_INTERNAL_URL_BROWSERLESS() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`,
{
waitUntil: 'networkidle',
timeout: 10_000,
},
);
// !: This is a workaround to ensure the page is loaded correctly.
// !: It's not clear why but suddenly browserless cdp connections would

View File

@ -1,7 +1,11 @@
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
NEXT_PUBLIC_WEBAPP_URL,
USE_INTERNAL_URL_BROWSERLESS,
} from '../../constants/app';
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
import { env } from '../../utils/env';
import { encryptSecondaryData } from '../crypto/encrypt';
@ -48,14 +52,19 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
{
name: 'lang',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
url: USE_INTERNAL_URL_BROWSERLESS()
? NEXT_PUBLIC_WEBAPP_URL()
: NEXT_PRIVATE_INTERNAL_WEBAPP_URL(),
},
]);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
await page.goto(
`${USE_INTERNAL_URL_BROWSERLESS() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`,
{
waitUntil: 'networkidle',
timeout: 10_000,
},
);
// !: This is a workaround to ensure the page is loaded correctly.
// !: It's not clear why but suddenly browserless cdp connections would

View File

@ -88,11 +88,13 @@ export const addUserToOrganisation = async ({
organisationId,
organisationGroups,
organisationMemberRole,
bypassEmail = false,
}: {
userId: number;
organisationId: string;
organisationGroups: OrganisationGroup[];
organisationMemberRole: OrganisationMemberRole;
bypassEmail?: boolean;
}) => {
const organisationGroupToUse = organisationGroups.find(
(group) =>
@ -122,13 +124,15 @@ export const addUserToOrganisation = async ({
},
});
await jobs.triggerJob({
name: 'send.organisation-member-joined.email',
payload: {
organisationId,
memberUserId: userId,
},
});
if (!bypassEmail) {
await jobs.triggerJob({
name: 'send.organisation-member-joined.email',
payload: {
organisationId,
memberUserId: userId,
},
});
}
},
{ timeout: 30_000 },
);

View File

@ -1,5 +1,7 @@
import Konva from 'konva';
// sort-imports-ignore
import 'konva/skia-backend';
import Konva from 'konva';
import path from 'node:path';
import type { Canvas } from 'skia-canvas';
import { FontLibrary } from 'skia-canvas';
@ -21,21 +23,19 @@ export const insertFieldInPDFV2 = async ({
}: InsertFieldInPDFV2Options) => {
const fontPath = path.join(process.cwd(), 'public/fonts');
FontLibrary.use([
path.join(fontPath, 'caveat.ttf'),
path.join(fontPath, 'noto-sans.ttf'),
path.join(fontPath, 'noto-sans-japanese.ttf'),
path.join(fontPath, 'noto-sans-chinese.ttf'),
path.join(fontPath, 'noto-sans-korean.ttf'),
]);
FontLibrary.use({
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
['Noto Sans']: [path.join(fontPath, 'noto-sans.ttf')],
['Noto Sans Japanese']: [path.join(fontPath, 'noto-sans-japanese.ttf')],
['Noto Sans Chinese']: [path.join(fontPath, 'noto-sans-chinese.ttf')],
['Noto Sans Korean']: [path.join(fontPath, 'noto-sans-korean.ttf')],
});
const stage = new Konva.Stage({ width: pageWidth, height: pageHeight });
const layer = new Konva.Layer();
const insertedFields = fields.filter((field) => field.inserted);
// Render the fields onto the layer.
for (const field of insertedFields) {
for (const field of fields) {
renderField({
scale: 1,
field: {

View File

@ -1,13 +1,22 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { AppError } from '../../errors/app-error';
import { flattenAnnotations } from './flatten-annotations';
import { flattenForm, removeOptionalContentGroups } from './flatten-form';
export const normalizePdf = async (pdf: Buffer) => {
const pdfDoc = await PDFDocument.load(pdf).catch(() => null);
const pdfDoc = await PDFDocument.load(pdf).catch((e) => {
console.error(`PDF normalization error: ${e.message}`);
if (!pdfDoc) {
return pdf;
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is not a valid PDF',
});
});
if (pdfDoc.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is encrypted',
});
}
removeOptionalContentGroups(pdfDoc);

View File

@ -15,7 +15,7 @@ import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface CreateDocumentRecipientsOptions {
export interface CreateEnvelopeRecipientsOptions {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
@ -30,16 +30,16 @@ export interface CreateDocumentRecipientsOptions {
requestMetadata: ApiRequestMetadata;
}
export const createDocumentRecipients = async ({
export const createEnvelopeRecipients = async ({
userId,
teamId,
id,
recipients: recipientsToCreate,
requestMetadata,
}: CreateDocumentRecipientsOptions) => {
}: CreateEnvelopeRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
type: null,
userId,
teamId,
});
@ -62,13 +62,13 @@ export const createDocumentRecipients = async ({
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -112,21 +112,23 @@ export const createDocumentRecipients = async ({
});
// Handle recipient created audit log.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: createdRecipient.email,
recipientName: createdRecipient.name,
recipientId: createdRecipient.id,
recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: createdRecipient.email,
recipientName: createdRecipient.name,
recipientId: createdRecipient.id,
recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
},
}),
});
}
return createdRecipient;
}),

View File

@ -1,115 +0,0 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface CreateTemplateRecipientsOptions {
userId: number;
teamId: number;
templateId: number;
recipients: {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
export const createTemplateRecipients = async ({
userId,
teamId,
templateId,
recipients: recipientsToCreate,
}: CreateTemplateRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const template = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const recipientsHaveActionAuth = recipientsToCreate.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
}));
const createdRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const createdRecipient = await tx.recipient.create({
data: {
envelopeId: template.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,
},
});
return createdRecipient;
}),
);
});
return {
recipients: createdRecipients.map((recipient) =>
mapRecipientToLegacyRecipient(recipient, template),
),
};
};

View File

@ -14,26 +14,27 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { canRecipientBeModified } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteDocumentRecipientOptions {
export interface DeleteEnvelopeRecipientOptions {
userId: number;
teamId: number;
recipientId: number;
requestMetadata: ApiRequestMetadata;
}
export const deleteDocumentRecipient = async ({
export const deleteEnvelopeRecipient = async ({
userId,
teamId,
recipientId,
requestMetadata,
}: DeleteDocumentRecipientOptions) => {
}: DeleteEnvelopeRecipientOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.DOCUMENT,
recipients: {
some: {
id: recipientId,
@ -48,6 +49,9 @@ export const deleteDocumentRecipient = async ({
where: {
id: recipientId,
},
include: {
fields: true,
},
},
},
});
@ -89,24 +93,43 @@ export const deleteDocumentRecipient = async ({
});
}
const deletedRecipient = await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
recipientId: recipientToDelete.id,
recipientRole: recipientToDelete.role,
},
}),
if (!canRecipientBeModified(recipientToDelete, recipientToDelete.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Recipient has already interacted with the document.',
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelope.id,
},
type: null,
userId,
teamId,
});
const deletedRecipient = await prisma.$transaction(async (tx) => {
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
recipientId: recipientToDelete.id,
recipientRole: recipientToDelete.role,
},
}),
});
}
return await tx.recipient.delete({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
});
});
@ -116,7 +139,11 @@ export const deleteDocumentRecipient = async ({
).recipientRemoved;
// Send email to deleted recipient.
if (recipientToDelete.sendStatus === SendStatus.SENT && isRecipientRemovedEmailEnabled) {
if (
recipientToDelete.sendStatus === SendStatus.SENT &&
isRecipientRemovedEmailEnabled &&
envelope.type === EnvelopeType.DOCUMENT
) {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {

View File

@ -1,58 +0,0 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteTemplateRecipientOptions {
userId: number;
teamId: number;
recipientId: number;
}
export const deleteTemplateRecipient = async ({
userId,
teamId,
recipientId,
}: DeleteTemplateRecipientOptions): Promise<void> => {
const recipientToDelete = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelope: {
type: EnvelopeType.TEMPLATE,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
});
if (!recipientToDelete) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: recipientToDelete.envelopeId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
if (!recipientToDelete || recipientToDelete.id !== recipientId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
await prisma.recipient.delete({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
});
};

View File

@ -1,5 +1,4 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
@ -16,29 +15,38 @@ import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { extractLegacyIds } from '../../universal/id';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateDocumentRecipientsOptions {
export interface UpdateEnvelopeRecipientsOptions {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
recipients: RecipientData[];
recipients: {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
requestMetadata: ApiRequestMetadata;
}
export const updateDocumentRecipients = async ({
export const updateEnvelopeRecipients = async ({
userId,
teamId,
id,
recipients,
requestMetadata,
}: UpdateDocumentRecipientsOptions) => {
}: UpdateEnvelopeRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
type: null,
userId,
teamId,
});
@ -62,13 +70,13 @@ export const updateDocumentRecipients = async ({
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -160,24 +168,26 @@ export const updateDocumentRecipients = async ({
});
}
const changes = diffRecipientChanges(originalRecipient, updatedRecipient);
// Handle recipient updated audit log.
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: updatedRecipient.email,
recipientName: updatedRecipient.name,
recipientId: updatedRecipient.id,
recipientRole: updatedRecipient.role,
changes,
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
const changes = diffRecipientChanges(originalRecipient, updatedRecipient);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: updatedRecipient.email,
recipientName: updatedRecipient.name,
recipientId: updatedRecipient.id,
recipientRole: updatedRecipient.role,
changes,
},
}),
});
}
}
return updatedRecipient;
@ -188,19 +198,8 @@ export const updateDocumentRecipients = async ({
return {
recipients: updatedRecipients.map((recipient) => ({
...recipient,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
templateId: null,
...extractLegacyIds(envelope),
fields: recipient.fields.map((field) => mapFieldToLegacyField(field, envelope)),
})),
};
};
type RecipientData = {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};

View File

@ -1,168 +0,0 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateTemplateRecipientsOptions {
userId: number;
teamId: number;
templateId: number;
recipients: {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
export const updateTemplateRecipients = async ({
userId,
teamId,
templateId,
recipients,
}: UpdateTemplateRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth && !envelope.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const recipientsToUpdate = recipients.map((recipient) => {
const originalRecipient = envelope.recipients.find(
(existingRecipient) => existingRecipient.id === recipient.id,
);
if (!originalRecipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Recipient with id ${recipient.id} not found`,
});
}
return {
originalRecipient,
recipientUpdateData: recipient,
};
});
const updatedRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
recipientsToUpdate.map(async ({ originalRecipient, recipientUpdateData }) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions);
if (
recipientUpdateData.actionAuth !== undefined ||
recipientUpdateData.accessAuth !== undefined
) {
authOptions = createRecipientAuthOptions({
accessAuth: recipientUpdateData.accessAuth || authOptions.accessAuth,
actionAuth: recipientUpdateData.actionAuth || authOptions.actionAuth,
});
}
const mergedRecipient = {
...originalRecipient,
...recipientUpdateData,
};
const updatedRecipient = await tx.recipient.update({
where: {
id: originalRecipient.id,
envelopeId: envelope.id,
},
data: {
name: mergedRecipient.name,
email: mergedRecipient.email,
role: mergedRecipient.role,
signingOrder: mergedRecipient.signingOrder,
envelopeId: envelope.id,
sendStatus:
mergedRecipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
mergedRecipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
authOptions,
},
include: {
fields: true,
},
});
// Clear all fields if the recipient role is changed to a type that cannot have fields.
if (
originalRecipient.role !== updatedRecipient.role &&
(updatedRecipient.role === RecipientRole.CC ||
updatedRecipient.role === RecipientRole.VIEWER)
) {
await tx.field.deleteMany({
where: {
recipientId: updatedRecipient.id,
},
});
}
return updatedRecipient;
}),
);
});
return {
recipients: updatedRecipients.map((recipient) => ({
...recipient,
documentId: null,
templateId: mapSecondaryIdToTemplateId(envelope.secondaryId),
fields: recipient.fields.map((field) => mapFieldToLegacyField(field, envelope)),
})),
};
};

View File

@ -3,6 +3,7 @@ import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
DocumentStatus,
EnvelopeType,
@ -26,7 +27,7 @@ import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/f
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { ZFieldMetaSchema } from '../../types/field-meta';
@ -68,6 +69,10 @@ export type CreateDocumentFromDirectTemplateOptions = {
name?: string;
email: string;
};
nextSigner?: {
email: string;
name: string;
};
};
type CreatedDirectRecipientField = {
@ -77,6 +82,7 @@ type CreatedDirectRecipientField = {
export const ZCreateDocumentFromDirectTemplateResponseSchema = z.object({
token: z.string(),
envelopeId: z.string(),
documentId: z.number(),
recipientId: z.number(),
});
@ -92,6 +98,7 @@ export const createDocumentFromDirectTemplate = async ({
directTemplateExternalId,
signedFieldValues,
templateUpdatedAt,
nextSigner,
requestMetadata,
user,
}: CreateDocumentFromDirectTemplateOptions): Promise<TCreateDocumentFromDirectTemplateResponse> => {
@ -128,6 +135,17 @@ export const createDocumentFromDirectTemplate = async ({
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' });
}
if (
nextSigner &&
(!directTemplateEnvelope.documentMeta?.allowDictateNextSigner ||
directTemplateEnvelope.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'You need to enable allowDictateNextSigner and sequential signing to dictate the next signer',
});
}
const directTemplateEnvelopeLegacyId = mapSecondaryIdToTemplateId(
directTemplateEnvelope.secondaryId,
);
@ -197,6 +215,12 @@ export const createDocumentFromDirectTemplate = async ({
const fieldsToProcess = directTemplateRecipient.fields.filter((templateField) => {
const signedFieldValue = signedFieldValues.find((value) => value.fieldId === templateField.id);
// Custom logic for V2 to include all fields, since v1 excludes read only
// and prefilled fields.
if (directTemplateEnvelope.internalVersion === 2) {
return true;
}
// Include if it's required or has a signed value
return isRequiredField(templateField) || signedFieldValue !== undefined;
});
@ -450,19 +474,28 @@ export const createDocumentFromDirectTemplate = async ({
signingOrder: directTemplateRecipient.signingOrder,
fields: {
createMany: {
data: directTemplateNonSignatureFields.map(({ templateField, customText }) => ({
envelopeId: createdEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[templateField.envelopeItemId],
type: templateField.type,
page: templateField.page,
positionX: templateField.positionX,
positionY: templateField.positionY,
width: templateField.width,
height: templateField.height,
customText: customText ?? '',
inserted: true,
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
})),
data: directTemplateNonSignatureFields.map(({ templateField, customText }) => {
let inserted = true;
// Custom logic for V2 to only insert if values exist.
if (directTemplateEnvelope.internalVersion === 2) {
inserted = customText !== '';
}
return {
envelopeId: createdEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[templateField.envelopeItemId],
type: templateField.type,
page: templateField.page,
positionX: templateField.positionX,
positionY: templateField.positionY,
width: templateField.width,
height: templateField.height,
customText: customText ?? '',
inserted,
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
};
}),
},
},
},
@ -630,6 +663,77 @@ export const createDocumentFromDirectTemplate = async ({
}),
];
if (nextSigner) {
const pendingRecipients = await tx.recipient.findMany({
select: {
id: true,
signingOrder: true,
name: true,
email: true,
role: true,
},
where: {
envelopeId: createdEnvelope.id,
signingStatus: {
not: SigningStatus.SIGNED,
},
role: {
not: RecipientRole.CC,
},
},
// Composite sort so our next recipient is always the one with the lowest signing order or id
// if there is a tie.
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
});
const nextRecipient = pendingRecipients[0];
if (nextRecipient) {
auditLogsToCreate.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: createdEnvelope.id,
user: {
name: user?.name || directRecipientName || '',
email: user?.email || directRecipientEmail,
},
metadata: requestMetadata,
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
},
}),
);
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
});
}
}
await tx.documentAuditLog.createMany({
data: auditLogsToCreate,
});
@ -727,6 +831,7 @@ export const createDocumentFromDirectTemplate = async ({
return {
token,
envelopeId: createdEnvelope.id,
documentId: incrementedDocumentId.documentId,
recipientId,
};

View File

@ -87,5 +87,9 @@ export const getTemplateByDirectLinkToken = async ({
},
recipients: recipientsWithMappedFields,
fields: recipientsWithMappedFields.flatMap((recipient) => recipient.fields),
envelopeItems: envelope.envelopeItems.map((item) => ({
id: item.id,
envelopeId: item.envelopeId,
})),
};
};

View File

@ -29,6 +29,7 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
envelopeItems: {
select: {
id: true,
envelopeId: true,
documentData: true,
},
},
@ -94,5 +95,9 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
}
: null,
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
envelopeItems: envelope.envelopeItems.map((envelopeItem) => ({
id: envelopeItem.id,
envelopeId: envelopeItem.envelopeId,
})),
};
};

View File

@ -29,7 +29,7 @@ export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWeb
const signature = sign(body);
await Promise.race([
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL}/api/webhook/trigger`, {
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/api/webhook/trigger`, {
method: 'POST',
headers: {
'content-type': 'application/json',