Files
esign/packages/lib/server-only/document/complete-document-with-token.ts
zooqueen 15d742477b fix: create documents and templates on a fresh database
Envelope id allocation now establishes the counter row it reads, so the
first document on a database that has never allocated one gets id 1.

Nothing inside a transaction reaches the global Prisma client any more.
Base SQLite serves one write connection, so a second client waiting on
the lock the transaction already holds stalls until the busy timeout and
the transaction dies. Webhooks, job triggers and verification emails are
side effects of committed state, so they fire after the commit; the
organisation link no longer nests a second transaction inside itself; and
a recipient lookup in the v1 field API reads on its own transaction.

EXISTS subqueries name a column. Postgres accepts an empty select list,
SQLite does not, which is what stopped the seal sweep, the unsealed
document view and signing volume from running at all.

Also:
- the profile form saves a name before a signature exists, matching the
  server schema, instead of failing validation with nothing to show
- every upload reads its error from one place, so a failed template
  upload says template
- the documents filter strip scrolls its selected tab into view
- the inbox and support headings use the shared page header
- one settings nav item, so a click lands on the link and fires once
- the command dialog names itself for screen readers, and the mobile
  search and menu buttons carry labels
- documentation links point at docs.hanzo.ai
- the organisations row links to the organisation
- the app is called Hanzo Sign throughout, the wordmark variables are
  declared to turbo, and the webhook secret rides a valid header name
2026-08-14 01:15:39 -07:00

423 lines
12 KiB
TypeScript

import {
DocumentSigningOrder,
DocumentStatus,
EnvelopeType,
FieldType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import {
DOCUMENT_AUDIT_LOG_TYPE,
RECIPIENT_DIFF_TYPE,
} from '@hanzo/esign-lib/types/document-audit-logs';
import type { RequestMetadata } from '@hanzo/esign-lib/universal/extract-request-metadata';
import { fieldsContainUnsignedRequiredField } from '@hanzo/esign-lib/utils/advanced-fields-helpers';
import { createDocumentAuditLogData } from '@hanzo/esign-lib/utils/document-audit-logs';
import { prisma } from '@hanzo/esign-prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import type { TRecipientAccessAuth } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';
import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
id: EnvelopeIdOptions;
userId?: number;
accessAuthOptions?: TRecipientAccessAuth;
requestMetadata?: RequestMetadata;
nextSigner?: {
email: string;
name: string;
};
/**
* Override the recipient information. This will only work if the recipient
* does not have a name or email set.
*/
recipientOverride?: {
email?: string;
name?: string;
};
};
export const completeDocumentWithToken = async ({
token,
id,
userId,
accessAuthOptions,
requestMetadata,
nextSigner,
recipientOverride,
}: CompleteDocumentWithTokenOptions) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
recipients: {
some: {
token,
},
},
},
include: {
documentMeta: true,
recipients: {
where: {
token,
},
},
},
});
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (envelope.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${envelope.id} must be pending`);
}
if (envelope.recipients.length === 0) {
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
}
const [recipient] = envelope.recipients;
assertRecipientNotExpired(recipient);
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
if (recipient.signingStatus === SigningStatus.REJECTED) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Recipient has already rejected the document',
statusCode: 400,
});
}
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
if (!isRecipientsTurn) {
throw new Error(
`Recipient ${recipient.id} attempted to complete the document before it was their turn`,
);
}
}
const fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
if (fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
let recipientName = recipient.name;
let recipientEmail = recipient.email;
// Only trim the name if it's been derived.
if (!recipientName) {
recipientName = (
recipientOverride?.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
''
).trim();
}
// Only trim the email if it's been derived.
if (!recipient.email) {
recipientEmail = (
recipientOverride?.email ||
fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
''
)
.trim()
.toLowerCase();
}
if (!recipientEmail) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Recipient email is required',
});
}
// Check ACCESS AUTH 2FA validation during document completion
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
if (!accessAuthOptions) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Access authentication required',
});
}
if (!recipient.email.trim()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient ${recipient.id} requires an email because they have auth requirements.`,
});
}
const isValid = await isRecipientAuthorized({
type: 'ACCESS_2FA',
documentAuthOptions: envelope.authOptions,
recipient: recipient,
userId, // Can be undefined for non-account recipients
authOptions: accessAuthOptions,
});
if (!isValid) {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED,
envelopeId: envelope.id,
data: {
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
},
}),
});
throw new AppError(AppErrorCode.TWO_FACTOR_AUTH_FAILED, {
message: 'Invalid 2FA authentication',
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED,
envelopeId: envelope.id,
data: {
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
},
}),
});
}
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
name: recipientName,
email: recipientEmail,
},
});
if (recipientEmail !== recipient.email || recipientName !== recipient.name) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
user: {
name: recipientName,
email: recipientEmail,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: recipient.name,
to: recipientName,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: recipient.email,
to: recipientEmail,
},
],
},
}),
});
}
const authOptions = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
envelopeId: envelope.id,
user: {
name: recipientName,
email: recipientEmail,
},
requestMetadata,
data: {
recipientEmail: recipientEmail,
recipientName: recipientName,
recipientId: recipient.id,
recipientRole: recipient.role,
actionAuth: authOptions.derivedRecipientActionAuth,
},
}),
});
});
await jobs.triggerJob({
name: 'send.recipient.signed.email',
payload: {
documentId: legacyDocumentId,
recipientId: recipient.id,
},
});
const pendingRecipients = await prisma.recipient.findMany({
select: {
id: true,
signingOrder: true,
name: true,
email: true,
role: true,
},
where: {
envelopeId: envelope.id,
signingStatus: {
not: SigningStatus.SIGNED,
},
role: {
not: RecipientRole.CC,
},
},
// Composite sort so our next recipient is always the one with the lowest signing order or id
// if there is a tie.
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
});
if (pendingRecipients.length > 0) {
await sendPendingEmail({ id, recipientId: recipient.id });
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const [nextRecipient] = pendingRecipients;
await prisma.$transaction(async (tx) => {
if (nextSigner && envelope.documentMeta?.allowDictateNextSigner) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
user: {
name: recipientName,
email: recipientEmail,
},
requestMetadata,
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
},
}),
});
}
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
});
});
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: envelope.userId,
documentId: legacyDocumentId,
recipientId: nextRecipient.id,
requestMetadata,
},
});
}
}
const haveAllRecipientsSigned = await prisma.envelope.findFirst({
where: {
id: envelope.id,
recipients: {
every: {
OR: [{ signingStatus: SigningStatus.SIGNED }, { role: RecipientRole.CC }],
},
},
},
});
if (haveAllRecipientsSigned) {
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId: legacyDocumentId,
requestMetadata,
},
});
}
const updatedDocument = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
type: EnvelopeType.DOCUMENT,
},
include: {
documentMeta: true,
recipients: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedDocument)),
userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined,
});
};