Merge branch 'main' into feat/acroform-field-import

This commit is contained in:
Ephraim Duncan
2026-08-19 09:55:00 +00:00
committed by GitHub
478 changed files with 31812 additions and 14567 deletions
@@ -0,0 +1,372 @@
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5;
const MAX_POSTGRES_INT = 2147483647;
const GROUP_ORDER = ['document', 'user', 'organisation', 'team', 'recipient', 'subscription'] as const;
export type AdminGlobalSearchResultType = (typeof GROUP_ORDER)[number];
export type AdminGlobalSearchResult = {
label: string;
sublabel?: string;
path: string;
value: string;
};
export type AdminGlobalSearchGroup = {
type: AdminGlobalSearchResultType;
results: AdminGlobalSearchResult[];
};
export type AdminGlobalSearchOptions = {
query: string;
};
type PartialResults = Partial<Record<AdminGlobalSearchResultType, AdminGlobalSearchResult[]>>;
export const adminGlobalSearch = async ({ query }: AdminGlobalSearchOptions): Promise<AdminGlobalSearchGroup[]> => {
const trimmedQuery = query.trim();
if (trimmedQuery.length === 0) {
return [];
}
const resultsByType = await resolveSearch(trimmedQuery);
return GROUP_ORDER.map((type) => ({
type,
results: (resultsByType[type] ?? []).map((result) => ({
...result,
// Append the raw query so cmdk's client-side filter never hides
// server-verified results.
value: `${result.value} ${trimmedQuery}`,
})),
})).filter((group) => group.results.length > 0);
};
const resolveSearch = async (query: string): Promise<PartialResults> => {
// Recognized ID prefixes resolve to a single exact lookup.
if (query.startsWith('envelope_')) {
return { document: await findDocumentsByExactId({ id: query }) };
}
if (query.startsWith('document_')) {
return { document: await findDocumentsByExactId({ secondaryId: query }) };
}
if (query.startsWith('org_')) {
return { organisation: await findOrganisationsByIdOrUrl(query) };
}
// Bare numbers are treated as verified ID lookups only. Oversized numbers
// fall through to text search.
const numericId = Number(query);
if (/^\d+$/.test(query) && numericId <= MAX_POSTGRES_INT) {
const [document, user, team, recipient, subscription] = await Promise.all([
findDocumentsByExactId({ secondaryId: `document_${numericId}` }),
findUsersById(numericId),
findTeamsById(numericId),
findRecipientsById(numericId),
findSubscriptionsById(numericId),
]);
return { document, user, team, recipient, subscription };
}
// Free text searches all resource types in parallel.
const [document, user, organisation, team, recipient, subscription] = await Promise.all([
findDocumentsByText(query),
findUsersByText(query),
findOrganisationsByText(query),
findTeamsByText(query),
findRecipientsByText(query),
findSubscriptionsByText(query),
]);
return {
document,
user,
organisation,
team,
recipient,
subscription,
};
};
const joinSublabel = (parts: Array<string | null | undefined>) =>
parts.filter((part) => part && part.length > 0).join(' · ') || undefined;
// ─── Documents ────────────────────────────────────────────────────────────────
const documentSelect = {
id: true,
title: true,
secondaryId: true,
user: { select: { email: true } },
} as const;
type DocumentRow = {
id: string;
title: string;
secondaryId: string;
user: { email: string };
};
const mapDocument = (envelope: DocumentRow): AdminGlobalSearchResult => ({
label: envelope.title,
sublabel: joinSublabel([envelope.secondaryId, envelope.user.email]),
path: `/admin/documents/${envelope.id}`,
value: `document ${envelope.id} ${envelope.secondaryId} ${envelope.title} ${envelope.user.email}`,
});
const findDocumentsByExactId = async (where: { id: string } | { secondaryId: string }) => {
const envelope = await prisma.envelope.findFirst({
where: { ...where, type: EnvelopeType.DOCUMENT },
select: documentSelect,
});
return envelope ? [mapDocument(envelope)] : [];
};
const findDocumentsByText = async (query: string) => {
const envelopes = await prisma.envelope.findMany({
where: {
type: EnvelopeType.DOCUMENT,
title: { contains: query, mode: 'insensitive' },
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: documentSelect,
});
return envelopes.map(mapDocument);
};
// ─── Users ────────────────────────────────────────────────────────────────────
const userSelect = {
id: true,
name: true,
email: true,
} as const;
type UserRow = { id: number; name: string | null; email: string };
const mapUser = (user: UserRow): AdminGlobalSearchResult => ({
label: user.name || user.email,
sublabel: joinSublabel([`#${user.id}`, user.email]),
path: `/admin/users/${user.id}`,
value: `user ${user.id} ${user.name ?? ''} ${user.email}`,
});
const findUsersById = async (id: number) => {
const user = await prisma.user.findFirst({
where: { id },
select: userSelect,
});
return user ? [mapUser(user)] : [];
};
const findUsersByText = async (query: string) => {
const users = await prisma.user.findMany({
where: {
OR: [{ name: { contains: query, mode: 'insensitive' } }, { email: { contains: query, mode: 'insensitive' } }],
},
orderBy: { id: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: userSelect,
});
return users.map(mapUser);
};
// ─── Organisations ────────────────────────────────────────────────────────────
const organisationSelect = {
id: true,
name: true,
owner: { select: { email: true } },
} as const;
type OrganisationRow = { id: string; name: string; owner: { email: string } };
const mapOrganisation = (organisation: OrganisationRow): AdminGlobalSearchResult => ({
label: organisation.name,
sublabel: joinSublabel([organisation.id, organisation.owner.email]),
path: `/admin/organisations/${organisation.id}`,
value: `organisation ${organisation.id} ${organisation.name} ${organisation.owner.email}`,
});
const findOrganisationsByIdOrUrl = async (query: string) => {
const organisations = await prisma.organisation.findMany({
where: {
OR: [{ id: query }, { url: query }],
},
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: organisationSelect,
});
return organisations.map(mapOrganisation);
};
const findOrganisationsByText = async (query: string) => {
const organisations = await prisma.organisation.findMany({
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ url: { contains: query, mode: 'insensitive' } },
{ customerId: { contains: query, mode: 'insensitive' } },
{ owner: { email: { contains: query, mode: 'insensitive' } } },
],
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: organisationSelect,
});
return organisations.map(mapOrganisation);
};
// ─── Teams ────────────────────────────────────────────────────────────────────
const teamSelect = {
id: true,
name: true,
url: true,
organisation: { select: { name: true } },
} as const;
type TeamRow = { id: number; name: string; url: string; organisation: { name: string } };
const mapTeam = (team: TeamRow): AdminGlobalSearchResult => ({
label: team.name,
sublabel: joinSublabel([`#${team.id}`, `/${team.url}`, team.organisation.name]),
path: `/admin/teams/${team.id}`,
value: `team ${team.id} ${team.name} ${team.url} ${team.organisation.name}`,
});
const findTeamsById = async (id: number) => {
const team = await prisma.team.findFirst({
where: { id },
select: teamSelect,
});
return team ? [mapTeam(team)] : [];
};
const findTeamsByText = async (query: string) => {
const teams = await prisma.team.findMany({
where: {
OR: [{ name: { contains: query, mode: 'insensitive' } }, { url: { contains: query, mode: 'insensitive' } }],
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: teamSelect,
});
return teams.map(mapTeam);
};
// ─── Recipients ───────────────────────────────────────────────────────────────
const recipientSelect = {
id: true,
name: true,
email: true,
envelope: { select: { id: true, title: true } },
} as const;
type RecipientRow = {
id: number;
name: string;
email: string;
envelope: { id: string; title: string };
};
const mapRecipient = (recipient: RecipientRow): AdminGlobalSearchResult => ({
label: recipient.email,
sublabel: joinSublabel([`#${recipient.id}`, recipient.name, recipient.envelope.title]),
path: `/admin/documents/${recipient.envelope.id}`,
value: `recipient ${recipient.id} ${recipient.name} ${recipient.email} ${recipient.envelope.title}`,
});
const findRecipientsById = async (id: number) => {
const recipient = await prisma.recipient.findFirst({
where: {
id,
envelope: { type: EnvelopeType.DOCUMENT },
},
select: recipientSelect,
});
return recipient ? [mapRecipient(recipient)] : [];
};
const findRecipientsByText = async (query: string) => {
const recipients = await prisma.recipient.findMany({
where: {
envelope: { type: EnvelopeType.DOCUMENT },
OR: [{ email: { contains: query, mode: 'insensitive' } }, { name: { contains: query, mode: 'insensitive' } }],
},
orderBy: { id: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: recipientSelect,
});
return recipients.map(mapRecipient);
};
// ─── Subscriptions ────────────────────────────────────────────────────────────
const subscriptionSelect = {
id: true,
status: true,
planId: true,
customerId: true,
organisationId: true,
} as const;
type SubscriptionRow = {
id: number;
status: string;
planId: string;
customerId: string;
organisationId: string;
};
const mapSubscription = (subscription: SubscriptionRow): AdminGlobalSearchResult => ({
label: `Subscription #${subscription.id}`,
sublabel: joinSublabel([subscription.status, subscription.planId]),
path: `/admin/organisations/${subscription.organisationId}`,
value: `subscription ${subscription.id} ${subscription.planId} ${subscription.customerId}`,
});
const findSubscriptionsById = async (id: number) => {
const subscription = await prisma.subscription.findFirst({
where: { id },
select: subscriptionSelect,
});
return subscription ? [mapSubscription(subscription)] : [];
};
const findSubscriptionsByText = async (query: string) => {
const subscriptions = await prisma.subscription.findMany({
where: {
OR: [
{ planId: { contains: query, mode: 'insensitive' } },
{ customerId: { contains: query, mode: 'insensitive' } },
],
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: subscriptionSelect,
});
return subscriptions.map(mapSubscription);
};
@@ -13,7 +13,7 @@ export const getDocumentStats = async () => {
},
});
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number> = {
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX' | 'EXPIRED'>, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Canvas, Image, Path2D } from '@documenso/skia-canvas';
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { Canvas, Image, Path2D } from 'skia-canvas';
// @ts-expect-error napi-rs/canvas satisfies the requirements
globalThis.Path2D = Path2D;
@@ -0,0 +1,26 @@
import { AppError, AppErrorCode } from '../../errors/app-error';
import { putFileServerSide } from '../../universal/upload/put-file.server';
import { optimiseBrandingLogo } from '../../utils/images/logo';
/**
* Validate, sanitise and store an uploaded branding logo. Returns the
* `JSON.stringify({ type, data })` reference persisted in the `brandingLogo`
* column (the same format the serving endpoints already expect).
*/
export const buildBrandingLogoData = async (file: File): Promise<string> => {
const buffer = Buffer.from(await file.arrayBuffer());
const optimised = await optimiseBrandingLogo(buffer).catch(() => {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'The branding logo must be a valid image file.',
});
});
const documentData = await putFileServerSide({
name: 'branding-logo.png',
type: 'image/png',
arrayBuffer: async () => Promise.resolve(optimised),
});
return JSON.stringify(documentData);
};
@@ -59,7 +59,7 @@ export const completeDocumentWithToken = async ({
nextSigner,
recipientOverride,
}: CompleteDocumentWithTokenOptions) => {
const envelope = await prisma.envelope.findFirstOrThrow({
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
recipients: {
@@ -78,24 +78,56 @@ export const completeDocumentWithToken = async ({
},
});
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (envelope.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${envelope.id} must be pending`);
// The most common cause is a stale signing page: the document was deleted,
// or the recipient was removed, after the link was opened. Surface a
// NOT_FOUND instead of leaking a Prisma P2025 as a 500.
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found for the provided signing token',
statusCode: 404,
});
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (envelope.recipients.length === 0) {
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Document ${envelope.id} has no recipient with the provided token`,
statusCode: 404,
});
}
const [recipient] = envelope.recipients;
assertRecipientNotExpired(recipient);
// A retried or duplicate completion request for an already signed
// recipient throws a code the router resolves idempotently. This must be
// checked before the envelope status guard since the envelope may have
// been completed and sealed by the recipient's original request.
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
throw new AppError(AppErrorCode.RECIPIENT_ALREADY_SIGNED, {
message: `Recipient ${recipient.id} has already signed`,
statusCode: 400,
});
}
if (envelope.status !== DocumentStatus.PENDING) {
const envelopeStatusErrorCode: Record<DocumentStatus, AppErrorCode> = {
[DocumentStatus.DRAFT]: AppErrorCode.ENVELOPE_DRAFT,
[DocumentStatus.COMPLETED]: AppErrorCode.ENVELOPE_COMPLETED,
[DocumentStatus.REJECTED]: AppErrorCode.ENVELOPE_REJECTED,
[DocumentStatus.CANCELLED]: AppErrorCode.ENVELOPE_CANCELLED,
// Unreachable: guarded by the status check above.
[DocumentStatus.PENDING]: AppErrorCode.INVALID_REQUEST,
};
throw new AppError(envelopeStatusErrorCode[envelope.status], {
message: `Document ${envelope.id} must be pending to be completed, found ${envelope.status}`,
statusCode: 400,
});
}
assertRecipientNotExpired(recipient);
if (recipient.signingStatus === SigningStatus.REJECTED) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Recipient has already rejected the document',
@@ -109,7 +141,10 @@ export const completeDocumentWithToken = async ({
});
if (!isRecipientsTurn) {
throw new Error(`Recipient ${recipient.id} attempted to complete the document before it was their turn`);
throw new AppError(AppErrorCode.RECIPIENT_OUT_OF_TURN, {
message: `Recipient ${recipient.id} attempted to complete the document before it was their turn`,
statusCode: 400,
});
}
}
@@ -272,13 +307,22 @@ export const completeDocumentWithToken = async ({
}
if (fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
throw new AppError(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, {
message: `Recipient ${recipient.id} has unsigned fields`,
statusCode: 400,
});
}
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
// Conditional update so two concurrent completion requests can't both
// proceed: only the request that transitions the recipient to SIGNED
// continues, the loser sees a count of 0 and aborts.
const { count: updatedRecipientCount } = await tx.recipient.updateMany({
where: {
id: recipient.id,
signingStatus: {
not: SigningStatus.SIGNED,
},
},
data: {
signingStatus: SigningStatus.SIGNED,
@@ -288,6 +332,16 @@ export const completeDocumentWithToken = async ({
},
});
// A concurrent request completed the recipient between our initial read
// and this transaction. Abort so the winning request handles all side
// effects, the router resolves this code idempotently.
if (updatedRecipientCount === 0) {
throw new AppError(AppErrorCode.RECIPIENT_ALREADY_SIGNED, {
message: `Recipient ${recipient.id} has already signed`,
statusCode: 400,
});
}
if (recipientEmail !== recipient.email || recipientName !== recipient.name) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
@@ -16,6 +16,7 @@ import { match } from 'ts-pattern';
import type { FindResultResponse } from '../../types/search-params';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
import { hasExpiredRecipient } from '../envelope/query-helpers';
import { getTeamById } from '../team/get-team';
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
@@ -36,6 +37,11 @@ export type FindDocumentsOptions = {
senderIds?: number[];
query?: string;
folderId?: string;
/**
* When true, restrict results to envelopes with at least one recipient whose signing
* link has expired. Orthogonal to `status` — applied additively.
*/
hasExpiredRecipients?: boolean;
/**
* When true (default), use a windowed count that caps early for faster pagination.
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
@@ -115,6 +121,7 @@ export const findDocuments = async ({
senderIds,
query = '',
folderId,
hasExpiredRecipients,
useWindowedCount = true,
}: FindDocumentsOptions) => {
const user = await prisma.user.findFirstOrThrow({
@@ -199,6 +206,11 @@ export const findDocuments = async ({
);
}
// Expired recipient filter (orthogonal to status, additive)
if (hasExpiredRecipients) {
qb = qb.where((eb) => hasExpiredRecipient(eb));
}
return qb;
};
@@ -305,6 +317,15 @@ export const findDocuments = async ({
]),
),
)
.with(ExtendedDocumentStatus.EXPIRED, () =>
qb.where((eb) =>
eb.and([
personalDeletedFilter(eb),
hasExpiredRecipient(eb),
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
]),
),
)
.exhaustive();
};
@@ -455,6 +476,18 @@ export const findDocuments = async ({
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
}),
)
.with(ExtendedDocumentStatus.EXPIRED, () =>
qb.where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), hasExpiredRecipient(eb), eb.or(accessBranches)]);
}),
)
.exhaustive();
};
+18 -1
View File
@@ -8,6 +8,7 @@ import { DateTime } from 'luxon';
import { STATS_COUNT_CAP } from '../../constants/document';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { hasExpiredRecipient } from '../envelope/query-helpers';
import { getTeamById } from '../team/get-team';
// Kysely query builder type for Envelope queries.
@@ -253,6 +254,19 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// EXPIRED: docs visible to the team/user with at least one expired, unsigned recipient.
// Access control mirrors the EXPIRED branch in findDocuments so the count matches the listing.
const expiredQuery = buildBaseQuery().where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), hasExpiredRecipient(eb), eb.or(accessBranches)]);
});
// INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient
// Returns 0 if the team has no team email.
const inboxQuery = teamEmail
@@ -274,15 +288,17 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
// ─── Execute all counts in parallel ──────────────────────────────────
const [draft, pending, completed, rejected, cancelled, inbox] = await Promise.all([
const [draft, pending, completed, rejected, cancelled, expired, inbox] = await Promise.all([
cappedCount(draftQuery),
cappedCount(pendingQuery),
cappedCount(completedQuery),
cappedCount(rejectedQuery),
cappedCount(cancelledQuery),
cappedCount(expiredQuery),
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
]);
// `expired` is intentionally excluded from `all` — it overlaps PENDING.
const all = Math.min(draft + pending + completed + rejected + cancelled + inbox, STATS_COUNT_CAP);
const stats: Record<ExtendedDocumentStatus, number> = {
@@ -291,6 +307,7 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
[ExtendedDocumentStatus.COMPLETED]: completed,
[ExtendedDocumentStatus.REJECTED]: rejected,
[ExtendedDocumentStatus.CANCELLED]: cancelled,
[ExtendedDocumentStatus.EXPIRED]: expired,
[ExtendedDocumentStatus.INBOX]: inbox,
[ExtendedDocumentStatus.ALL]: all,
};
@@ -194,7 +194,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
.join(', ');
throw new AppError(AppErrorCode.INVALID_REQUEST, {
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
});
}
@@ -7,6 +7,7 @@ import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { FindResultResponse } from '../../types/search-params';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
import { getTeamById } from '../team/get-team';
import { hasExpiredRecipient } from './query-helpers';
export type FindEnvelopesOptions = {
userId: number;
@@ -23,6 +24,11 @@ export type FindEnvelopesOptions = {
};
query?: string;
folderId?: string;
/**
* When true, restrict results to envelopes with at least one recipient whose signing
* link has expired. Orthogonal to `status` — applied additively.
*/
hasExpiredRecipients?: boolean;
/**
* When true (default), use a windowed count that caps early for faster pagination.
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
@@ -106,6 +112,7 @@ export const findEnvelopes = async ({
orderBy,
query = '',
folderId,
hasExpiredRecipients,
useWindowedCount = true,
}: FindEnvelopesOptions) => {
const user = await prisma.user.findFirstOrThrow({
@@ -182,6 +189,11 @@ export const findEnvelopes = async ({
);
}
// Expired recipient filter (orthogonal to status, additive)
if (hasExpiredRecipients) {
qb = qb.where((eb) => hasExpiredRecipient(eb));
}
// ─── Access control ──────────────────────────────────────────────────
//
// An envelope is visible if ANY of:
@@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { extractFieldAutoInsertValues } from '../document/send-document';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@@ -125,6 +126,17 @@ export const getEnvelopeForDirectTemplateSigning = async ({
});
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(
envelope.recipients,
envelope.recipients.flatMap((envelopeRecipient) => envelopeRecipient.fields),
);
if (recipientsWithMissingFields.length > 0) {
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
message: 'One or more signers on this direct template are missing a signature field',
});
}
const settings = await getTeamSettings({ teamId: envelope.teamId });
const sender = settings.includeSenderDetails
@@ -5,7 +5,7 @@ import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/Envelope
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { z } from 'zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
@@ -266,6 +266,11 @@ export const getEnvelopeForRecipientSigning = async ({
if (envelope.documentMeta.signingOrder === DocumentSigningOrder.SEQUENTIAL && currentRecipientIndex !== -1) {
for (let i = 0; i < currentRecipientIndex; i++) {
// CC recipients have no action to take, so they can never block the flow.
if (envelope.recipients[i].role === RecipientRole.CC) {
continue;
}
if (envelope.recipients[i].signingStatus !== SigningStatus.SIGNED) {
isRecipientsTurn = false;
break;
@@ -0,0 +1,27 @@
import { sql } from '@documenso/prisma';
import type { DB } from '@documenso/prisma/generated/types';
import { RecipientRole, SigningStatus } from '@prisma/client';
import type { ExpressionBuilder } from 'kysely';
// Expression builder type scoped to the Envelope table context.
type EnvelopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
/**
* Reusable EXISTS subquery: checks that the envelope has at least one recipient whose
* signing link has expired — `expiresAt` in the past, still unsigned, and not a CC.
*
* This is the single source of truth for the "expired recipient" predicate used by
* `findDocuments`, `findEnvelopes`, and `getStats`. It must stay in sync with
* `isRecipientExpired` (packages/lib/utils/recipients.ts).
*/
export const hasExpiredRecipient = (eb: EnvelopeExpressionBuilder) =>
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.expiresAt', 'is not', null)
.where('Recipient.expiresAt', '<=', new Date())
.where('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED))
.where('Recipient.role', '!=', sql.lit(RecipientRole.CC))
.select(sql.lit(1).as('one')),
);
@@ -2,8 +2,9 @@
* !: This is a workaround to fix the memory leak in the skia-canvas library.
* !: Internals are ported from the original `konva/skia-backend.js` file.
*/
import { Canvas, DOMMatrix, Image, Path2D } from '@documenso/skia-canvas';
import { Konva } from 'konva/lib/_CoreInternals';
import { Canvas, DOMMatrix, Image, Path2D } from 'skia-canvas';
// @ts-expect-error skia-canvas satisfies the requirements
global.DOMMatrix = DOMMatrix;
@@ -37,6 +38,6 @@ Konva.Util.createImageElement = () => {
return node as unknown as HTMLImageElement;
};
Konva._renderBackend = 'skia-canvas';
Konva._renderBackend = '@documenso/skia-canvas';
export default Konva;
@@ -1,7 +1,12 @@
import {
assertMemberCountWithinCap,
syncMemberCountWithStripeSeatPlan,
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { prisma } from '@documenso/prisma';
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
import { OrganisationGroupType, OrganisationMemberInviteStatus, SubscriptionStatus } from '@prisma/client';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { generateDatabaseId } from '../../universal/id';
@@ -22,6 +27,13 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
organisation: {
include: {
groups: true,
organisationClaim: true,
subscription: true,
members: {
select: {
id: true,
},
},
},
},
},
@@ -66,6 +78,35 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
return;
}
const newMemberCount = organisation.members.length + 1;
// Billing occurs when a user accepts an invite.
// Assert that the new member count is within the cap and sync the seat plan with Stripe.
if (IS_BILLING_ENABLED()) {
const { subscription, organisationClaim } = organisation;
// A canceled subscription cannot have its seat quantity updated in Stripe,
// and an organisation with lapsed billing should not gain new members.
// Throw a deliberate error so the invite page can render an accurate
// message instead of an opaque Stripe failure.
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
throw new AppError('SUBSCRIPTION_INACTIVE', {
message: 'The organisation subscription is inactive',
});
}
// Organisations can exist without a subscription (e.g. after being
// downgraded to the free plan). The claim cap remains authoritative in
// that case, surfacing LIMIT_EXCEEDED instead of an opaque "subscription
// not found" error.
await assertMemberCountWithinCap(subscription, organisationClaim, newMemberCount);
if (subscription) {
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount, 'grow');
}
}
// Todo: Logging
await addUserToOrganisation({
userId: user.id,
organisationId: organisation.id,
@@ -1,7 +1,3 @@
import {
assertMemberCountWithinCap,
syncMemberCountWithStripeSeatPlan,
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
@@ -17,7 +13,6 @@ import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { generateDatabaseId } from '../../universal/id';
import { validateIfSubscriptionIsRequired } from '../../utils/billing';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
@@ -62,8 +57,6 @@ export const createOrganisationMemberInvites = async ({
},
},
organisationGlobalSettings: true,
organisationClaim: true,
subscription: true,
},
});
@@ -71,10 +64,6 @@ export const createOrganisationMemberInvites = async ({
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { organisationClaim } = organisation;
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const currentOrganisationMemberRole = await getMemberOrganisationRole({
organisationId: organisation.id,
reference: {
@@ -120,19 +109,6 @@ export const createOrganisationMemberInvites = async ({
}),
);
const numberOfCurrentMembers = organisation.members.length;
const numberOfCurrentInvites = organisation.invites.length;
const numberOfNewInvites = organisationMemberInvites.length;
const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites;
// Enforce the seat cap and sync billing for seat based plans.
if (subscription) {
await assertMemberCountWithinCap(subscription, organisationClaim, totalMemberCountWithInvites);
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, totalMemberCountWithInvites);
}
await prisma.organisationMemberInvite.createMany({
data: organisationMemberInvites,
});
+1 -1
View File
@@ -1,8 +1,8 @@
import path from 'node:path';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FontLibrary } from '@documenso/skia-canvas';
import type { Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { FontLibrary } from 'skia-canvas';
import { match } from 'ts-pattern';
/**
@@ -2,8 +2,8 @@
import '../konva/skia-backend';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import type { Canvas } from '@documenso/skia-canvas';
import Konva from 'konva';
import type { Canvas } from 'skia-canvas';
import { renderField } from '../../universal/field-renderer/render-field';
import { ensureFontLibrary } from './helpers';
@@ -1,14 +1,16 @@
// sort-imports-ignore
import '../konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from '@documenso/skia-canvas';
import { Image as SkiaImage } from '@documenso/skia-canvas';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta, Envelope, RecipientRole } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import type { Canvas } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { match, P } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
@@ -1,14 +1,16 @@
// sort-imports-ignore
import '../konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from '@documenso/skia-canvas';
import { Image as SkiaImage } from '@documenso/skia-canvas';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { Field, RecipientRole, Signature } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import { DateTime } from 'luxon';
import type { Canvas } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { UAParser } from 'ua-parser-js';
import { renderSVG } from 'uqr';
@@ -1,4 +1,4 @@
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
export type QuotaFlags = {
isDocumentQuotaExceeded: boolean;
@@ -22,39 +22,6 @@ type ComputeQuotaFlagsOptions = {
};
};
/**
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
*/
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* A counter is "nearing" its quota once usage reaches the warning threshold
* (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
* exceeded are mutually exclusive per counter.
*/
const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
return {
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
@@ -1,4 +1,4 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
import { getQuotaWarningCount } from '../../universal/quota-usage';
export type QuotaAlertKind = 'quota' | 'quotaNearing';
@@ -32,7 +32,7 @@ export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKin
// From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
// warning threshold equals the quota itself, the warning can never fire — the
// exhausting request is handled by the quota branch above.
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
const warningCount = getQuotaWarningCount(quota);
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
@@ -84,13 +84,13 @@ export const syncSubscriptionRateLimit = createRateLimit({
export const apiV1RateLimit = createRateLimit({
action: 'api.v1',
max: 100,
max: 1000,
window: '1m',
});
export const apiV2RateLimit = createRateLimit({
action: 'api.v2',
max: 100,
max: 1000,
window: '1m',
});
@@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma';
import { DocumentSigningOrder, EnvelopeType, SigningStatus } from '@prisma/client';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
export type GetIsRecipientTurnOptions = {
token: string;
@@ -38,6 +38,11 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
}
for (let i = 0; i < currentRecipientIndex; i++) {
// CC recipients have no action to take, so they can never block the flow.
if (recipients[i].role === RecipientRole.CC) {
continue;
}
if (recipients[i].signingStatus !== SigningStatus.SIGNED) {
return false;
}
@@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { mapDocumentIdToSecondaryId } from '../../utils/envelope';
@@ -16,6 +16,11 @@ export const getNextPendingRecipient = async ({
type: EnvelopeType.DOCUMENT,
secondaryId: mapDocumentIdToSecondaryId(documentId),
},
// CC recipients are informational only and never take part in signing,
// so they must never be offered as the next pending recipient.
role: {
not: RecipientRole.CC,
},
},
orderBy: [
{
@@ -1,37 +0,0 @@
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type GetTeamEmailByEmailOptions = {
email: string;
};
export const getTeamEmailByEmail = async ({ email }: GetTeamEmailByEmailOptions) => {
return await prisma.teamEmail.findFirst({
where: {
email,
},
include: {
team: {
select: {
id: true,
name: true,
url: true,
},
},
},
});
};
export const getTeamWithEmail = async ({ userId, teamUrl }: { userId: number; teamUrl: string }) => {
return await prisma.team.findFirstOrThrow({
where: {
...buildTeamWhereQuery({ teamId: undefined, userId }),
url: teamUrl,
},
include: {
teamEmail: true,
emailVerification: true,
},
});
};
+2 -2
View File
@@ -15,12 +15,12 @@ export type GetTeamsOptions = {
};
export const ZGetTeamsResponseSchema = TeamSchema.extend({
teamRole: z.nativeEnum(TeamMemberRole),
currentTeamRole: z.nativeEnum(TeamMemberRole),
}).array();
export type TGetTeamsResponse = z.infer<typeof ZGetTeamsResponseSchema>;
export const getTeams = async ({ userId, teamId }: GetTeamsOptions) => {
export const getTeams = async ({ userId, teamId }: GetTeamsOptions): Promise<TGetTeamsResponse> => {
const teams = await prisma.team.findMany({
where: buildTeamWhereQuery({ teamId, userId }),
include: {
@@ -40,6 +40,7 @@ import {
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { incrementDocumentId } from '../envelope/increment-id';
@@ -172,6 +173,17 @@ export const createDocumentFromDirectTemplate = async ({
});
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(
recipients,
recipients.flatMap((recipient) => recipient.fields),
);
if (recipientsWithMissingFields.length > 0) {
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
message: 'One or more signers on this direct template are missing a signature field',
});
}
if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
}
+21 -1
View File
@@ -1,6 +1,7 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { deleteOrganisation } from '../organisation/delete-organisation';
export type DeleteUserOptions = {
@@ -59,6 +60,13 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
})),
);
// Organisations the user is a member of (but not owner). Owned organisations
// are fully torn down below (including subscription cancellation), so only
// these need a seat sync after the user's memberships cascade away.
const memberOrganisationIds = user.organisationMember
.filter((member) => member.organisation.ownerUserId !== user.id)
.map((member) => member.organisationId);
// For organisations the user owns - fully tear them down (orphan envelopes,
// delete the organisation, and cancel any Stripe subscription). Without this
// the organisations would only cascade away when the user row is deleted,
@@ -82,9 +90,21 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
}),
);
return await prisma.user.delete({
const deletedUser = await prisma.user.delete({
where: {
id: user.id,
},
});
// The user's memberships were cascade-deleted with the user row — queue a
// seat sync for each organisation they belonged to so the Stripe quantity
// trues down to the new member count (no proration, no credit).
for (const organisationId of memberOrganisationIds) {
await jobs.triggerJob({
name: 'internal.sync-organisation-seats',
payload: { organisationId },
});
}
return deletedUser;
};
@@ -3,6 +3,7 @@ import { DateTime } from 'luxon';
import { EMAIL_VERIFICATION_STATE, USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
import { jobsClient } from '../../jobs/client';
import { getMostRecentEmailVerificationToken } from './get-most-recent-email-verification-token';
export type VerifyEmailProps = {
token: string;
@@ -36,13 +37,8 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
const valid = verificationToken.expires > new Date();
if (!valid) {
const mostRecentToken = await prisma.verificationToken.findFirst({
where: {
userId: verificationToken.userId,
},
orderBy: {
createdAt: 'desc',
},
const mostRecentToken = await getMostRecentEmailVerificationToken({
userId: verificationToken.userId,
});
// If there isn't a recent token or it's older than 1 hour, send a new token
@@ -80,6 +76,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
prisma.verificationToken.updateMany({
where: {
userId: verificationToken.userId,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
data: {
completed: true,
@@ -89,6 +86,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
prisma.verificationToken.deleteMany({
where: {
userId: verificationToken.userId,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
expires: {
lt: new Date(),
},
@@ -1,24 +1,26 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
export const getWebhooksByTeamId = async (teamId: number, userId: number) => {
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
return await prisma.webhook.findMany({
where: {
team: {
id: teamId,
teamGroups: {
some: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
teamId,
},
orderBy: {
createdAt: 'desc',