mirror of
https://github.com/documenso/documenso.git
synced 2026-08-23 23:02:22 +10:00
Merge branch 'main' into feat/acroform-field-import
This commit is contained in:
@@ -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,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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user