+
+
+
+ Unsealed Documents
+
+
+
+
+
+ Documents where all recipients have signed but the document has not been sealed. Documents
+ stuck for more than 6 hours are no longer retried by the sweep job.
+
+
+
+
+
+ {(table) => }
+
+
+ {isLoading && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/package.json b/package.json
index 4d4956bad..62550e78f 100644
--- a/package.json
+++ b/package.json
@@ -9,8 +9,8 @@
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
- "dev": "turbo run dev --filter=@documenso/remix",
- "dev:remix": "turbo run dev --filter=@documenso/remix",
+ "dev": "npm run translate:compile && turbo run dev --filter=@documenso/remix",
+ "dev:remix": "npm run translate:compile && turbo run dev --filter=@documenso/remix",
"dev:docs": "turbo run dev --filter=@documenso/documentation",
"dev:openpage-api": "turbo run dev --filter=@documenso/openpage-api",
"start": "turbo run start --filter=@documenso/remix --filter=@documenso/documentation --filter=@documenso/openpage-api",
diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts
index af6241320..33ea6ee52 100644
--- a/packages/lib/jobs/client.ts
+++ b/packages/lib/jobs/client.ts
@@ -16,6 +16,7 @@ import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-w
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
import { PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION } from './definitions/internal/process-recipient-expired';
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
+import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
/**
@@ -29,6 +30,7 @@ export const jobsClient = new JobClient([
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
+ SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
diff --git a/packages/lib/jobs/definitions/internal/seal-document-sweep.handler.ts b/packages/lib/jobs/definitions/internal/seal-document-sweep.handler.ts
new file mode 100644
index 000000000..a1d8dc527
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/seal-document-sweep.handler.ts
@@ -0,0 +1,105 @@
+import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
+import { DateTime } from 'luxon';
+
+import { kyselyPrisma, sql } from '@documenso/prisma';
+
+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,
+ },
+ });
+ }),
+ );
+};
diff --git a/packages/lib/jobs/definitions/internal/seal-document-sweep.ts b/packages/lib/jobs/definitions/internal/seal-document-sweep.ts
new file mode 100644
index 000000000..a8798a134
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/seal-document-sweep.ts
@@ -0,0 +1,30 @@
+import { z } from 'zod';
+
+import { type JobDefinition } from '../../client/_internal/job';
+
+const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID = 'internal.seal-document-sweep';
+
+const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA = z.object({});
+
+export type TSealDocumentSweepJobDefinition = z.infer<
+ typeof SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA
+>;
+
+export const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION = {
+ id: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID,
+ name: 'Seal Document Sweep',
+ version: '1.0.0',
+ trigger: {
+ name: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID,
+ schema: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA,
+ cron: '*/15 * * * *', // Every 15 minutes.
+ },
+ handler: async ({ payload, io }) => {
+ const handler = await import('./seal-document-sweep.handler');
+
+ await handler.run({ payload, io });
+ },
+} as const satisfies JobDefinition<
+ typeof SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID,
+ TSealDocumentSweepJobDefinition
+>;
diff --git a/packages/lib/server-only/admin/admin-find-unsealed-documents.ts b/packages/lib/server-only/admin/admin-find-unsealed-documents.ts
new file mode 100644
index 000000000..41eefc9e8
--- /dev/null
+++ b/packages/lib/server-only/admin/admin-find-unsealed-documents.ts
@@ -0,0 +1,102 @@
+import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
+
+import { kyselyPrisma, sql } from '@documenso/prisma';
+
+import type { FindResultResponse } from '../../types/search-params';
+
+export type AdminUnsealedDocument = {
+ id: string;
+ secondaryId: string;
+ title: string;
+ status: string;
+ createdAt: Date;
+ updatedAt: Date;
+ userId: number;
+ teamId: number;
+ ownerName: string | null;
+ ownerEmail: string;
+ lastSignedAt: Date | null;
+};
+
+export type AdminFindUnsealedDocumentsOptions = {
+ page?: number;
+ perPage?: number;
+};
+
+export const adminFindUnsealedDocuments = async ({
+ page = 1,
+ perPage = 20,
+}: AdminFindUnsealedDocumentsOptions): Promise