Merge remote-tracking branch 'origin/main' into spike/react-19

This commit is contained in:
Lucas Smith
2026-07-24 17:09:36 +10:00
111 changed files with 5175 additions and 800 deletions
@@ -7,9 +7,10 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { useId } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
import { z } from 'zod';
import { isCcRecipient, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from '../../utils/recipients';
const LocalRecipientSchema = z.object({
formId: z.string().min(1),
id: z.number().optional(),
@@ -94,13 +95,13 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder ?? index + 1,
signingOrder: isCcRecipient(recipient) ? undefined : (recipient.signingOrder ?? index + 1),
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
}));
const signers: TLocalRecipient[] =
formRecipients.length > 0
? sortBy(formRecipients, [prop('signingOrder'), 'asc'], [prop('id'), 'asc'])
? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients))
: [
{
formId: initialId,
+9
View File
@@ -36,6 +36,13 @@ export enum AppErrorCode {
*/
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
/**
* A signer recipient does not have a signature field assigned. Thrown when
* distributing an envelope or using a direct template where at least one
* signer has no signature field.
*/
MISSING_SIGNATURE_FIELD = 'MISSING_SIGNATURE_FIELD',
/**
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
* for the recovery taxonomy.
@@ -84,6 +91,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
@@ -291,6 +299,7 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_CANCELLED,
AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.MISSING_SIGNATURE_FIELD,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
+4
View File
@@ -17,6 +17,7 @@ import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emai
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation';
import { ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION } from './definitions/internal/alert-organisation-seat-drift';
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
@@ -29,6 +30,7 @@ import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-docume
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
import { SYNC_ORGANISATION_SEATS_JOB_DEFINITION } from './definitions/internal/sync-organisation-seats';
/**
* The `as const` assertion is load bearing as it provides the correct level of type inference for
@@ -64,7 +66,9 @@ export const jobsClient = new JobClient([
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION,
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
SYNC_ORGANISATION_SEATS_JOB_DEFINITION,
] as const);
export const jobs = jobsClient;
@@ -0,0 +1,67 @@
import { mailer } from '@documenso/email/mailer';
import { prisma } from '@documenso/prisma';
import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '../../../constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
import type { JobRunIO } from '../../client/_internal/job';
import type { TAlertOrganisationSeatDriftJobDefinition } from './alert-organisation-seat-drift';
/**
* Daily check for organisations whose member count exceeds their paid seat
* count (`organisationClaim.memberCount`, where `0` means unlimited).
*/
export const run = async ({ io }: { payload: TAlertOrganisationSeatDriftJobDefinition; io: JobRunIO }) => {
if (!IS_BILLING_ENABLED()) {
return;
}
const organisations = await prisma.organisation.findMany({
where: {
// Exclude unlimited-seat plans (memberCount === 0).
organisationClaim: {
memberCount: {
not: 0,
},
},
},
select: {
id: true,
name: true,
organisationClaim: {
select: {
memberCount: true,
},
},
_count: {
select: {
members: true,
},
},
},
});
const driftedOrganisations = organisations.filter(
(organisation) =>
organisation.organisationClaim !== null &&
organisation._count.members > organisation.organisationClaim.memberCount,
);
if (driftedOrganisations.length === 0) {
io.logger.info('No organisations exceed their paid seat count');
return;
}
await mailer.sendMail({
to: SUPPORT_EMAIL,
from: DOCUMENSO_INTERNAL_EMAIL,
subject: `[Billing] ${driftedOrganisations.length} organisation(s) exceed their paid seat count`,
text: [
`${driftedOrganisations.length} organisation(s) have more members than their paid seat count:`,
'',
...driftedOrganisations.map(
(organisation) =>
`- ${organisation.name} (${organisation.id}): ${organisation._count.members} members vs ${organisation.organisationClaim?.memberCount ?? 0} paid seats`,
),
].join('\n'),
});
};
@@ -0,0 +1,30 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID = 'internal.alert-organisation-seat-drift';
const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA = z.object({});
export type TAlertOrganisationSeatDriftJobDefinition = z.infer<
typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA
>;
export const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION = {
id: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
name: 'Alert Organisation Seat Drift',
version: '1.0.0',
trigger: {
name: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
schema: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA,
cron: '0 0 * * *', // Once a day at midnight.
},
handler: async ({ payload, io }) => {
const handler = await import('./alert-organisation-seat-drift.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
TAlertOrganisationSeatDriftJobDefinition
>;
@@ -0,0 +1,54 @@
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@prisma/client';
import { IS_BILLING_ENABLED } from '../../../constants/app';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSyncOrganisationSeatsJobDefinition } from './sync-organisation-seats';
export const run = async ({ payload }: { payload: TSyncOrganisationSeatsJobDefinition; io: JobRunIO }) => {
const { organisationId } = payload;
if (!IS_BILLING_ENABLED()) {
return;
}
const organisation = await prisma.organisation.findUnique({
where: {
id: organisationId,
},
include: {
subscription: true,
organisationClaim: true,
},
});
if (!organisation || !organisation.subscription) {
return;
}
// Skip canceled/terminal subscriptions — Stripe rejects quantity updates on a
// canceled subscription. PAST_DUE is still live and a no-proration shrink is
// safe, so it's allowed through.
if (organisation.subscription.status === SubscriptionStatus.INACTIVE) {
return;
}
const memberCount = await prisma.organisationMember.count({
where: {
organisationId,
},
});
// An organisation always retains its owner; guarding zero avoids writing the
// unlimited sentinel to the claim.
if (memberCount === 0) {
return;
}
await syncMemberCountWithStripeSeatPlan(
organisation.subscription,
organisation.organisationClaim,
memberCount,
'shrink',
);
};
@@ -0,0 +1,29 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID = 'internal.sync-organisation-seats';
const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA = z.object({
organisationId: z.string(),
});
export type TSyncOrganisationSeatsJobDefinition = z.infer<typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA>;
export const SYNC_ORGANISATION_SEATS_JOB_DEFINITION = {
id: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
name: 'Sync Organisation Seats',
version: '1.0.0',
trigger: {
name: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
schema: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./sync-organisation-seats.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
TSyncOrganisationSeatsJobDefinition
>;
@@ -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);
};
@@ -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.`,
});
}
@@ -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;
@@ -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,
});
@@ -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: [
{
@@ -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;
};
+71
View File
@@ -0,0 +1,71 @@
import { RecipientRole } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { isAssistantLastSigner, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from './recipients';
describe('recipient signing order helpers', () => {
it('sorts CC recipients after ordered active recipients', () => {
const recipients = [
{ id: 1, role: RecipientRole.CC, signingOrder: 1 },
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 2 },
{ id: 3, role: RecipientRole.APPROVER, signingOrder: 1 },
];
expect(sortRecipientsForSigningOrder(recipients).map((recipient) => recipient.id)).toEqual([3, 2, 1]);
});
it('keeps original order when recipients have the same signing order', () => {
const recipients = [
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 1 },
{ id: 1, role: RecipientRole.APPROVER, signingOrder: 1 },
];
expect(sortRecipientsForSigningOrder(recipients).map((recipient) => recipient.id)).toEqual([2, 1]);
});
it('sorts and normalizes active recipient signing order and removes it from CC recipients', () => {
const recipients = [
{ id: 1, role: RecipientRole.CC, signingOrder: 1 },
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 4 },
{ id: 3, role: RecipientRole.APPROVER, signingOrder: 2 },
];
expect(normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(recipients))).toEqual([
{ id: 3, role: RecipientRole.APPROVER, signingOrder: 1 },
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 2 },
{ id: 1, role: RecipientRole.CC, signingOrder: undefined },
]);
});
it('preserves caller order while normalizing signing order', () => {
const recipients = [
{ id: 2, role: RecipientRole.ASSISTANT, signingOrder: 2 },
{ id: 1, role: RecipientRole.SIGNER, signingOrder: 1 },
{ id: 3, role: RecipientRole.CC, signingOrder: 1 },
];
expect(normalizeRecipientSigningOrders(recipients)).toEqual([
{ id: 2, role: RecipientRole.ASSISTANT, signingOrder: 1 },
{ id: 1, role: RecipientRole.SIGNER, signingOrder: 2 },
{ id: 3, role: RecipientRole.CC, signingOrder: undefined },
]);
});
it('checks whether the last non-CC recipient is an assistant', () => {
expect(
isAssistantLastSigner([
{ role: RecipientRole.SIGNER },
{ role: RecipientRole.ASSISTANT },
{ role: RecipientRole.CC },
]),
).toBe(true);
expect(
isAssistantLastSigner([
{ role: RecipientRole.ASSISTANT },
{ role: RecipientRole.SIGNER },
{ role: RecipientRole.CC },
]),
).toBe(false);
});
});
+54 -2
View File
@@ -1,6 +1,6 @@
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { Envelope } from '@prisma/client';
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
import type { Envelope, Field, Recipient } from '@prisma/client';
import { RecipientRole, SigningStatus } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { AppError, AppErrorCode } from '../errors/app-error';
@@ -15,6 +15,58 @@ import { zEmail } from './zod';
*/
export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as const;
// signingOrder isn't required when submitting the recipient form (Zod: z.number().optional())
type RecipientWithSigningOrder = Pick<Recipient, 'role'> & Partial<Pick<Recipient, 'signingOrder'>>;
export const isCcRecipient = (recipient: Pick<Recipient, 'role'>) => {
return recipient.role === RecipientRole.CC;
};
export const isAssistantLastSigner = (recipients: Pick<Recipient, 'role'>[]) => {
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
const lastNonCcRecipient = nonCcRecipients[nonCcRecipients.length - 1];
return lastNonCcRecipient?.role === RecipientRole.ASSISTANT;
};
export const sortRecipientsForSigningOrder = <T extends RecipientWithSigningOrder>(recipients: T[]): T[] => {
return [...recipients].sort((r1, r2) => {
const r1IsCcRecipient = isCcRecipient(r1);
const r2IsCcRecipient = isCcRecipient(r2);
// CC recipients always sort after non-CC recipients.
if (r1IsCcRecipient !== r2IsCcRecipient) {
return r1IsCcRecipient ? 1 : -1;
}
// Order by signing order; missing orders sort last.
const r1SigningOrder = r1.signingOrder ?? Number.MAX_SAFE_INTEGER;
const r2SigningOrder = r2.signingOrder ?? Number.MAX_SAFE_INTEGER;
return r1SigningOrder - r2SigningOrder;
});
};
export const normalizeRecipientSigningOrders = <T extends RecipientWithSigningOrder>(
recipients: T[],
canUpdateRecipient: (recipient: T) => boolean = () => true,
): Array<T & { signingOrder?: number }> => {
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
const ccRecipients = recipients.filter((recipient) => isCcRecipient(recipient));
const normalizedNonCcRecipients = nonCcRecipients.map((recipient, index) => ({
...recipient,
signingOrder: canUpdateRecipient(recipient) ? index + 1 : (recipient.signingOrder ?? index + 1),
}));
const normalizedCcRecipients = ccRecipients.map((recipient) => ({
...recipient,
signingOrder: undefined,
}));
return [...normalizedNonCcRecipients, ...normalizedCcRecipients];
};
/**
* Returns recipients who are missing required fields for their role.
*