Merge branch 'main' into feat/public-completed-document-access

This commit is contained in:
ephraimduncan
2026-04-22 18:29:11 +00:00
625 changed files with 48707 additions and 14739 deletions
@@ -38,19 +38,19 @@ export const createStripeCustomerRoute = adminProcedure
throw new AppError(AppErrorCode.NOT_FOUND);
}
await prisma.$transaction(async (tx) => {
const stripeCustomer = await createCustomer({
name: organisation.name,
email: organisation.owner.email,
});
// Create Stripe customer outside a transaction to avoid holding a
// connection open during the external API call.
const stripeCustomer = await createCustomer({
name: organisation.name,
email: organisation.owner.email,
});
await tx.organisation.update({
where: {
id: organisationId,
},
data: {
customerId: stripeCustomer.id,
},
});
await prisma.organisation.update({
where: {
id: organisationId,
},
data: {
customerId: stripeCustomer.id,
},
});
});
@@ -0,0 +1,126 @@
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZDeleteAdminOrganisationMemberRequestSchema,
ZDeleteAdminOrganisationMemberResponseSchema,
} from './delete-organisation-member.types';
export const deleteAdminOrganisationMemberRoute = adminProcedure
.input(ZDeleteAdminOrganisationMemberRequestSchema)
.output(ZDeleteAdminOrganisationMemberResponseSchema)
.mutation(async ({ ctx, input }) => {
const { organisationId, organisationMemberId } = input;
ctx.logger.info({
input: {
organisationId,
organisationMemberId,
},
});
const organisation = await prisma.organisation.findUnique({
where: {
id: organisationId,
},
include: {
subscription: true,
organisationClaim: true,
teams: {
select: {
id: true,
},
},
members: {
select: {
id: true,
userId: true,
},
},
invites: {
where: {
status: OrganisationMemberInviteStatus.PENDING,
},
select: {
id: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
const memberToDelete = organisation.members.find(
(member) => member.id === organisationMemberId,
);
if (!memberToDelete) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Member not found in this organisation',
});
}
if (memberToDelete.userId === organisation.ownerUserId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot remove the organisation owner. Transfer ownership first.',
});
}
const newMemberCount = organisation.members.length + organisation.invites.length - 1;
// Removing a member is a reducing operation, so we don't gate it on the
// subscription being present. Sync Stripe only when one exists.
if (organisation.subscription) {
await syncMemberCountWithStripeSeatPlan(
organisation.subscription,
organisation.organisationClaim,
newMemberCount,
);
}
const teamIds = organisation.teams.map((team) => team.id);
await prisma.$transaction(async (tx) => {
// Removing an OrganisationMember cascades the user out of every team in
// the org via OrganisationGroupMember, but their authored Envelope rows
// still reference them. Reassign those to the org owner so they remain
// reachable after the member loses access (mirrors delete-user.ts).
if (teamIds.length > 0) {
await tx.envelope.updateMany({
where: {
userId: memberToDelete.userId,
teamId: {
in: teamIds,
},
},
data: {
userId: organisation.ownerUserId,
},
});
}
await tx.organisationMember.delete({
where: {
id: organisationMemberId,
organisationId,
},
});
});
await jobs.triggerJob({
name: 'send.organisation-member-left.email',
payload: {
organisationId,
memberUserId: memberToDelete.userId,
},
});
});
@@ -0,0 +1,15 @@
import { z } from 'zod';
export const ZDeleteAdminOrganisationMemberRequestSchema = z.object({
organisationId: z.string().min(1),
organisationMemberId: z.string().min(1),
});
export const ZDeleteAdminOrganisationMemberResponseSchema = z.void();
export type TDeleteAdminOrganisationMemberRequest = z.infer<
typeof ZDeleteAdminOrganisationMemberRequestSchema
>;
export type TDeleteAdminOrganisationMemberResponse = z.infer<
typeof ZDeleteAdminOrganisationMemberResponseSchema
>;
@@ -0,0 +1,106 @@
import { OrganisationGroupType } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZDeleteAdminTeamMemberRequestSchema,
ZDeleteAdminTeamMemberResponseSchema,
} from './delete-team-member.types';
export const deleteAdminTeamMemberRoute = adminProcedure
.input(ZDeleteAdminTeamMemberRequestSchema)
.output(ZDeleteAdminTeamMemberResponseSchema)
.mutation(async ({ ctx, input }) => {
const { teamId, memberId } = input;
ctx.logger.info({
input: {
teamId,
memberId,
},
});
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
include: {
organisation: {
select: {
ownerUserId: true,
},
},
teamGroups: {
where: {
organisationGroup: {
type: OrganisationGroupType.INTERNAL_TEAM,
organisationGroupMembers: {
some: {
organisationMember: {
id: memberId,
},
},
},
},
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
const teamGroupToRemoveMemberFrom = team.teamGroups[0];
if (!teamGroupToRemoveMemberFrom) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message:
'Member is not directly assigned to this team. Inherited members cannot be removed here.',
});
}
const member = await prisma.organisationMember.findUnique({
where: {
id: memberId,
},
select: {
userId: true,
},
});
if (!member) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Member not found',
});
}
await prisma.$transaction(async (tx) => {
// Removing a user from a single team drops their INTERNAL_TEAM
// OrganisationGroupMember link, but Envelope rows they authored in this
// team still point at their userId. Reassign to the org owner so those
// envelopes remain reachable after the member loses team access.
await tx.envelope.updateMany({
where: {
userId: member.userId,
teamId,
},
data: {
userId: team.organisation.ownerUserId,
},
});
await tx.organisationGroupMember.delete({
where: {
organisationMemberId_groupId: {
organisationMemberId: memberId,
groupId: teamGroupToRemoveMemberFrom.organisationGroupId,
},
},
});
});
});
@@ -0,0 +1,11 @@
import { z } from 'zod';
export const ZDeleteAdminTeamMemberRequestSchema = z.object({
teamId: z.number().min(1),
memberId: z.string().min(1),
});
export const ZDeleteAdminTeamMemberResponseSchema = z.void();
export type TDeleteAdminTeamMemberRequest = z.infer<typeof ZDeleteAdminTeamMemberRequestSchema>;
export type TDeleteAdminTeamMemberResponse = z.infer<typeof ZDeleteAdminTeamMemberResponseSchema>;
@@ -0,0 +1,82 @@
import { EnvelopeType } from '@prisma/client';
import { PDF_SIZE_A4_72PPI } from '@documenso/lib/constants/pdf';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZAdminDownloadDocumentAuditLogsRequestSchema,
ZAdminDownloadDocumentAuditLogsResponseSchema,
} from './download-document-audit-logs.types';
export const downloadDocumentAuditLogsRoute = adminProcedure
.input(ZAdminDownloadDocumentAuditLogsRequestSchema)
.output(ZAdminDownloadDocumentAuditLogsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { envelopeId } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
const envelope = await prisma.envelope.findFirst({
where: unsafeBuildEnvelopeIdQuery(
{
type: 'envelopeId',
id: envelopeId,
},
EnvelopeType.DOCUMENT,
),
include: {
documentMeta: true,
envelopeItems: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
orderBy: {
id: 'asc',
},
},
fields: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const auditLogPdf = await generateAuditLogPdf({
envelope,
recipients: envelope.recipients,
fields: envelope.fields,
language: envelope.documentMeta.language,
envelopeOwner: {
email: envelope.user.email,
name: envelope.user.name || '',
},
envelopeItems: envelope.envelopeItems.map((item) => item.title),
pageWidth: PDF_SIZE_A4_72PPI.width,
pageHeight: PDF_SIZE_A4_72PPI.height,
});
const result = await auditLogPdf.save();
const base64 = Buffer.from(result).toString('base64');
return {
data: base64,
envelopeTitle: envelope.title,
};
});
@@ -0,0 +1,17 @@
import { z } from 'zod';
export const ZAdminDownloadDocumentAuditLogsRequestSchema = z.object({
envelopeId: z.string(),
});
export const ZAdminDownloadDocumentAuditLogsResponseSchema = z.object({
data: z.string(),
envelopeTitle: z.string(),
});
export type TAdminDownloadDocumentAuditLogsRequest = z.infer<
typeof ZAdminDownloadDocumentAuditLogsRequestSchema
>;
export type TAdminDownloadDocumentAuditLogsResponse = z.infer<
typeof ZAdminDownloadDocumentAuditLogsResponseSchema
>;
@@ -120,14 +120,16 @@ export const findAdminOrganisations = async ({
};
}
const orderBy: Prisma.OrganisationOrderByWithRelationInput[] = query
? [{ subscription: { status: 'asc' } }, { name: 'asc' }]
: [{ createdAt: 'desc' }];
const [data, count] = await Promise.all([
prisma.organisation.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
orderBy,
select: {
id: true,
createdAt: true,
@@ -0,0 +1,16 @@
import { adminFindUnsealedDocuments } from '@documenso/lib/server-only/admin/admin-find-unsealed-documents';
import { adminProcedure } from '../trpc';
import {
ZFindUnsealedDocumentsRequestSchema,
ZFindUnsealedDocumentsResponseSchema,
} from './find-unsealed-documents.types';
export const findUnsealedDocumentsRoute = adminProcedure
.input(ZFindUnsealedDocumentsRequestSchema)
.output(ZFindUnsealedDocumentsResponseSchema)
.query(async ({ input }) => {
const { page, perPage } = input;
return await adminFindUnsealedDocuments({ page, perPage });
});
@@ -0,0 +1,31 @@
import { z } from 'zod';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
export const ZFindUnsealedDocumentsRequestSchema = ZFindSearchParamsSchema.pick({
page: true,
perPage: true,
}).extend({
perPage: z.number().optional().default(20),
});
export const ZAdminUnsealedDocumentSchema = z.object({
id: z.string(),
secondaryId: z.string(),
title: z.string(),
status: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
userId: z.number(),
teamId: z.number(),
ownerName: z.string().nullable(),
ownerEmail: z.string(),
lastSignedAt: z.date().nullable(),
});
export const ZFindUnsealedDocumentsResponseSchema = ZFindResultResponse.extend({
data: ZAdminUnsealedDocumentSchema.array(),
});
export type TFindUnsealedDocumentsRequest = z.infer<typeof ZFindUnsealedDocumentsRequestSchema>;
export type TFindUnsealedDocumentsResponse = z.infer<typeof ZFindUnsealedDocumentsResponseSchema>;
@@ -0,0 +1,131 @@
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
import { getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import { ZGetAdminTeamRequestSchema, ZGetAdminTeamResponseSchema } from './get-admin-team.types';
export const getAdminTeamRoute = adminProcedure
.input(ZGetAdminTeamRequestSchema)
.output(ZGetAdminTeamResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId } = input;
ctx.logger.info({
input: {
teamId,
},
});
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
include: {
organisation: {
select: {
id: true,
name: true,
url: true,
ownerUserId: true,
},
},
teamEmail: true,
teamGlobalSettings: true,
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
const [teamMembers, pendingInvites] = await Promise.all([
prisma.organisationMember.findMany({
where: {
organisationId: team.organisationId,
organisationGroupMembers: {
some: {
group: {
teamGroups: {
some: {
teamId,
},
},
},
},
},
},
orderBy: {
createdAt: 'desc',
},
select: {
id: true,
userId: true,
createdAt: true,
organisationGroupMembers: {
include: {
group: {
include: {
teamGroups: {
where: {
teamId,
},
},
},
},
},
},
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
}),
// Invites are organisation-scoped in the schema (no team relation), so this is intentionally
// all pending invites for the team's parent organisation.
prisma.organisationMemberInvite.findMany({
where: {
organisationId: team.organisationId,
status: OrganisationMemberInviteStatus.PENDING,
},
orderBy: {
createdAt: 'desc',
},
select: {
id: true,
email: true,
createdAt: true,
organisationRole: true,
status: true,
},
}),
]);
const mappedTeamMembers = teamMembers.map((teamMember) => {
const groups = teamMember.organisationGroupMembers.map(({ group }) => group);
return {
id: teamMember.id,
userId: teamMember.userId,
createdAt: teamMember.createdAt,
user: teamMember.user,
teamRole: getHighestTeamRoleInGroup(groups.flatMap((group) => group.teamGroups)),
organisationRole: getHighestOrganisationRoleInGroup(groups),
};
});
return {
...team,
memberCount: mappedTeamMembers.length,
teamMembers: mappedTeamMembers,
pendingInvites,
};
});
@@ -0,0 +1,52 @@
import { z } from 'zod';
import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema';
import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
import TeamEmailSchema from '@documenso/prisma/generated/zod/modelSchema/TeamEmailSchema';
import TeamGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/TeamGlobalSettingsSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
export const ZGetAdminTeamRequestSchema = z.object({
teamId: z.number().min(1),
});
export const ZGetAdminTeamResponseSchema = TeamSchema.extend({
organisation: OrganisationSchema.pick({
id: true,
name: true,
url: true,
ownerUserId: true,
}),
teamEmail: TeamEmailSchema.nullable(),
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),
memberCount: z.number(),
teamMembers: OrganisationMemberSchema.pick({
id: true,
userId: true,
createdAt: true,
})
.extend({
user: UserSchema.pick({
id: true,
email: true,
name: true,
}),
teamRole: TeamMemberRoleSchema,
organisationRole: OrganisationMemberRoleSchema,
})
.array(),
pendingInvites: OrganisationMemberInviteSchema.pick({
id: true,
email: true,
createdAt: true,
organisationRole: true,
status: true,
}).array(),
});
export type TGetAdminTeamRequest = z.infer<typeof ZGetAdminTeamRequestSchema>;
export type TGetAdminTeamResponse = z.infer<typeof ZGetAdminTeamResponseSchema>;
@@ -3,9 +3,12 @@ import { createAdminOrganisationRoute } from './create-admin-organisation';
import { createStripeCustomerRoute } from './create-stripe-customer';
import { createSubscriptionClaimRoute } from './create-subscription-claim';
import { deleteDocumentRoute } from './delete-document';
import { deleteAdminOrganisationMemberRoute } from './delete-organisation-member';
import { deleteSubscriptionClaimRoute } from './delete-subscription-claim';
import { deleteAdminTeamMemberRoute } from './delete-team-member';
import { deleteUserRoute } from './delete-user';
import { disableUserRoute } from './disable-user';
import { downloadDocumentAuditLogsRoute } from './download-document-audit-logs';
import { enableUserRoute } from './enable-user';
import { findAdminOrganisationsRoute } from './find-admin-organisations';
import { findDocumentAuditLogsRoute } from './find-document-audit-logs';
@@ -13,8 +16,10 @@ import { findDocumentJobsRoute } from './find-document-jobs';
import { findDocumentsRoute } from './find-documents';
import { findEmailDomainsRoute } from './find-email-domains';
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { findUnsealedDocumentsRoute } from './find-unsealed-documents';
import { findUserTeamsRoute } from './find-user-teams';
import { getAdminOrganisationRoute } from './get-admin-organisation';
import { getAdminTeamRoute } from './get-admin-team';
import { getEmailDomainRoute } from './get-email-domain';
import { getUserRoute } from './get-user';
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
@@ -22,6 +27,7 @@ import { reregisterEmailDomainRoute } from './reregister-email-domain';
import { resealDocumentRoute } from './reseal-document';
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import { resyncLicenseRoute } from './resync-license';
import { swapOrganisationSubscriptionRoute } from './swap-organisation-subscription';
import { updateAdminOrganisationRoute } from './update-admin-organisation';
import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role';
import { updateRecipientRoute } from './update-recipient';
@@ -35,10 +41,12 @@ export const adminRouter = router({
get: getAdminOrganisationRoute,
create: createAdminOrganisationRoute,
update: updateAdminOrganisationRoute,
swapSubscription: swapOrganisationSubscriptionRoute,
},
organisationMember: {
promoteToOwner: promoteMemberToOwnerRoute,
updateRole: updateOrganisationMemberRoleRoute,
delete: deleteAdminOrganisationMemberRoute,
},
claims: {
find: findSubscriptionClaimsRoute,
@@ -63,10 +71,12 @@ export const adminRouter = router({
},
document: {
find: findDocumentsRoute,
findUnsealed: findUnsealedDocumentsRoute,
delete: deleteDocumentRoute,
reseal: resealDocumentRoute,
findJobs: findDocumentJobsRoute,
findAuditLogs: findDocumentAuditLogsRoute,
downloadAuditLogs: downloadDocumentAuditLogsRoute,
},
recipient: {
update: updateRecipientRoute,
@@ -76,5 +86,11 @@ export const adminRouter = router({
get: getEmailDomainRoute,
reregister: reregisterEmailDomainRoute,
},
team: {
get: getAdminTeamRoute,
},
teamMember: {
delete: deleteAdminTeamMemberRoute,
},
updateSiteSetting: updateSiteSettingRoute,
});
@@ -0,0 +1,140 @@
import { SubscriptionStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
import { INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZSwapOrganisationSubscriptionRequestSchema,
ZSwapOrganisationSubscriptionResponseSchema,
} from './swap-organisation-subscription.types';
export const swapOrganisationSubscriptionRoute = adminProcedure
.input(ZSwapOrganisationSubscriptionRequestSchema)
.output(ZSwapOrganisationSubscriptionResponseSchema)
.mutation(async ({ input, ctx }) => {
const { sourceOrganisationId, targetOrganisationId } = input;
ctx.logger.info({
input: {
sourceOrganisationId,
targetOrganisationId,
},
});
if (sourceOrganisationId === targetOrganisationId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Source and target organisations must be different',
});
}
const sourceOrg = await prisma.organisation.findUnique({
where: { id: sourceOrganisationId },
include: {
subscription: true,
organisationClaim: true,
},
});
if (!sourceOrg) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Source organisation not found',
});
}
if (
!sourceOrg.subscription ||
(sourceOrg.subscription.status !== SubscriptionStatus.ACTIVE &&
sourceOrg.subscription.status !== SubscriptionStatus.PAST_DUE)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Source organisation does not have an active subscription',
});
}
const targetOrg = await prisma.organisation.findUnique({
where: { id: targetOrganisationId },
include: {
subscription: true,
organisationClaim: true,
},
});
if (!targetOrg) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Target organisation not found',
});
}
if (sourceOrg.ownerUserId !== targetOrg.ownerUserId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Both organisations must be owned by the same user',
});
}
if (
targetOrg.subscription &&
(targetOrg.subscription.status === SubscriptionStatus.ACTIVE ||
targetOrg.subscription.status === SubscriptionStatus.PAST_DUE)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Target organisation already has an active subscription',
});
}
const customerId = sourceOrg.customerId ?? sourceOrg.subscription.customerId;
await prisma.$transaction(async (tx) => {
// Delete stale INACTIVE subscription on target if present.
if (targetOrg.subscription) {
await tx.subscription.delete({
where: { id: targetOrg.subscription.id },
});
}
// Clear customerId on source org to avoid unique constraint violation.
await tx.organisation.update({
where: { id: sourceOrganisationId },
data: { customerId: null },
});
// Set customerId on target org.
await tx.organisation.update({
where: { id: targetOrganisationId },
data: { customerId },
});
// Move the subscription record to the target org.
await tx.subscription.update({
where: { id: sourceOrg.subscription!.id },
data: { organisationId: targetOrganisationId },
});
// Copy source org's claim entitlements to target org's claim.
if (sourceOrg.organisationClaim && targetOrg.organisationClaim) {
await tx.organisationClaim.update({
where: { id: targetOrg.organisationClaim.id },
data: {
originalSubscriptionClaimId: sourceOrg.organisationClaim.originalSubscriptionClaimId,
teamCount: sourceOrg.organisationClaim.teamCount,
memberCount: sourceOrg.organisationClaim.memberCount,
envelopeItemCount: sourceOrg.organisationClaim.envelopeItemCount,
flags: sourceOrg.organisationClaim.flags,
},
});
}
// Reset source org's claim to FREE.
if (sourceOrg.organisationClaim) {
await tx.organisationClaim.update({
where: { id: sourceOrg.organisationClaim.id },
data: {
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
...createOrganisationClaimUpsertData(internalClaims[INTERNAL_CLAIM_ID.FREE]),
},
});
}
});
});
@@ -0,0 +1,15 @@
import { z } from 'zod';
export const ZSwapOrganisationSubscriptionRequestSchema = z.object({
sourceOrganisationId: z.string(),
targetOrganisationId: z.string(),
});
export const ZSwapOrganisationSubscriptionResponseSchema = z.void();
export type TSwapOrganisationSubscriptionRequest = z.infer<
typeof ZSwapOrganisationSubscriptionRequestSchema
>;
export type TSwapOrganisationSubscriptionResponse = z.infer<
typeof ZSwapOrganisationSubscriptionResponseSchema
>;
@@ -10,7 +10,7 @@ export const updateRecipientRoute = adminProcedure
.input(ZUpdateRecipientRequestSchema)
.output(ZUpdateRecipientResponseSchema)
.mutation(async ({ input, ctx }) => {
const { id, name, email } = input;
const { id, name, email, role } = input;
ctx.logger.info({
input: {
@@ -18,5 +18,5 @@ export const updateRecipientRoute = adminProcedure
},
});
await updateRecipient({ id, name, email });
await updateRecipient({ id, name, email, role });
});
@@ -1,9 +1,12 @@
import { z } from 'zod';
import { zEmail } from '@documenso/lib/utils/zod';
export const ZUpdateRecipientRequestSchema = z.object({
id: z.number().min(1),
name: z.string().optional(),
email: z.string().email().optional(),
email: zEmail().optional(),
role: z.enum(['CC', 'SIGNER', 'VIEWER', 'APPROVER', 'ASSISTANT']).optional(),
});
export const ZUpdateRecipientResponseSchema = z.void();
@@ -29,24 +29,22 @@ export const updateSubscriptionClaimRoute = adminProcedure
const newlyEnabledFlags = getNewTruthyFlags(existingClaim.flags, data.flags);
await prisma.$transaction(async (tx) => {
await tx.subscriptionClaim.update({
where: {
id,
},
data,
});
if (Object.keys(newlyEnabledFlags).length > 0) {
await jobsClient.triggerJob({
name: 'internal.backport-subscription-claims',
payload: {
subscriptionClaimId: id,
flags: newlyEnabledFlags,
},
});
}
await prisma.subscriptionClaim.update({
where: {
id,
},
data,
});
if (Object.keys(newlyEnabledFlags).length > 0) {
await jobsClient.triggerJob({
name: 'internal.backport-subscription-claims',
payload: {
subscriptionClaimId: id,
flags: newlyEnabledFlags,
},
});
}
});
function getNewTruthyFlags(
@@ -1,10 +1,12 @@
import { Role } from '@prisma/client';
import { z } from 'zod';
import { zEmail } from '@documenso/lib/utils/zod';
export const ZUpdateUserRequestSchema = z.object({
id: z.number().min(1),
name: z.string().nullish(),
email: z.string().email().optional(),
email: zEmail().optional(),
roles: z.array(z.nativeEnum(Role)).optional(),
});
@@ -18,7 +18,7 @@ import {
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { zodFormData } from '../../utils/zod-form-data';
import { zfdFile, zodFormData } from '../../utils/zod-form-data';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc';
import { ZDocumentExternalIdSchema, ZDocumentTitleSchema } from './schema';
@@ -79,7 +79,7 @@ export const ZCreateDocumentPayloadSchema = z.object({
export const ZCreateDocumentRequestSchema = zodFormData({
payload: zfd.json(ZCreateDocumentPayloadSchema),
file: zfd.file(),
file: zfdFile(),
});
export const ZCreateDocumentResponseSchema = z.object({
@@ -11,6 +11,7 @@ import {
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { zEmail } from '@documenso/lib/utils/zod';
import type { TrpcRouteMeta } from '../trpc';
@@ -36,7 +37,7 @@ export const ZDistributeDocumentRequestSchema = z.object({
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
emailReplyTo: zEmail().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
@@ -1,7 +1,5 @@
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { mapEnvelopesToDocumentMany } from '@documenso/lib/utils/document';
import { authenticatedProcedure } from '../trpc';
@@ -30,28 +28,15 @@ export const findDocumentsInternalRoute = authenticatedProcedure
folderId,
} = input;
const getStatOptions: GetStatsInput = {
user,
period,
search: query,
folderId,
};
if (teamId) {
const team = await getTeamById({ userId: user.id, teamId });
getStatOptions.team = {
teamId: team.id,
teamEmail: team.teamEmail?.email,
senderIds,
currentTeamMemberRole: team.currentTeamRole,
currentUserEmail: user.email,
userId: user.id,
};
}
const [stats, documents] = await Promise.all([
getStats(getStatOptions),
getStats({
userId: user.id,
teamId,
period,
search: query,
folderId,
senderIds,
}),
findDocuments({
userId: user.id,
teamId,
@@ -38,6 +38,7 @@ export const findDocumentsRoute = authenticatedProcedure
perPage,
folderId,
orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined,
useWindowedCount: false,
});
return {
@@ -1,11 +1,13 @@
import { DocumentVisibility } from '@prisma/client';
import { z } from 'zod';
export const DOCUMENT_TITLE_MAX_LENGTH = 255;
export const ZDocumentTitleSchema = z
.string()
.trim()
.min(1)
.max(255)
.max(DOCUMENT_TITLE_MAX_LENGTH)
.describe('The title of the document.');
export const ZDocumentExternalIdSchema = z
@@ -1,7 +1,7 @@
import { z } from 'zod';
export const ZSearchDocumentRequestSchema = z.object({
query: z.string(),
query: z.string().trim().min(1).max(1024),
});
export const ZSearchDocumentResponseSchema = z
@@ -5,14 +5,14 @@ import { authenticatedProcedure } from '../trpc';
import {
ZUpdateDocumentRequestSchema,
ZUpdateDocumentResponseSchema,
updateDocumentMeta,
} from './update-document.types';
import { updateDocumentMeta as updateDocumentTrpcMeta } from './update-document.types';
/**
* Public route.
*/
export const updateDocumentRoute = authenticatedProcedure
.meta(updateDocumentTrpcMeta)
.meta(updateDocumentMeta)
.input(ZUpdateDocumentRequestSchema)
.output(ZUpdateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
@@ -1,17 +1,21 @@
import { router } from '../trpc';
import { createEmbeddingDocumentRoute } from './create-embedding-document';
import { createEmbeddingEnvelopeRoute } from './create-embedding-envelope';
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
import { createEmbeddingTemplateRoute } from './create-embedding-template';
import { getMultiSignDocumentRoute } from './get-multi-sign-document';
import { updateEmbeddingDocumentRoute } from './update-embedding-document';
import { updateEmbeddingEnvelopeRoute } from './update-embedding-envelope';
import { updateEmbeddingTemplateRoute } from './update-embedding-template';
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
export const embeddingPresignRouter = router({
createEmbeddingPresignToken: createEmbeddingPresignTokenRoute,
verifyEmbeddingPresignToken: verifyEmbeddingPresignTokenRoute,
createEmbeddingEnvelope: createEmbeddingEnvelopeRoute,
createEmbeddingDocument: createEmbeddingDocumentRoute,
createEmbeddingTemplate: createEmbeddingTemplateRoute,
updateEmbeddingEnvelope: updateEmbeddingEnvelopeRoute,
updateEmbeddingDocument: updateEmbeddingDocumentRoute,
updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
// applyMultiSignSignature: applyMultiSignSignatureRoute,
@@ -21,6 +21,7 @@ import {
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { zEmail } from '@documenso/lib/utils/zod';
import { RecipientRole } from '@documenso/prisma/client';
import { DocumentSigningOrder } from '@documenso/prisma/generated/types';
@@ -33,7 +34,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
recipients: z.array(
z.object({
id: z.number().optional(),
email: z.string().email(),
email: zEmail(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -0,0 +1,41 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { createEnvelopeRouteCaller } from '../envelope-router/create-envelope';
import { procedure } from '../trpc';
import {
ZCreateEmbeddingEnvelopeRequestSchema,
ZCreateEmbeddingEnvelopeResponseSchema,
} from './create-embedding-envelope.types';
export const createEmbeddingEnvelopeRoute = procedure
.input(ZCreateEmbeddingEnvelopeRequestSchema)
.output(ZCreateEmbeddingEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {
const { req } = ctx;
const authorizationHeader = req.headers.get('authorization');
const [presignToken] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
if (!presignToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'No presign token provided',
});
}
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
const { userId, teamId } = apiToken;
return await createEnvelopeRouteCaller({
userId,
teamId,
input,
options: {
// Default recipients should be added on the frontend automatically for embeds.
bypassDefaultRecipients: true,
},
apiRequestMetadata: ctx.metadata,
});
});
@@ -0,0 +1,8 @@
import {
ZCreateEnvelopeRequestSchema,
ZCreateEnvelopeResponseSchema,
} from '../envelope-router/create-envelope.types';
export const ZCreateEmbeddingEnvelopeRequestSchema = ZCreateEnvelopeRequestSchema;
export const ZCreateEmbeddingEnvelopeResponseSchema = ZCreateEnvelopeResponseSchema;
@@ -24,7 +24,9 @@ export const ZCreateEmbeddingPresignTokenRequestSchema = z.object({
scope: z
.string()
.optional()
.describe('Resource restriction. Example: documentId:1, templateId:2'),
.describe(
'Resource restriction. V1 embeds only support documentId:1, templateId:2. V2 embeds only support envelopeId:envelope_123',
),
});
export const ZCreateEmbeddingPresignTokenResponseSchema = z.object({
@@ -21,6 +21,7 @@ import {
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { zEmail } from '@documenso/lib/utils/zod';
import { DocumentSigningOrder, RecipientRole } from '@documenso/prisma/generated/types';
import { ZDocumentExternalIdSchema, ZDocumentTitleSchema } from '../document-router/schema';
@@ -32,7 +33,7 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
recipients: z.array(
z.object({
id: z.number().optional(),
email: z.string().email(),
email: zEmail(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -0,0 +1,517 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import pMap from 'p-map';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { UNSAFE_createEnvelopeItems } from '@documenso/lib/server-only/envelope-item/create-envelope-items';
import { UNSAFE_deleteEnvelopeItem } from '@documenso/lib/server-only/envelope-item/delete-envelope-item';
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
import { UNSAFE_updateEnvelopeItems } from '@documenso/lib/server-only/envelope-item/update-envelope-items';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients';
import { nanoid } from '@documenso/lib/universal/id';
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { procedure } from '../trpc';
import {
ZUpdateEmbeddingEnvelopeRequestSchema,
ZUpdateEmbeddingEnvelopeResponseSchema,
} from './update-embedding-envelope.types';
export const updateEmbeddingEnvelopeRoute = procedure
.input(ZUpdateEmbeddingEnvelopeRequestSchema)
.output(ZUpdateEmbeddingEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {
const { payload, files } = input;
const { envelopeId, data, meta } = payload;
ctx.logger.info({
input: {
envelopeId,
},
});
const authorizationHeader = ctx.req.headers.get('authorization');
const [presignToken] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
if (!presignToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'No presign token provided',
});
}
const apiToken = await verifyEmbeddingPresignToken({
token: presignToken,
scope: `envelopeId:${envelopeId}`,
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelopeId,
},
type: null, // Allow updating both documents and templates.
userId: apiToken.userId,
teamId: apiToken.teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
envelopeItems: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
recipients: true,
envelopeAttachments: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.status === DocumentStatus.COMPLETED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify completed envelope',
});
}
// Step 1: Update the envelope items.
const envelopeItemsToUpdate: EnvelopeItemUpdateOptions[] = [];
const envelopeItemsToCreate: EnvelopeItemCreateOptions[] = [];
const envelopeItemsToReplace: EnvelopeItemReplaceOptions[] = [];
// Sort and group envelope items to update, create, and replace.
data.envelopeItems.forEach((item) => {
const isNewEnvelopeItem = item.id.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX);
// Handle existing envelope items.
if (!isNewEnvelopeItem) {
const envelopeItem = envelope.envelopeItems.find(
(envelopeItem) => envelopeItem.id === item.id,
);
if (!envelopeItem) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope item not found',
});
}
// Check if this existing item has a replacement file.
if (item.replaceFileIndex !== undefined) {
const replaceFile = files[item.replaceFileIndex];
if (!replaceFile) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid replace file index',
});
}
envelopeItemsToReplace.push({
envelopeItemId: envelopeItem.id,
oldDocumentDataId: envelopeItem.documentDataId,
title: item.title,
order: item.order,
file: replaceFile,
});
return;
}
const hasEnvelopeItemChanged =
envelopeItem.title !== item.title || envelopeItem.order !== item.order;
if (hasEnvelopeItemChanged) {
envelopeItemsToUpdate.push({
envelopeItemId: envelopeItem.id,
title: item.title,
order: item.order,
});
}
// Return to continue loop.
return;
}
const newEnvelopeItemFile = item.index !== undefined ? files[item.index] : undefined;
if (!newEnvelopeItemFile) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid envelope item index',
});
}
// Handle not yet uploaded envelope items.
envelopeItemsToCreate.push({
embeddedEnvelopeItemId: item.id,
title: item.title,
order: item.order,
file: newEnvelopeItemFile,
});
});
// Delete envelope items that have been removed from the payload.
const envelopeItemIdsToDelete = envelope.envelopeItems
.filter((item) => !data.envelopeItems.some((i) => i.id === item.id))
.map((item) => item.id);
const willEnvelopeItemsBeModified =
envelopeItemIdsToDelete.length > 0 ||
envelopeItemsToCreate.length > 0 ||
envelopeItemsToUpdate.length > 0 ||
envelopeItemsToReplace.length > 0;
const organisationClaim = envelope.team.organisation.organisationClaim;
const resultingEnvelopeItemCount =
envelope.envelopeItems.length - envelopeItemIdsToDelete.length + envelopeItemsToCreate.length;
if (resultingEnvelopeItemCount > organisationClaim.envelopeItemCount) {
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
message: `You cannot upload more than ${organisationClaim.envelopeItemCount} envelope items`,
statusCode: 400,
});
}
// Should be safe to use stale envelope.recipients since only signed or sent
// recipients affect the outcome.
if (willEnvelopeItemsBeModified) {
const permissions = getEnvelopeItemPermissions(envelope, envelope.recipients);
const hasFileChange = envelopeItemIdsToDelete.length > 0 || envelopeItemsToCreate.length > 0;
const hasOrderChange = envelopeItemsToUpdate.some((item) => {
const existing = envelope.envelopeItems.find((e) => e.id === item.envelopeItemId);
return !existing || item.order !== existing.order;
});
const hasTitleChange = envelopeItemsToUpdate.some((item) => item.title !== undefined);
if (hasFileChange && !permissions.canFileBeChanged) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item files are not editable',
});
}
if (hasOrderChange && !permissions.canOrderBeChanged) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item order is not editable',
});
}
if (hasTitleChange && !permissions.canTitleBeChanged) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item title is not editable',
});
}
}
if (envelopeItemIdsToDelete.length > 0) {
await pMap(
envelopeItemIdsToDelete,
async (envelopeItemId) => {
await UNSAFE_deleteEnvelopeItem({
envelopeId: envelope.id,
envelopeItemId,
user: apiToken.user,
apiRequestMetadata: ctx.metadata,
});
},
{ concurrency: 2 },
);
}
// Mapping for the client side embedded prefix envelope item IDs to the real envelope item IDs.
const embeddedEnvelopeItemIdMapping: Record<string, string> = {};
// Create new envelope items.
if (envelopeItemsToCreate.length > 0) {
const createdEnvelopeItems = await UNSAFE_createEnvelopeItems({
files: envelopeItemsToCreate.map((item) => ({
clientId: item.embeddedEnvelopeItemId,
file: item.file,
orderOverride: item.order,
})),
envelope: {
...envelope,
// Purposefully putting empty recipients here since placeholders should automatically injected on the client side for
// embedded purposes. Todo: Embeds - (Not implemeneted yet)
recipients: [],
},
user: {
id: apiToken.user.id,
name: apiToken.user.name,
email: apiToken.user.email,
},
apiRequestMetadata: ctx.metadata,
});
// Build the map from the envelope item order.
createdEnvelopeItems.forEach((item) => {
if (!item.clientId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Client ID not found',
});
}
embeddedEnvelopeItemIdMapping[item.clientId] = item.id;
});
}
if (envelopeItemsToUpdate.length > 0) {
await UNSAFE_updateEnvelopeItems({
envelopeId: envelope.id,
envelopeType: envelope.type,
existingEnvelopeItems: envelope.envelopeItems,
data: envelopeItemsToUpdate,
user: {
name: apiToken.user.name,
email: apiToken.user.email,
},
apiRequestMetadata: ctx.metadata,
});
}
// Replace PDFs for existing envelope items without creating placeholder fields
// field cleanup is handled in later steps.
if (envelopeItemsToReplace.length > 0) {
await pMap(
envelopeItemsToReplace,
async (item) => {
await UNSAFE_replaceEnvelopeItemPdf({
envelope,
recipients: [],
envelopeItemId: item.envelopeItemId,
oldDocumentDataId: item.oldDocumentDataId,
data: {
title: item.title,
order: item.order,
file: item.file,
},
user: apiToken.user,
apiRequestMetadata: ctx.metadata,
});
},
{ concurrency: 2 },
);
}
// Step 2: Update the general envelope data and meta.
await updateEnvelope({
userId: apiToken.userId,
teamId: apiToken.teamId,
id: {
type: 'envelopeId',
id: envelope.id,
},
data: {
title: data.title,
externalId: data.externalId,
visibility: data.visibility,
globalAccessAuth: data.globalAccessAuth,
globalActionAuth: data.globalActionAuth,
folderId: data.folderId,
},
meta,
requestMetadata: ctx.metadata,
});
// Step 3: Update the recipients
const recipientsWithClientId = data.recipients.map((recipient) => ({
...recipient,
clientId: nanoid(),
}));
const { recipients: updatedRecipients } = await match(envelope.type)
.with(EnvelopeType.DOCUMENT, async () =>
setDocumentRecipients({
userId: apiToken.userId,
teamId: apiToken.teamId,
id: {
type: 'envelopeId',
id: envelope.id,
},
recipients: recipientsWithClientId.map((recipient) => ({
id: recipient.id,
clientId: recipient.clientId,
email: recipient.email,
name: recipient.name ?? '',
role: recipient.role,
signingOrder: recipient.signingOrder,
actionAuth: recipient.actionAuth,
})),
requestMetadata: ctx.metadata,
}),
)
.with(EnvelopeType.TEMPLATE, async () =>
setTemplateRecipients({
userId: apiToken.userId,
teamId: apiToken.teamId,
id: {
type: 'envelopeId',
id: envelope.id,
},
recipients: recipientsWithClientId.map((recipient) => ({
id: recipient.id,
clientId: recipient.clientId,
email: recipient.email,
name: recipient.name ?? '',
role: recipient.role,
signingOrder: recipient.signingOrder,
actionAuth: recipient.actionAuth,
})),
}),
)
.exhaustive();
// Step 4: Update the fields.
const fields = recipientsWithClientId.flatMap((recipient) => {
const recipientId = updatedRecipients.find((r) => r.clientId === recipient.clientId)?.id;
if (!recipientId) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Recipient not found',
});
}
return (recipient.fields ?? []).map((field) => {
let envelopeItemId = field.envelopeItemId;
if (envelopeItemId.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX)) {
envelopeItemId = embeddedEnvelopeItemIdMapping[envelopeItemId];
}
if (!envelopeItemId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope item not found',
});
}
return {
...field,
recipientId,
envelopeItemId,
};
});
});
await match(envelope.type)
.with(EnvelopeType.DOCUMENT, async () =>
setFieldsForDocument({
userId: apiToken.userId,
teamId: apiToken.teamId,
id: {
type: 'envelopeId',
id: envelopeId,
},
fields: fields.map((field) => ({
...field,
pageNumber: field.page,
pageX: field.positionX,
pageY: field.positionY,
pageWidth: field.width,
pageHeight: field.height,
})),
requestMetadata: ctx.metadata,
}),
)
.with(EnvelopeType.TEMPLATE, async () =>
setFieldsForTemplate({
userId: apiToken.userId,
teamId: apiToken.teamId,
id: {
type: 'envelopeId',
id: envelopeId,
},
fields: fields.map((field) => ({
...field,
pageNumber: field.page,
pageX: field.positionX,
pageY: field.positionY,
pageWidth: field.width,
pageHeight: field.height,
})),
}),
)
.exhaustive();
// Step 5: Handle attachments (set semantics: delete all existing, create new).
let hasEnvelopeAttachmentsChanged =
envelope.envelopeAttachments.length !== data.attachments.length;
data.attachments.forEach((attachment) => {
const foundAttachment = envelope.envelopeAttachments.find((a) => a.id === attachment.id);
if (!foundAttachment) {
hasEnvelopeAttachmentsChanged = true;
return;
}
const hasAttachmentChanged =
foundAttachment.label !== attachment.label ||
foundAttachment.data !== attachment.data ||
foundAttachment.type !== attachment.type;
if (hasAttachmentChanged) {
hasEnvelopeAttachmentsChanged = true;
return;
}
});
if (hasEnvelopeAttachmentsChanged) {
await prisma.envelopeAttachment.deleteMany({
where: {
envelopeId: envelope.id,
},
});
if (data.attachments.length > 0) {
await prisma.envelopeAttachment.createMany({
data: data.attachments.map((attachment) => ({
envelopeId: envelope.id,
label: attachment.label,
data: attachment.data,
type: attachment.type,
})),
});
}
}
});
type EnvelopeItemUpdateOptions = {
envelopeItemId: string;
title?: string;
order?: number;
};
type EnvelopeItemCreateOptions = {
embeddedEnvelopeItemId: string;
title: string;
order: number;
file: File;
};
type EnvelopeItemReplaceOptions = {
envelopeItemId: string;
oldDocumentDataId: string;
title: string;
order: number;
file: File;
};
@@ -0,0 +1,121 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentMetaUpdateSchema } from '@documenso/lib/types/document-meta';
import {
ZClampedFieldHeightSchema,
ZClampedFieldPositionXSchema,
ZClampedFieldPositionYSchema,
ZClampedFieldWidthSchema,
ZFieldPageNumberSchema,
} from '@documenso/lib/types/field';
import { ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { EnvelopeAttachmentSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeAttachmentSchema';
import { ZSetEnvelopeRecipientSchema } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
import { zfdFile, zodFormData } from '../../utils/zod-form-data';
import {
ZDocumentExternalIdSchema,
ZDocumentTitleSchema,
ZDocumentVisibilitySchema,
} from '../document-router/schema';
export const ZUpdateEmbeddingEnvelopePayloadSchema = z.object({
envelopeId: z.string(),
data: z.object({
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
folderId: z.string().nullish(),
/**
* The list of envelope items that are part of the envelope.
*
* Any missing IDs will be treated as deleting the envelope item.
*/
envelopeItems: z
.object({
/**
* This is not necesssarily a real id, it can be a temporary id for the envelope item.
*/
id: z.string(),
/**
* The title of the envelope item.
*/
title: z.string(),
/**
* The order of the envelope item in the envelope.
*/
order: z.number().int().min(0),
/**
* The file index for items that are not yet uploaded.
*/
index: z.number().int().min(0).optional(),
/**
* The file index for existing items that need their PDF replaced.
* Only applicable to items with real IDs (not PRESIGNED_ prefix).
*/
replaceFileIndex: z.number().int().min(0).optional(),
})
.refine((item) => !(item.index !== undefined && item.replaceFileIndex !== undefined), {
message: 'Cannot provide both index and replaceFileIndex on the same envelope item',
path: ['replaceFileIndex'],
})
.array(),
/**
* This is a set command.
*/
recipients: ZSetEnvelopeRecipientSchema.extend({
fields: ZEnvelopeFieldAndMetaSchema.and(
z.object({
id: z.number().optional(),
page: ZFieldPageNumberSchema,
positionX: ZClampedFieldPositionXSchema,
positionY: ZClampedFieldPositionYSchema,
width: ZClampedFieldWidthSchema,
height: ZClampedFieldHeightSchema,
envelopeItemId: z.string(),
}),
).array(),
}).array(),
/**
* The list of attachments for the envelope.
*
* This is a set command: when provided, all existing attachments are deleted
* and replaced with the provided list.
*/
attachments: EnvelopeAttachmentSchema.pick({
type: true,
label: true,
data: true,
})
.extend({
id: z.string().optional(),
})
.array(),
}),
meta: ZDocumentMetaUpdateSchema.optional(),
});
export const ZUpdateEmbeddingEnvelopeRequestSchema = zodFormData({
payload: zfd.json(ZUpdateEmbeddingEnvelopePayloadSchema),
files: zfd.repeatableOfType(zfdFile()),
});
export const ZUpdateEmbeddingEnvelopeResponseSchema = z.void();
export type TUpdateEmbeddingEnvelopePayload = z.infer<typeof ZUpdateEmbeddingEnvelopePayloadSchema>;
export type TUpdateEmbeddingEnvelopeRequest = z.infer<typeof ZUpdateEmbeddingEnvelopeRequestSchema>;
@@ -1,9 +1,11 @@
import { z } from 'zod';
import { zEmail } from '@documenso/lib/utils/zod';
export const ZCreateOrganisationEmailRequestSchema = z.object({
emailDomainId: z.string(),
emailName: z.string().min(1).max(100),
email: z.string().email().toLowerCase(),
email: zEmail().toLowerCase(),
// This does not need to be validated to be part of the domain.
// replyTo: z.string().email().optional(),
@@ -1,17 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { UNSAFE_createEnvelopeItems } from '@documenso/lib/server-only/envelope-item/create-envelope-items';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import {
convertPlaceholdersToFieldInputs,
extractPdfPlaceholders,
} from '@documenso/lib/server-only/pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers';
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { prefixedId } from '@documenso/lib/universal/id';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
@@ -73,7 +63,9 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
});
}
if (!canEnvelopeItemsBeModified(envelope, envelope.recipients)) {
const { canFileBeChanged } = getEnvelopeItemPermissions(envelope, envelope.recipients);
if (!canFileBeChanged) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item is not editable',
});
@@ -91,130 +83,17 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
});
}
// For each file: normalize, extract & clean placeholders, then upload.
const envelopeItems = await Promise.all(
files.map(async (file) => {
let buffer = Buffer.from(await file.arrayBuffer());
if (envelope.formValues) {
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
const normalized = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { id: documentDataId } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
return {
title: file.name,
documentDataId,
placeholders,
};
}),
);
const currentHighestOrderValue =
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
const result = await prisma.$transaction(async (tx) => {
const createdItems = await tx.envelopeItem.createManyAndReturn({
data: envelopeItems.map((item) => ({
id: prefixedId('envelope_item'),
envelopeId,
title: item.title,
documentDataId: item.documentDataId,
order: currentHighestOrderValue + 1,
})),
include: {
documentData: true,
},
});
await tx.documentAuditLog.createMany({
data: createdItems.map((item) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED,
envelopeId: envelope.id,
data: {
envelopeItemId: item.id,
envelopeItemTitle: item.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: metadata.requestMetadata,
}),
),
});
// Create fields from placeholders if the envelope already has recipients.
if (envelope.recipients.length > 0) {
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
});
for (const uploadedItem of envelopeItems) {
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
continue;
}
const createdItem = createdItems.find(
(ci) => ci.documentDataId === uploadedItem.documentDataId,
);
if (!createdItem) {
continue;
}
const fieldsToCreate = convertPlaceholdersToFieldInputs(
uploadedItem.placeholders,
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(
recipientPlaceholder,
placeholder,
orderedRecipients,
orderedRecipients,
),
createdItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: createdItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
}
}
}
return createdItems;
const result = await UNSAFE_createEnvelopeItems({
files: files.map((file) => ({
file,
})),
envelope,
user: {
id: user.id,
name: user.name,
email: user.email,
},
apiRequestMetadata: metadata,
});
return {
@@ -3,7 +3,7 @@ import { zfd } from 'zod-form-data';
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
import { zodFormData } from '../../utils/zod-form-data';
import { zfdFile, zodFormData } from '../../utils/zod-form-data';
import type { TrpcRouteMeta } from '../trpc';
export const createEnvelopeItemsMeta: TrpcRouteMeta = {
@@ -24,7 +24,7 @@ export const ZCreateEnvelopeItemsPayloadSchema = z.object({
export const ZCreateEnvelopeItemsRequestSchema = zodFormData({
payload: zfd.json(ZCreateEnvelopeItemsPayloadSchema),
files: zfd.repeatableOfType(zfd.file()),
files: zfd.repeatableOfType(zfdFile()),
});
export const ZCreateEnvelopeItemsResponseSchema = z.object({
@@ -33,6 +33,7 @@ export const ZCreateEnvelopeItemsResponseSchema = z.object({
title: true,
envelopeId: true,
order: true,
documentDataId: true,
}).array(),
});
@@ -5,10 +5,12 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
import { authenticatedProcedure } from '../trpc';
import type { TCreateEnvelopeRequest } from './create-envelope.types';
import {
ZCreateEnvelopeRequestSchema,
ZCreateEnvelopeResponseSchema,
@@ -20,150 +22,183 @@ export const createEnvelopeRoute = authenticatedProcedure
.input(ZCreateEnvelopeRequestSchema)
.output(ZCreateEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx;
ctx.logger.info({
input: {
folderId: input.payload.folderId,
},
});
const { payload, files } = input;
return await createEnvelopeRouteCaller({
userId: ctx.user.id,
teamId: ctx.teamId,
input,
apiRequestMetadata: ctx.metadata,
});
});
const {
title,
type CreateEnvelopeRouteOptions = {
/**
* Verified user ID.
*/
userId: number;
/**
* Unverified team ID.
*/
teamId: number;
input: TCreateEnvelopeRequest;
apiRequestMetadata: ApiRequestMetadata;
options?: {
bypassDefaultRecipients?: boolean;
};
};
export const createEnvelopeRouteCaller = async ({
userId,
teamId,
input,
apiRequestMetadata,
options = {},
}: CreateEnvelopeRouteOptions) => {
const { payload, files } = input;
const {
title,
type,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
formValues,
recipients,
folderId,
meta,
attachments,
delegatedDocumentOwner,
} = payload;
const { remaining, maximumEnvelopeItemCount } = await getServerLimits({
userId,
teamId,
});
if (remaining.documents <= 0) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'You have reached your document limit for this month. Please upgrade your plan.',
statusCode: 400,
});
}
if (files.length > maximumEnvelopeItemCount) {
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
message: `You cannot upload more than ${maximumEnvelopeItemCount} envelope items per envelope`,
statusCode: 400,
});
}
if (files.some((file) => !file.type.startsWith('application/pdf'))) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'You cannot upload non-PDF files',
statusCode: 400,
});
}
// For each file: normalize, extract & clean placeholders, then upload.
const envelopeItems = await Promise.all(
files.map(async (file) => {
let pdf = Buffer.from(await file.arrayBuffer());
if (formValues) {
// eslint-disable-next-line require-atomic-updates
pdf = await insertFormValuesInPdf({
pdf,
formValues,
});
}
const normalized = await normalizePdf(pdf, {
flattenForm: type !== EnvelopeType.TEMPLATE,
});
// Todo: Embeds - Might need to add this for client-side embeds in the future.
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { documentData } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
return {
title: file.name,
documentDataId: documentData.id,
placeholders,
};
}),
);
const recipientsToCreate = recipients?.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
accessAuth: recipient.accessAuth,
actionAuth: recipient.actionAuth,
fields: recipient.fields?.map((field) => {
let documentDataId: string | undefined = undefined;
if (typeof field.identifier === 'string') {
documentDataId = envelopeItems.find(
(item) => item.title === field.identifier,
)?.documentDataId;
}
if (typeof field.identifier === 'number') {
documentDataId = envelopeItems.at(field.identifier)?.documentDataId;
}
if (field.identifier === undefined) {
documentDataId = envelopeItems.at(0)?.documentDataId;
}
if (!documentDataId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document data not found',
});
}
return {
...field,
documentDataId,
};
}),
}));
const envelope = await createEnvelope({
userId,
teamId,
internalVersion: 2,
data: {
type,
title,
externalId,
formValues,
visibility,
globalAccessAuth,
globalActionAuth,
formValues,
recipients,
recipients: recipientsToCreate,
folderId,
meta,
attachments,
envelopeItems,
delegatedDocumentOwner,
} = payload;
ctx.logger.info({
input: {
folderId,
},
});
const { remaining, maximumEnvelopeItemCount } = await getServerLimits({
userId: user.id,
teamId,
});
if (remaining.documents <= 0) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'You have reached your document limit for this month. Please upgrade your plan.',
statusCode: 400,
});
}
if (files.length > maximumEnvelopeItemCount) {
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
message: `You cannot upload more than ${maximumEnvelopeItemCount} envelope items per envelope`,
statusCode: 400,
});
}
if (files.some((file) => !file.type.startsWith('application/pdf'))) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'You cannot upload non-PDF files',
statusCode: 400,
});
}
// For each file: normalize, extract & clean placeholders, then upload.
const envelopeItems = await Promise.all(
files.map(async (file) => {
let pdf = Buffer.from(await file.arrayBuffer());
if (formValues) {
// eslint-disable-next-line require-atomic-updates
pdf = await insertFormValuesInPdf({
pdf,
formValues,
});
}
const normalized = await normalizePdf(pdf, {
flattenForm: type !== EnvelopeType.TEMPLATE,
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { id: documentDataId } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
return {
title: file.name,
documentDataId,
placeholders,
};
}),
);
const recipientsToCreate = recipients?.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
accessAuth: recipient.accessAuth,
actionAuth: recipient.actionAuth,
fields: recipient.fields?.map((field) => {
let documentDataId: string | undefined = undefined;
if (typeof field.identifier === 'string') {
documentDataId = envelopeItems.find(
(item) => item.title === field.identifier,
)?.documentDataId;
}
if (typeof field.identifier === 'number') {
documentDataId = envelopeItems.at(field.identifier)?.documentDataId;
}
if (field.identifier === undefined) {
documentDataId = envelopeItems.at(0)?.documentDataId;
}
if (!documentDataId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document data not found',
});
}
return {
...field,
documentDataId,
};
}),
}));
const envelope = await createEnvelope({
userId: user.id,
teamId,
internalVersion: 2,
data: {
type,
title,
externalId,
formValues,
visibility,
globalAccessAuth,
globalActionAuth,
recipients: recipientsToCreate,
folderId,
envelopeItems,
delegatedDocumentOwner,
},
attachments,
meta,
requestMetadata: ctx.metadata,
});
return {
id: envelope.id,
};
},
attachments,
meta,
requestMetadata: apiRequestMetadata,
bypassDefaultRecipients: options.bypassDefaultRecipients,
});
return {
id: envelope.id,
};
};
@@ -17,8 +17,9 @@ import {
ZFieldPageNumberSchema,
} from '@documenso/lib/types/field';
import { ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { zEmail } from '@documenso/lib/utils/zod';
import { zodFormData } from '../../utils/zod-form-data';
import { zfdFile, zodFormData } from '../../utils/zod-form-data';
import {
ZDocumentExternalIdSchema,
ZDocumentTitleSchema,
@@ -41,9 +42,7 @@ export const createEnvelopeMeta: TrpcRouteMeta = {
export const ZCreateEnvelopePayloadSchema = z.object({
title: ZDocumentTitleSchema,
type: z.nativeEnum(EnvelopeType),
delegatedDocumentOwner: z
.string()
.email()
delegatedDocumentOwner: zEmail()
.describe('The email of the user who will own the document.')
.optional(),
externalId: ZDocumentExternalIdSchema.optional(),
@@ -94,7 +93,7 @@ export const ZCreateEnvelopePayloadSchema = z.object({
export const ZCreateEnvelopeRequestSchema = zodFormData({
payload: zfd.json(ZCreateEnvelopePayloadSchema),
files: zfd.repeatableOfType(zfd.file()),
files: zfd.repeatableOfType(zfdFile()),
});
export const ZCreateEnvelopeResponseSchema = z.object({
@@ -1,8 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { UNSAFE_deleteEnvelopeItem } from '@documenso/lib/server-only/envelope-item/delete-envelope-item';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { ZGenericSuccessResponse } from '../schema';
@@ -51,55 +50,19 @@ export const deleteEnvelopeItemRoute = authenticatedProcedure
});
}
if (!canEnvelopeItemsBeModified(envelope, envelope.recipients)) {
const { canFileBeChanged } = getEnvelopeItemPermissions(envelope, envelope.recipients);
if (!canFileBeChanged) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item is not editable',
});
}
const result = await prisma.$transaction(async (tx) => {
const deletedEnvelopeItem = await tx.envelopeItem.delete({
where: {
id: envelopeItemId,
envelopeId: envelope.id,
},
select: {
id: true,
title: true,
documentData: {
select: {
id: true,
},
},
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED,
envelopeId: envelope.id,
data: {
envelopeItemId: deletedEnvelopeItem.id,
envelopeItemTitle: deletedEnvelopeItem.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: metadata.requestMetadata,
}),
});
return deletedEnvelopeItem;
});
await prisma.documentData.delete({
where: {
id: result.documentData.id,
envelopeItem: {
is: null,
},
},
await UNSAFE_deleteEnvelopeItem({
envelopeId,
envelopeItemId,
user,
apiRequestMetadata: metadata,
});
return ZGenericSuccessResponse;
@@ -52,5 +52,6 @@ export const findEnvelopesRoute = authenticatedProcedure
perPage,
folderId,
orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined,
useWindowedCount: false,
});
});
@@ -0,0 +1,31 @@
import { getEditorEnvelopeById } from '@documenso/lib/server-only/envelope/get-editor-envelope-by-id';
import { authenticatedProcedure } from '../trpc';
import {
ZGetEditorEnvelopeRequestSchema,
ZGetEditorEnvelopeResponseSchema,
} from './get-editor-envelope.types';
export const getEditorEnvelopeRoute = authenticatedProcedure
.input(ZGetEditorEnvelopeRequestSchema)
.output(ZGetEditorEnvelopeResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeId } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
return await getEditorEnvelopeById({
userId: user.id,
teamId,
id: {
type: 'envelopeId',
id: envelopeId,
},
type: null,
});
});
@@ -0,0 +1,12 @@
import { z } from 'zod';
import { ZEditorEnvelopeSchema } from '@documenso/lib/types/envelope-editor';
export const ZGetEditorEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
});
export const ZGetEditorEnvelopeResponseSchema = ZEditorEnvelopeSchema;
export type TGetEditorEnvelopeRequest = z.infer<typeof ZGetEditorEnvelopeRequestSchema>;
export type TGetEditorEnvelopeResponse = z.infer<typeof ZGetEditorEnvelopeResponseSchema>;
@@ -0,0 +1,105 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZReplaceEnvelopeItemPdfRequestSchema,
ZReplaceEnvelopeItemPdfResponseSchema,
} from './replace-envelope-item-pdf.types';
/**
* Keep this internal for the envelope editor.
*
* If we want to make this public then create a separate one that only allows
* the PDF to be replaced & doesn't return deleted fields, etc.
*/
export const replaceEnvelopeItemPdfRoute = authenticatedProcedure
.input(ZReplaceEnvelopeItemPdfRequestSchema)
.output(ZReplaceEnvelopeItemPdfResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId, metadata } = ctx;
const { payload, file } = input;
const { envelopeId, envelopeItemId, title } = payload;
ctx.logger.info({
input: {
envelopeId,
envelopeItemId,
},
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelopeId,
},
type: null,
userId: user.id,
teamId,
});
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: true,
envelopeItems: {
orderBy: {
order: 'asc',
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.internalVersion !== 2) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'PDF replacement is only supported for version 2 envelopes',
});
}
const { canFileBeChanged } = getEnvelopeItemPermissions(envelope, envelope.recipients);
if (!canFileBeChanged) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item is not editable',
});
}
const envelopeItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
if (!envelopeItem) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope item not found',
});
}
const { updatedItem, fields } = await UNSAFE_replaceEnvelopeItemPdf({
envelope,
recipients: envelope.recipients,
envelopeItemId,
oldDocumentDataId: envelopeItem.documentDataId,
data: {
file,
title,
},
user: {
id: user.id,
name: user.name,
email: user.email,
},
apiRequestMetadata: metadata,
});
return {
data: updatedItem,
fields,
};
});
@@ -0,0 +1,42 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { ZEnvelopeFieldSchema } from '@documenso/lib/types/field';
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
import { zfdFile, zodFormData } from '../../utils/zod-form-data';
import { ZDocumentTitleSchema } from '../document-router/schema';
export const ZReplaceEnvelopeItemPdfPayloadSchema = z.object({
envelopeId: z.string(),
envelopeItemId: z.string(),
title: ZDocumentTitleSchema.optional(),
});
export const ZReplaceEnvelopeItemPdfRequestSchema = zodFormData({
payload: zfd.json(ZReplaceEnvelopeItemPdfPayloadSchema),
file: zfdFile(),
});
export const ZReplaceEnvelopeItemPdfResponseSchema = z.object({
data: EnvelopeItemSchema.pick({
id: true,
title: true,
envelopeId: true,
order: true,
documentDataId: true,
}),
/**
* The full list of fields for the envelope after the replacement.
*
* This is only populated if fields have been changed or deleted. It will
* return undefined otherwise.
*
* Done this way to reduce number of queries.
*/
fields: ZEnvelopeFieldSchema.array().optional(),
});
export type TReplaceEnvelopeItemPdfPayload = z.infer<typeof ZReplaceEnvelopeItemPdfPayloadSchema>;
export type TReplaceEnvelopeItemPdfRequest = z.infer<typeof ZReplaceEnvelopeItemPdfRequestSchema>;
export type TReplaceEnvelopeItemPdfResponse = z.infer<typeof ZReplaceEnvelopeItemPdfResponseSchema>;
@@ -22,11 +22,14 @@ import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-re
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
import { findEnvelopeAuditLogsRoute } from './find-envelope-audit-logs';
import { findEnvelopesRoute } from './find-envelopes';
import { getEditorEnvelopeRoute } from './get-editor-envelope';
import { getEnvelopeRoute } from './get-envelope';
import { getEnvelopeItemsRoute } from './get-envelope-items';
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids';
import { redistributeEnvelopeRoute } from './redistribute-envelope';
import { replaceEnvelopeItemPdfRoute } from './replace-envelope-item-pdf';
import { saveAsTemplateRoute } from './save-as-template';
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
import { signEnvelopeFieldRoute } from './sign-envelope-field';
@@ -54,6 +57,7 @@ export const envelopeRouter = router({
updateMany: updateEnvelopeItemsRoute,
delete: deleteEnvelopeItemRoute,
download: downloadEnvelopeItemRoute,
replacePdf: replaceEnvelopeItemPdfRoute,
},
recipient: {
get: getEnvelopeRecipientRoute,
@@ -78,6 +82,9 @@ export const envelopeRouter = router({
move: bulkMoveEnvelopesRoute,
delete: bulkDeleteEnvelopesRoute,
},
editor: {
get: getEditorEnvelopeRoute,
},
get: getEnvelopeRoute,
getMany: getEnvelopesByIdsRoute,
create: createEnvelopeRoute,
@@ -85,6 +92,7 @@ export const envelopeRouter = router({
update: updateEnvelopeRoute,
delete: deleteEnvelopeRoute,
duplicate: duplicateEnvelopeRoute,
saveAsTemplate: saveAsTemplateRoute,
distribute: distributeEnvelopeRoute,
redistribute: redistributeEnvelopeRoute,
signingStatus: signingStatusEnvelopeRoute,
@@ -0,0 +1,39 @@
import { duplicateEnvelope } from '@documenso/lib/server-only/envelope/duplicate-envelope';
import { authenticatedProcedure } from '../trpc';
import {
ZSaveAsTemplateRequestSchema,
ZSaveAsTemplateResponseSchema,
} from './save-as-template.types';
export const saveAsTemplateRoute = authenticatedProcedure
.input(ZSaveAsTemplateRequestSchema)
.output(ZSaveAsTemplateResponseSchema)
.mutation(async ({ input, ctx }) => {
const { envelopeId, includeRecipients, includeFields } = input;
const { teamId } = ctx;
ctx.logger.info({
input: {
envelopeId,
},
});
const result = await duplicateEnvelope({
userId: ctx.user.id,
teamId,
id: {
type: 'envelopeId',
id: envelopeId,
},
overrides: {
duplicateAsTemplate: true,
includeRecipients,
includeFields,
},
});
return {
id: result.id,
};
});
@@ -0,0 +1,14 @@
import { z } from 'zod';
export const ZSaveAsTemplateRequestSchema = z.object({
envelopeId: z.string().min(1).describe('The ID of the envelope to save as a template.'),
includeRecipients: z.boolean(),
includeFields: z.boolean(),
});
export const ZSaveAsTemplateResponseSchema = z.object({
id: z.string().describe('The ID of the newly created template envelope.'),
});
export type TSaveAsTemplateRequest = z.infer<typeof ZSaveAsTemplateRequestSchema>;
export type TSaveAsTemplateResponse = z.infer<typeof ZSaveAsTemplateResponseSchema>;
@@ -4,19 +4,19 @@ import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema, ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
export const ZSetEnvelopeRecipientSchema = z.object({
id: z.number().optional(),
email: ZRecipientEmailSchema,
name: z.string().max(255),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
});
export const ZSetEnvelopeRecipientsRequestSchema = z.object({
envelopeId: z.string(),
envelopeType: z.nativeEnum(EnvelopeType),
recipients: z.array(
z.object({
id: z.number().optional(),
email: ZRecipientEmailSchema,
name: z.string().max(255),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
recipients: ZSetEnvelopeRecipientSchema.array(),
});
export const ZSetEnvelopeRecipientsResponseSchema = z.object({
@@ -1,6 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { UNSAFE_updateEnvelopeItems } from '@documenso/lib/server-only/envelope-item/update-envelope-items';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
@@ -54,9 +55,40 @@ export const updateEnvelopeItemsRoute = authenticatedProcedure
});
}
if (!canEnvelopeItemsBeModified(envelope, envelope.recipients)) {
const permissions = getEnvelopeItemPermissions(envelope, envelope.recipients);
const hasOrderChange = data.some((item) => {
if (item.order === undefined) {
return false;
}
const existingItem = envelope.envelopeItems.find((e) => e.id === item.envelopeItemId);
return !existingItem || existingItem.order !== item.order;
});
const hasTitleChange = data.some((item) => item.title !== undefined);
if (!hasTitleChange && !hasOrderChange) {
return {
data: envelope.envelopeItems.map((item) => ({
id: item.id,
order: item.order,
title: item.title,
envelopeId: item.envelopeId,
})),
};
}
if (hasTitleChange && !permissions.canTitleBeChanged) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item is not editable',
message: 'Envelope item title is not editable',
});
}
if (hasOrderChange && !permissions.canOrderBeChanged) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item order is not editable',
});
}
@@ -71,28 +103,17 @@ export const updateEnvelopeItemsRoute = authenticatedProcedure
});
}
const updatedEnvelopeItems = await Promise.all(
data.map(async ({ envelopeItemId, order, title }) =>
prisma.envelopeItem.update({
where: {
envelopeId: envelope.id,
id: envelopeItemId,
},
data: {
order,
title,
},
select: {
id: true,
order: true,
title: true,
envelopeId: true,
},
}),
),
);
// Todo: Envelope [AUDIT_LOGS]
const updatedEnvelopeItems = await UNSAFE_updateEnvelopeItems({
envelopeId,
envelopeType: envelope.type,
existingEnvelopeItems: envelope.envelopeItems,
data,
user: {
name: user.name,
email: user.email,
},
apiRequestMetadata: ctx.metadata,
});
return {
data: updatedEnvelopeItems,
@@ -1,3 +1,4 @@
import { TemplateType } from '@prisma/client';
import { z } from 'zod';
import {
@@ -33,6 +34,7 @@ export const ZUpdateEnvelopeRequestSchema = z.object({
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
folderId: z.string().nullish(),
templateType: z.nativeEnum(TemplateType).optional(),
})
.optional(),
meta: ZDocumentMetaUpdateSchema.optional(),
@@ -20,7 +20,7 @@ import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-att
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { zodFormData } from '../../utils/zod-form-data';
import { zfdFile, zodFormData } from '../../utils/zod-form-data';
import type { TrpcRouteMeta } from '../trpc';
import { ZRecipientWithSigningUrlSchema } from './schema';
@@ -117,7 +117,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
export const ZUseEnvelopeRequestSchema = zodFormData({
payload: zfd.json(ZUseEnvelopePayloadSchema),
files: zfd.repeatableOfType(zfd.file()).optional(),
files: zfd.repeatableOfType(zfdFile()).optional(),
});
export const ZUseEnvelopeResponseSchema = z.object({
@@ -1,6 +1,8 @@
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
import { zEmail } from '@documenso/lib/utils/zod';
// export const createOrganisationMemberInvitesMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'POST',
@@ -16,7 +18,7 @@ export const ZCreateOrganisationMemberInvitesRequestSchema = z.object({
invitations: z
.array(
z.object({
email: z.string().trim().email().toLowerCase(),
email: zEmail().trim().toLowerCase(),
organisationRole: z.nativeEnum(OrganisationMemberRole),
}),
)
@@ -1,8 +1,11 @@
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles';
import {
buildOrganisationWhereQuery,
isOrganisationRoleWithinUserHierarchy,
} from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
@@ -52,17 +55,52 @@ export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { organisationClaim } = organisation;
const currentOrganisationMemberRole = await getMemberOrganisationRole({
organisationId: organisation.id,
reference: {
type: 'User',
id: userId,
},
});
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const invitesToDelete = await prisma.organisationMemberInvite.findMany({
where: {
id: {
in: invitationIds,
},
organisationId: organisation.id,
},
select: {
id: true,
organisationRole: true,
},
});
const hasUnauthorizedRoleAccess = invitesToDelete.some(
(invite) =>
!isOrganisationRoleWithinUserHierarchy(
currentOrganisationMemberRole,
invite.organisationRole,
),
);
if (hasUnauthorizedRoleAccess) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User does not have permission to delete invitations for higher roles',
});
}
const { organisationClaim } = organisation;
const numberOfCurrentMembers = organisation.members.length;
const numberOfCurrentInvites = organisation.invites.length;
const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites - 1;
if (subscription) {
// Removing pending invites is a reducing operation, so we don't gate it on
// the subscription being present. Sync Stripe only when one exists.
if (organisation.subscription) {
await syncMemberCountWithStripeSeatPlan(
subscription,
organisation.subscription,
organisationClaim,
totalMemberCountWithInvites,
);
@@ -2,7 +2,6 @@ import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/str
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
@@ -55,6 +54,11 @@ export const deleteOrganisationMembers = async ({
include: {
subscription: true,
organisationClaim: true,
teams: {
select: {
id: true,
},
},
members: {
select: {
id: true,
@@ -82,16 +86,43 @@ export const deleteOrganisationMembers = async ({
organisationMemberIds.includes(member.id),
);
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const inviteCount = organisation.invites.length;
const newMemberCount = organisation.members.length + inviteCount - membersToDelete.length;
if (subscription) {
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount);
// Removing members is a reducing operation, so we don't gate it on the
// subscription being present. Sync Stripe only when one exists.
if (organisation.subscription) {
await syncMemberCountWithStripeSeatPlan(
organisation.subscription,
organisationClaim,
newMemberCount,
);
}
const removedUserIds = membersToDelete.map((member) => member.userId);
const teamIds = organisation.teams.map((team) => team.id);
await prisma.$transaction(async (tx) => {
// Removing an OrganisationMember cascades the user out of every team in
// the org via OrganisationGroupMember, but their authored Envelope rows
// still reference them. Reassign those to the org owner so they remain
// reachable after the member loses access (mirrors delete-user.ts).
if (removedUserIds.length > 0 && teamIds.length > 0) {
await tx.envelope.updateMany({
where: {
userId: {
in: removedUserIds,
},
teamId: {
in: teamIds,
},
},
data: {
userId: organisation.ownerUserId,
},
});
}
await tx.organisationMember.deleteMany({
where: {
id: {
@@ -51,6 +51,7 @@ export const getOrganisationSession = async ({
where: buildTeamWhereQuery({ teamId: undefined, userId }),
include: {
teamGlobalSettings: true,
teamEmail: { select: { email: true } },
teamGroups: {
where: {
organisationGroup: {
@@ -3,6 +3,7 @@ import { z } from 'zod';
import { ZOrganisationSchema } from '@documenso/lib/types/organisation';
import { OrganisationMemberRole, TeamMemberRole } from '@documenso/prisma/generated/types';
import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import { TeamEmailSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamEmailSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
export const ZGetOrganisationSessionResponseSchema = ZOrganisationSchema.extend({
@@ -16,6 +17,7 @@ export const ZGetOrganisationSessionResponseSchema = ZOrganisationSchema.extend(
organisationId: true,
}).extend({
currentTeamRole: z.nativeEnum(TeamMemberRole),
teamEmail: TeamEmailSchema.pick({ email: true }).nullable(),
preferences: z.object({
aiFeaturesEnabled: z.boolean(),
}),
@@ -1,7 +1,6 @@
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
@@ -30,6 +29,11 @@ export const leaveOrganisationRoute = authenticatedProcedure
include: {
organisationClaim: true,
subscription: true,
teams: {
select: {
id: true,
},
},
invites: {
where: {
status: OrganisationMemberInviteStatus.PENDING,
@@ -52,22 +56,48 @@ export const leaveOrganisationRoute = authenticatedProcedure
const { organisationClaim } = organisation;
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const inviteCount = organisation.invites.length;
const newMemberCount = organisation.members.length + inviteCount - 1;
if (subscription) {
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount);
// Leaving is a reducing operation, so we don't gate it on the subscription
// being present. Sync Stripe only when one exists.
if (organisation.subscription) {
await syncMemberCountWithStripeSeatPlan(
organisation.subscription,
organisationClaim,
newMemberCount,
);
}
await prisma.organisationMember.delete({
where: {
userId_organisationId: {
userId,
organisationId,
const teamIds = organisation.teams.map((team) => team.id);
await prisma.$transaction(async (tx) => {
// Leaving the org cascades the user out of every team via
// OrganisationGroupMember, but their authored Envelope rows still
// reference them. Reassign those to the org owner so they remain
// reachable after the member loses access (mirrors delete-user.ts).
if (teamIds.length > 0) {
await tx.envelope.updateMany({
where: {
userId,
teamId: {
in: teamIds,
},
},
data: {
userId: organisation.ownerUserId,
},
});
}
await tx.organisationMember.delete({
where: {
userId_organisationId: {
userId,
organisationId,
},
},
},
});
});
await jobs.triggerJob({
@@ -40,6 +40,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
defaultRecipients,
delegateDocumentOwnership,
envelopeExpirationPeriod,
reminderSettings,
// Branding related settings.
brandingEnabled,
@@ -153,6 +154,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
envelopeExpirationPeriod:
envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
// Branding related settings.
brandingEnabled,
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
@@ -9,6 +10,7 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { zEmail } from '@documenso/lib/utils/zod';
export const ZUpdateOrganisationSettingsRequestSchema = z.object({
organisationId: z.string(),
@@ -28,6 +30,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(),
reminderSettings: ZEnvelopeReminderSettings.optional(),
// Branding related settings.
brandingEnabled: z.boolean().optional(),
@@ -37,7 +40,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
// Email related settings.
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
emailReplyTo: zEmail().nullish(),
// emailReplyToName: z.string().optional(),
emailDocumentSettings: ZDocumentEmailSettingsSchema.optional(),
@@ -1,5 +1,7 @@
import { z } from 'zod';
import { zEmail } from '@documenso/lib/utils/zod';
export const ZGetRecipientSuggestionsRequestSchema = z.object({
query: z.string().default(''),
});
@@ -8,7 +10,7 @@ export const ZGetRecipientSuggestionsResponseSchema = z.object({
results: z.array(
z.object({
name: z.string().nullable(),
email: z.string().email(),
email: z.union([zEmail(), z.literal('')]),
}),
),
});
@@ -9,6 +9,7 @@ import {
ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientLiteSchema, ZRecipientSchema } from '@documenso/lib/types/recipient';
import { zEmail } from '@documenso/lib/utils/zod';
export const ZGetRecipientRequestSchema = z.object({
recipientId: z.number(),
@@ -24,7 +25,7 @@ export const ZGetRecipientResponseSchema = ZRecipientSchema;
* pass along required details.
*/
export const ZCreateRecipientSchema = z.object({
email: z.string().toLowerCase().email().min(1).max(254),
email: zEmail().toLowerCase().min(1).max(254),
name: z.string().max(255),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -34,7 +35,7 @@ export const ZCreateRecipientSchema = z.object({
export const ZUpdateRecipientSchema = z.object({
id: z.number().describe('The ID of the recipient to update.'),
email: z.string().toLowerCase().email().min(1).max(254).optional(),
email: zEmail().toLowerCase().min(1).max(254).optional(),
name: z.string().max(255).optional(),
role: z.nativeEnum(RecipientRole).optional(),
signingOrder: z.number().optional(),
@@ -83,7 +84,7 @@ export const ZSetDocumentRecipientsRequestSchema = z.object({
recipients: z.array(
z.object({
id: z.number().optional(),
email: z.string().toLowerCase().email().min(1).max(254),
email: zEmail().toLowerCase().min(1).max(254),
name: z.string().max(255),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -142,10 +143,7 @@ export const ZSetTemplateRecipientsRequestSchema = z.object({
.toLowerCase()
.refine(
(email) => {
return (
isTemplateRecipientEmailPlaceholder(email) ||
z.string().email().safeParse(email).success
);
return isTemplateRecipientEmailPlaceholder(email) || zEmail().safeParse(email).success;
},
{ message: 'Please enter a valid email address' },
),
@@ -167,13 +165,13 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
accessAuthOptions: ZRecipientAccessAuthSchema.optional(),
nextSigner: z
.object({
email: z.string().email().max(254),
email: zEmail().max(254),
name: z.string().min(1).max(255),
})
.optional(),
recipientOverride: z
.object({
email: z.string().trim().toLowerCase().email().max(254).optional(),
email: zEmail().trim().toLowerCase().max(254).optional(),
name: z.string().max(255).optional(),
})
.optional(),
@@ -34,6 +34,11 @@ export const deleteTeamMemberRoute = authenticatedProcedure
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
include: {
organisation: {
select: {
ownerUserId: true,
},
},
teamGroups: {
where: {
organisationGroup: {
@@ -106,12 +111,39 @@ export const deleteTeamMemberRoute = authenticatedProcedure
});
}
await prisma.organisationGroupMember.delete({
where: {
organisationMemberId_groupId: {
organisationMemberId: memberId,
groupId: teamGroupToRemoveMemberFrom.organisationGroupId,
const removedMember =
teamGroupToRemoveMemberFrom.organisationGroup.organisationGroupMembers.find(
(ogm) => ogm.organisationMember.id === memberId,
);
if (!removedMember) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Member not found in this team',
});
}
await prisma.$transaction(async (tx) => {
// Removing a user from a single team drops their INTERNAL_TEAM
// OrganisationGroupMember link, but Envelope rows they authored in this
// team still point at their userId. Reassign to the org owner so those
// envelopes remain reachable after the member loses team access.
await tx.envelope.updateMany({
where: {
userId: removedMember.organisationMember.userId,
teamId,
},
},
data: {
userId: team.organisation.ownerUserId,
},
});
await tx.organisationGroupMember.delete({
where: {
organisationMemberId_groupId: {
organisationMemberId: memberId,
groupId: teamGroupToRemoveMemberFrom.organisationGroupId,
},
},
});
});
});
+2 -1
View File
@@ -2,6 +2,7 @@ import { TeamMemberRole } from '@prisma/client';
import { z } from 'zod';
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
import { zEmail } from '@documenso/lib/utils/zod';
/**
* Restrict team URLs schema.
@@ -43,7 +44,7 @@ export const ZTeamNameSchema = z
export const ZCreateTeamEmailVerificationMutationSchema = z.object({
teamId: z.number(),
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().trim().email().toLowerCase().min(1, 'Please enter a valid email.'),
email: zEmail().trim().toLowerCase().min(1, 'Please enter a valid email.'),
});
export const ZDeleteTeamEmailMutationSchema = z.object({
@@ -42,6 +42,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
drawSignatureEnabled,
delegateDocumentOwnership,
envelopeExpirationPeriod,
reminderSettings,
// Branding related settings.
brandingEnabled,
@@ -159,6 +160,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
delegateDocumentOwnership,
envelopeExpirationPeriod:
envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
// Branding related settings.
brandingEnabled,
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
@@ -9,6 +10,7 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { zEmail } from '@documenso/lib/utils/zod';
/**
* Null = Inherit from organisation.
@@ -31,6 +33,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
drawSignatureEnabled: z.boolean().nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
reminderSettings: ZEnvelopeReminderSettings.nullish(),
// Branding related settings.
brandingEnabled: z.boolean().nullish(),
@@ -40,7 +43,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
// Email related settings.
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
emailReplyTo: zEmail().nullish(),
// emailReplyToName: z.string().nullish(),
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullish(),
@@ -55,6 +55,7 @@ export const getTemplatesByIdsRoute = authenticatedProcedure
select: {
id: true,
url: true,
name: true,
},
},
documentMeta: {
@@ -97,6 +98,7 @@ export const getTemplatesByIdsRoute = authenticatedProcedure
? {
id: envelope.team.id,
url: envelope.team.url,
name: envelope.team.name,
}
: null,
fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)),
@@ -18,7 +18,9 @@ import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/
import { createTemplateDirectLink } from '@documenso/lib/server-only/template/create-template-direct-link';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { deleteTemplateDirectLink } from '@documenso/lib/server-only/template/delete-template-direct-link';
import { findOrganisationTemplates } from '@documenso/lib/server-only/template/find-organisation-templates';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getOrganisationTemplateById } from '@documenso/lib/server-only/template/get-organisation-template-by-id';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
@@ -46,8 +48,11 @@ import {
ZDeleteTemplateMutationSchema,
ZDuplicateTemplateMutationSchema,
ZDuplicateTemplateResponseSchema,
ZFindOrganisationTemplatesRequestSchema,
ZFindTemplatesRequestSchema,
ZFindTemplatesResponseSchema,
ZGetOrganisationTemplateByIdRequestSchema,
ZGetOrganisationTemplateByIdResponseSchema,
ZGetTemplateByIdRequestSchema,
ZGetTemplateByIdResponseSchema,
ZToggleTemplateDirectLinkRequestSchema,
@@ -55,6 +60,7 @@ import {
ZUpdateTemplateRequestSchema,
ZUpdateTemplateResponseSchema,
} from './schema';
import { searchTemplateRoute } from './search-template';
export const templateRouter = router({
/**
@@ -121,6 +127,75 @@ export const templateRouter = router({
};
}),
/**
* @private
*/
findOrganisationTemplates: authenticatedProcedure
.input(ZFindOrganisationTemplatesRequestSchema)
.output(ZFindTemplatesResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId } = ctx;
const result = await findOrganisationTemplates({
userId: ctx.user.id,
teamId,
...input,
});
// Remapping for backwards compatibility.
return {
...result,
data: result.data.map((envelope) => {
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
return {
id: legacyTemplateId,
envelopeId: envelope.id,
type: envelope.templateType,
visibility: envelope.visibility,
externalId: envelope.externalId,
title: envelope.title,
userId: envelope.userId,
teamId: envelope.teamId,
authOptions: envelope.authOptions,
createdAt: envelope.createdAt,
updatedAt: envelope.updatedAt,
publicTitle: envelope.publicTitle,
publicDescription: envelope.publicDescription,
folderId: envelope.folderId,
useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
team: envelope.team,
fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)),
recipients: envelope.recipients.map((recipient) =>
mapRecipientToLegacyRecipient(recipient, envelope),
),
templateMeta: envelope.documentMeta,
directLink: envelope.directLink,
};
}),
};
}),
/**
* @private
*/
getOrganisationTemplateById: authenticatedProcedure
.input(ZGetOrganisationTemplateByIdRequestSchema)
.output(ZGetOrganisationTemplateByIdResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId } = ctx;
const { envelopeId } = input;
return await getOrganisationTemplateById({
id: {
type: 'envelopeId',
id: envelopeId,
},
userId: ctx.user.id,
teamId,
});
}),
/**
* @public
*/
@@ -160,6 +235,8 @@ export const templateRouter = router({
*/
getMany: getTemplatesByIdsRoute,
search: searchTemplateRoute,
/**
* Wait until RR7 so we can passthrough documents.
*
@@ -525,6 +602,10 @@ export const templateRouter = router({
}).catch((err) => {
console.error(err);
if (err instanceof AppError) {
throw err;
}
throw new AppError('DOCUMENT_SEND_FAILED');
});
}
+15 -5
View File
@@ -22,6 +22,7 @@ import {
ZDocumentMetaTypedSignatureEnabledSchema,
ZDocumentMetaUploadSignatureEnabledSchema,
} from '@documenso/lib/types/document-meta';
import { ZEnvelopeSchema } from '@documenso/lib/types/envelope';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
@@ -31,10 +32,11 @@ import {
ZTemplateManySchema,
ZTemplateSchema,
} from '@documenso/lib/types/template';
import { zEmail } from '@documenso/lib/utils/zod';
import { LegacyTemplateDirectLinkSchema } from '@documenso/prisma/types/template-legacy-schema';
import { ZDocumentExternalIdSchema } from '@documenso/trpc/server/document-router/schema';
import { zodFormData } from '../../utils/zod-form-data';
import { zfdFile, zodFormData } from '../../utils/zod-form-data';
import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema';
export const MAX_TEMPLATE_PUBLIC_TITLE_LENGTH = 50;
@@ -72,7 +74,7 @@ export const ZTemplateMetaUpsertSchema = z.object({
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
emailReplyTo: zEmail().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
@@ -85,14 +87,14 @@ export const ZTemplateMetaUpsertSchema = z.object({
export const ZCreateDocumentFromDirectTemplateRequestSchema = z.object({
directRecipientName: z.string().max(255).optional(),
directRecipientEmail: z.string().email().max(254),
directRecipientEmail: zEmail().max(254),
directTemplateToken: z.string().min(1),
directTemplateExternalId: z.string().optional(),
signedFieldValues: z.array(ZSignFieldWithTokenMutationSchema),
templateUpdatedAt: z.date(),
nextSigner: z
.object({
email: z.string().email().max(254),
email: zEmail().max(254),
name: z.string().min(1).max(255),
})
.optional(),
@@ -266,7 +268,7 @@ export const ZCreateTemplatePayloadSchema = ZCreateTemplateV2RequestSchema;
export const ZCreateTemplateMutationSchema = zodFormData({
payload: zfd.json(ZCreateTemplatePayloadSchema),
file: zfd.file(),
file: zfdFile(),
});
export const ZUpdateTemplateRequestSchema = z.object({
@@ -295,6 +297,8 @@ export const ZFindTemplatesRequestSchema = ZFindSearchParamsSchema.extend({
folderId: z.string().describe('The ID of the folder to filter templates by.').optional(),
});
export const ZFindOrganisationTemplatesRequestSchema = ZFindSearchParamsSchema;
export const ZFindTemplatesResponseSchema = ZFindResultResponse.extend({
data: ZTemplateManySchema.array(),
});
@@ -308,6 +312,12 @@ export const ZGetTemplateByIdRequestSchema = z.object({
export const ZGetTemplateByIdResponseSchema = ZTemplateSchema;
export const ZGetOrganisationTemplateByIdRequestSchema = z.object({
envelopeId: z.string(),
});
export const ZGetOrganisationTemplateByIdResponseSchema = ZEnvelopeSchema;
export const ZBulkSendTemplateMutationSchema = z.object({
templateId: z.number(),
teamId: z.number(),
@@ -0,0 +1,21 @@
import { searchTemplatesWithKeyword } from '@documenso/lib/server-only/template/search-templates-with-keyword';
import { authenticatedProcedure } from '../trpc';
import {
ZSearchTemplateRequestSchema,
ZSearchTemplateResponseSchema,
} from './search-template.types';
export const searchTemplateRoute = authenticatedProcedure
.input(ZSearchTemplateRequestSchema)
.output(ZSearchTemplateResponseSchema)
.query(async ({ input, ctx }) => {
const { query } = input;
const templates = await searchTemplatesWithKeyword({
query,
userId: ctx.user.id,
});
return templates;
});
@@ -0,0 +1,16 @@
import { z } from 'zod';
export const ZSearchTemplateRequestSchema = z.object({
query: z.string().trim().min(1).max(1024),
});
export const ZSearchTemplateResponseSchema = z
.object({
title: z.string(),
path: z.string(),
value: z.string(),
})
.array();
export type TSearchTemplateRequest = z.infer<typeof ZSearchTemplateRequestSchema>;
export type TSearchTemplateResponse = z.infer<typeof ZSearchTemplateResponseSchema>;
+8 -4
View File
@@ -73,7 +73,7 @@ const t = initTRPC
/**
* Middlewares
*/
export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path }) => {
export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, meta }) => {
const infoToLog: TrpcApiLog = {
path,
auth: ctx.metadata.auth,
@@ -84,8 +84,10 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path })
const authorizationHeader = ctx.req.headers.get('authorization');
const isApiV2 = Boolean(meta?.openapi?.path);
// Taken from `authenticatedMiddleware` in `@documenso/api/v1/middleware/authenticated.ts`.
if (authorizationHeader) {
if (authorizationHeader && isApiV2) {
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
@@ -164,7 +166,7 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path })
});
});
export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, path }) => {
export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, path, meta }) => {
// Recreate the logger with a sub request ID to differentiate between batched requests.
const trpcSessionLogger = ctx.logger.child({
nonBatchedRequestId: alphaid(),
@@ -180,8 +182,10 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
const authorizationHeader = ctx.req.headers.get('authorization');
const isApiV2 = Boolean(meta?.openapi?.path);
// Taken from `authenticatedMiddleware` in `@documenso/api/v1/middleware/authenticated.ts`.
if (authorizationHeader) {
if (authorizationHeader && isApiV2) {
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
@@ -1,8 +1,7 @@
import { Prisma, WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { jobs } from '@documenso/lib/jobs/client';
import { ZWebhookPayloadSchema } from '@documenso/lib/types/webhook-payload';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
@@ -35,46 +34,22 @@ export const resendWebhookCallRoute = authenticatedProcedure
}),
},
},
include: {
webhook: true,
},
});
if (!webhookCall) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { webhook } = webhookCall;
// `requestBody` stores the full delivery envelope; unwrap to the inner
// document so the handler doesn't wrap it a second time.
const { payload: data } = ZWebhookPayloadSchema.parse(webhookCall.requestBody);
// Note: This is duplicated in `execute-webhook.handler.ts`.
const response = await fetch(webhookCall.url, {
method: 'POST',
body: JSON.stringify(webhookCall.requestBody),
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': webhook.secret ?? '',
},
});
const body = await response.text();
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
try {
responseBody = JSON.parse(body);
} catch (err) {
responseBody = body;
}
return await prisma.webhookCall.update({
where: {
id: webhookCall.id,
},
data: {
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
responseCode: response.status,
responseBody,
responseHeaders: Object.fromEntries(response.headers.entries()),
await jobs.triggerJob({
name: 'internal.execute-webhook',
payload: {
event: webhookCall.event,
webhookId,
data,
},
});
});
@@ -1,26 +1,11 @@
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { z } from 'zod';
import WebhookCallSchema from '@documenso/prisma/generated/zod/modelSchema/WebhookCallSchema';
export const ZResendWebhookCallRequestSchema = z.object({
webhookId: z.string(),
webhookCallId: z.string(),
});
export const ZResendWebhookCallResponseSchema = WebhookCallSchema.pick({
webhookId: true,
status: true,
event: true,
id: true,
url: true,
responseCode: true,
createdAt: true,
}).extend({
requestBody: z.unknown(),
responseHeaders: z.unknown().nullable(),
responseBody: z.unknown().nullable(),
});
export const ZResendWebhookCallResponseSchema = z.void();
export type TResendWebhookRequest = z.infer<typeof ZResendWebhookCallRequestSchema>;
export type TResendWebhookResponse = z.infer<typeof ZResendWebhookCallResponseSchema>;
+10 -1
View File
@@ -1,8 +1,17 @@
import { WebhookTriggerEvents } from '@prisma/client';
import { z } from 'zod';
import { isPrivateUrl } from '@documenso/lib/server-only/webhooks/is-private-url';
export const ZWebhookUrlSchema = z
.string()
.url()
.refine((url) => !isPrivateUrl(url), {
message: 'Webhook URL cannot point to a private or loopback address',
});
export const ZCreateWebhookRequestSchema = z.object({
webhookUrl: z.string().url(),
webhookUrl: ZWebhookUrlSchema,
eventTriggers: z
.array(z.nativeEnum(WebhookTriggerEvents))
.min(1, { message: 'At least one event trigger is required' }),