feat: add envelopes (#2025)

This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
This commit is contained in:
David Nguyen
2025-10-14 21:56:36 +11:00
committed by GitHub
parent 7b17156e56
commit 7f09ba72f4
447 changed files with 33467 additions and 9622 deletions

View File

@ -0,0 +1,98 @@
import { EnvelopeType, type Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { FindResultResponse } from '../../types/search-params';
export interface AdminFindDocumentsOptions {
query?: string;
page?: number;
perPage?: number;
}
export const adminFindDocuments = async ({
query,
page = 1,
perPage = 10,
}: AdminFindDocumentsOptions) => {
let termFilters: Prisma.EnvelopeWhereInput | undefined = !query
? undefined
: {
title: {
contains: query,
mode: 'insensitive',
},
};
if (query && query.startsWith('envelope_')) {
termFilters = {
id: {
equals: query,
},
};
}
if (query && query.startsWith('document_')) {
termFilters = {
secondaryId: {
equals: query,
},
};
}
if (query) {
const isQueryAnInteger = !isNaN(parseInt(query));
if (isQueryAnInteger) {
termFilters = {
secondaryId: {
equals: `document_${query}`,
},
};
}
}
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where: {
type: EnvelopeType.DOCUMENT,
...termFilters,
},
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: true,
team: {
select: {
id: true,
url: true,
},
},
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.DOCUMENT,
...termFilters,
},
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};

View File

@ -0,0 +1,131 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { DocumentStatus, SendStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
export type AdminSuperDeleteDocumentOptions = {
envelopeId: string;
requestMetadata?: RequestMetadata;
};
export const adminSuperDeleteDocument = async ({
envelopeId,
requestMetadata,
}: AdminSuperDeleteDocumentOptions) => {
const envelope = await prisma.envelope.findUnique({
where: {
id: envelopeId,
},
include: {
recipients: true,
documentMeta: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const { branding, settings, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const { status, user } = envelope;
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).documentDeleted;
// if the document is pending, send cancellation emails to all recipients
if (
status === DocumentStatus.PENDING &&
envelope.recipients.length > 0 &&
isDocumentDeletedEmailEnabled
) {
await Promise.all(
envelope.recipients.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: envelope.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
const lang = envelope.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
}),
);
}
// always hard delete if deleted from admin
return await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
envelopeId,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'HARD',
},
}),
});
return await tx.envelope.delete({ where: { id: envelopeId } });
});
};

View File

@ -1,58 +0,0 @@
import type { Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { FindResultResponse } from '../../types/search-params';
export interface FindDocumentsOptions {
query?: string;
page?: number;
perPage?: number;
}
export const findDocuments = async ({ query, page = 1, perPage = 10 }: FindDocumentsOptions) => {
const termFilters: Prisma.DocumentWhereInput | undefined = !query
? undefined
: {
title: {
contains: query,
mode: 'insensitive',
},
};
const [data, count] = await Promise.all([
prisma.document.findMany({
where: {
...termFilters,
},
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: true,
},
}),
prisma.document.count({
where: {
...termFilters,
},
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};

View File

@ -1,8 +1,13 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
export const getDocumentStats = async () => {
const counts = await prisma.document.groupBy({
const counts = await prisma.envelope.groupBy({
where: {
type: EnvelopeType.DOCUMENT,
},
by: ['status'],
_count: {
_all: true,

View File

@ -1,14 +1,22 @@
import type { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
export type GetEntireDocumentOptions = {
id: number;
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
export type unsafeGetEntireEnvelopeOptions = {
id: EnvelopeIdOptions;
type: EnvelopeType;
};
export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => {
const document = await prisma.document.findFirstOrThrow({
where: {
id,
},
/**
* An unauthenticated function that returns the whole envelope
*/
export const unsafeGetEntireEnvelope = async ({ id, type }: unsafeGetEntireEnvelopeOptions) => {
const envelope = await prisma.envelope.findFirst({
where: unsafeBuildEnvelopeIdQuery(id, type),
include: {
documentMeta: true,
user: {
@ -30,5 +38,11 @@ export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => {
},
});
return document;
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return envelope;
};

View File

@ -1,4 +1,4 @@
import { DocumentStatus, SubscriptionStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType, SubscriptionStatus } from '@prisma/client';
import { kyselyPrisma, sql } from '@documenso/prisma';
@ -31,22 +31,23 @@ export async function getSigningVolume({
.selectFrom('Subscription as s')
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.leftJoin('Document as d', (join) =>
.leftJoin('Envelope as e', (join) =>
join
.onRef('t.id', '=', 'd.teamId')
.on('d.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('d.deletedAt', 'is', null),
.onRef('t.id', '=', 'e.teamId')
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('e.deletedAt', 'is', null),
)
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.where('e.type', '=', EnvelopeType.DOCUMENT)
.select([
's.id as id',
's.createdAt as createdAt',
's.planId as planId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT d.id)`.as('signingVolume'),
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
])
.groupBy(['s.id', 'o.name']);

View File

@ -32,12 +32,13 @@ type GetUserWithDocumentMonthlyGrowthQueryResult = Array<{
export const getUserWithSignedDocumentMonthlyGrowth = async () => {
const result = await prisma.$queryRaw<GetUserWithDocumentMonthlyGrowthQueryResult>`
SELECT
DATE_TRUNC('month', "Document"."createdAt") AS "month",
COUNT(DISTINCT "Document"."userId") as "count",
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."userId" END) as "signed_count"
FROM "Document"
INNER JOIN "Team" ON "Document"."teamId" = "Team"."id"
DATE_TRUNC('month', "Envelope"."createdAt") AS "month",
COUNT(DISTINCT "Envelope"."userId") as "count",
COUNT(DISTINCT CASE WHEN "Envelope"."status" = 'COMPLETED' THEN "Envelope"."userId" END) as "signed_count"
FROM "Envelope"
INNER JOIN "Team" ON "Envelope"."teamId" = "Team"."id"
INNER JOIN "Organisation" ON "Team"."organisationId" = "Organisation"."id"
WHERE "Envelope"."type" = 'DOCUMENT'::"EnvelopeType"
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12