Files
documenso/packages/lib/jobs/definitions/internal/seal-document-sweep.handler.ts
T
2026-05-08 16:04:22 +10:00

103 lines
3.9 KiB
TypeScript

import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { mapSecondaryIdToDocumentId } from '../../../utils/envelope';
import { jobs } from '../../client';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSealDocumentSweepJobDefinition } from './seal-document-sweep';
export const run = async ({ io }: { payload: TSealDocumentSweepJobDefinition; io: JobRunIO }) => {
const now = DateTime.now();
const fifteenMinutesAgo = now.minus({ minutes: 15 }).toJSDate();
const sixHoursAgo = now.minus({ hours: 6 }).toJSDate();
// Find all PENDING envelopes that should have been sealed but weren't.
//
// A document is ready to seal when either:
// 1. All recipients are SIGNED or have role CC (normal completion)
// 2. Any recipient has REJECTED (rejection triggers immediate seal)
//
// We only look at documents where the last action was between 15 minutes
// and 6 hours ago. The lower bound avoids racing with the normal seal-document
// job that fires on completion. The upper bound stops us from endlessly retrying
// documents that are stuck due to a deeper issue (e.g. corrupt PDF).
const unsealedEnvelopes = await kyselyPrisma.$kysely
.selectFrom('Envelope')
.select(['Envelope.id', 'Envelope.secondaryId'])
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.where('Envelope.deletedAt', 'is', null)
// Ensure there is at least one recipient.
.where((eb) => eb.exists(eb.selectFrom('Recipient').whereRef('Recipient.envelopeId', '=', 'Envelope.id')))
// Document is ready to seal: all recipients are SIGNED/CC, or any recipient REJECTED.
.where((eb) =>
eb.or([
// Case 1: All recipients are either SIGNED or CC.
eb.not(
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.signingStatus', '!=', sql.lit(SigningStatus.SIGNED))
.where('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
),
),
// Case 2: Any recipient has rejected.
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
),
]),
)
// Exclude envelopes where a recipient signed/rejected within the last 15 minutes
// to avoid racing with the standard completion flow.
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.signedAt', '>', fifteenMinutesAgo),
),
),
)
// Exclude envelopes where all activity is older than 6 hours.
// These are likely stuck due to a deeper issue and should not be retried.
.where((eb) =>
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.signedAt', '>', sixHoursAgo),
),
)
.limit(100)
.execute();
if (unsealedEnvelopes.length === 0) {
io.logger.info('No unsealed documents found');
return;
}
io.logger.info(`Found ${unsealedEnvelopes.length} unsealed documents`);
await Promise.allSettled(
unsealedEnvelopes.map(async (envelope) => {
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
io.logger.info(`Triggering seal for document ${documentId} (${envelope.id})`);
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId,
isResealing: true,
},
});
}),
);
};