Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/require-await -- stub; implementer must remove this and add real async/await */
import { createElement } from 'react';

import { msg } from '@lingui/core/macro';
import { msg, plural } from '@lingui/core/macro';
import { DocumentStatus, SigningStatus } from '@prisma/client';

import { mailer } from '@documenso/email/mailer';
import { SenderReminderDigestEmailTemplate } from '@documenso/email/templates/sender-reminder-digest';
Expand All @@ -23,51 +23,102 @@ export const run = async ({
payload: TSendOwnerReminderDigestEmailJobDefinition;
io: JobRunIO;
}) => {
const { teamId, envelopeIds } = payload;

// TODO(Person 2): Implement sender digest dispatch.
//
// Steps:
// 1. Fetch team with owner (user) and all envelopes from envelopeIds payload.
// Include documentMeta and pending recipient count per envelope.
//
// 2. Check ownerReminderDigest setting on first envelope's documentMeta:
// const isDigestEnabled = extractDerivedDocumentEmailSettings(documentMeta).ownerReminderDigest;
// if (!isDigestEnabled) return;
// Note: if the sender disabled digests, skip — don't send for any envelope in this team.
//
// 3. Build pendingDocuments array:
// pendingDocuments = envelopes.map(e => ({
// documentName: e.title,
// pendingRecipientCount: e.recipients.filter(r => r.signingStatus === 'NOT_SIGNED').length,
// daysRemaining: ..., // calculate from expiration period
// documentLink: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(team.url)}/${e.id}`,
// }));
//
// 4. getEmailContext + getI18nInstance + createElement(SenderReminderDigestEmailTemplate, {...})
// + renderEmailWithI18N + mailer.sendMail to team owner.
// Wrap in io.runTask('send-digest-email').
//
// 5. INSERT one DocumentReminderLog per envelope (recipientId = null = digest entry):
// await io.runTask('create-reminder-logs', async () => {
// await prisma.documentReminderLog.createMany({
// data: envelopeIds.map((eid) => ({ envelopeId: eid })),
// });
// });

void teamId;
void envelopeIds;
void createElement;
void msg;
void mailer;
void SenderReminderDigestEmailTemplate;
void prisma;
void getI18nInstance;
void NEXT_PUBLIC_WEBAPP_URL;
void getEmailContext;
void extractDerivedDocumentEmailSettings;
void renderEmailWithI18N;
void formatDocumentsPath;

io.logger.info(`send-owner-reminder-digest-email: not yet implemented (team ${teamId})`);
const { teamId, userId, envelopeIds } = payload;

const envelopes = await prisma.envelope.findMany({
where: { id: { in: envelopeIds }, teamId, userId, status: DocumentStatus.PENDING },
include: {
user: {
select: { id: true, email: true, name: true },
},
documentMeta: true,
team: {
select: { name: true, url: true },
},
recipients: {
select: { signingStatus: true, expiresAt: true },
},
},
});

if (envelopes.length === 0) {
return;
}

const firstEnvelope = envelopes[0];

const isDigestEnabled = extractDerivedDocumentEmailSettings(
firstEnvelope.documentMeta,
).ownerReminderDigest;

if (!isDigestEnabled) {
return;
}

const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: { type: 'team', teamId },
meta: firstEnvelope.documentMeta,
});

const i18n = await getI18nInstance(emailLanguage);
Comment on lines +28 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Wrap the read/context setup in io.runTask.

The initial Prisma read and the getEmailContext / getI18nInstance calls are still outside io.runTask, so a retry can observe a different state than the already-cached send/log tasks. Give these steps distinct task keys as well.

As per coding guidelines: "All job handler steps must be wrapped in io.runTask('unique-key', fn) for idempotency"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/lib/jobs/definitions/emails/send-owner-reminder-digest-email.handler.ts`
around lines 28 - 64, Wrap the initial database read and context lookups in
separate io.runTask calls to ensure idempotency: move the
prisma.envelope.findMany call into io.runTask('read-envelopes', ...) and
return/set envelopes, then run io.runTask('compute-digest-setting', ...) to call
extractDerivedDocumentEmailSettings(firstEnvelope.documentMeta) and compute
isDigestEnabled (return early if false inside that task), and run
io.runTask('get-email-context', ...) to call getEmailContext({ emailType:
'INTERNAL', source: { type: 'team', teamId }, meta: firstEnvelope.documentMeta
}) to obtain branding, emailLanguage, senderEmail, plus io.runTask('get-i18n',
...) to call getI18nInstance(emailLanguage) and set i18n; ensure you use the
same variable names (envelopes, firstEnvelope, isDigestEnabled, branding,
emailLanguage, senderEmail, i18n) so downstream code remains unchanged.


const pendingDocuments = envelopes.map((envelope) => {
const pendingRecipients = envelope.recipients.filter(
(r) => r.signingStatus === SigningStatus.NOT_SIGNED,
);

const earliestExpiry = pendingRecipients
.map((r) => r.expiresAt)
.filter((d): d is Date => d !== null)
.sort((a, b) => a.getTime() - b.getTime())[0];

const daysRemaining = earliestExpiry
? Math.max(0, Math.ceil((earliestExpiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
: null;

return {
documentName: envelope.title,
pendingRecipientCount: pendingRecipients.length,
daysRemaining,
documentLink: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team.url)}/${envelope.id}`,
};
});

const owner = firstEnvelope.user;
const teamName = firstEnvelope.team.name;
const count = envelopes.length;

const template = createElement(SenderReminderDigestEmailTemplate, {
ownerName: owner.name || owner.email,
teamName,
pendingDocuments,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});

await io.runTask('send-digest-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);

await mailer.sendMail({
to: {
name: owner.name || '',
address: owner.email,
},
from: senderEmail,
subject: i18n._(
msg`Reminder: ${plural(count, { one: '# document', other: '# documents' })} awaiting signatures in "${teamName}"`,
),
html,
text,
});
});

await io.runTask('create-reminder-logs', async () => {
await prisma.documentReminderLog.createMany({
data: envelopeIds.map((eid) => ({ envelopeId: eid })),
});
});
Comment on lines +119 to +123
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Create reminder logs only for envelopes included in the digest.

The createMany uses envelopeIds from the payload, but the query at line 28-42 filters envelopes by status: DocumentStatus.PENDING. If an envelope's status changed or it was deleted between the sweep and handler execution, this creates logs for documents not in the digest or causes a foreign key violation.

Use the queried envelopes to ensure logs are only created for documents actually included in the email.

🐛 Proposed fix
   await io.runTask('create-reminder-logs', async () => {
     await prisma.documentReminderLog.createMany({
-      data: envelopeIds.map((eid) => ({ envelopeId: eid })),
+      data: envelopes.map((envelope) => ({ envelopeId: envelope.id })),
     });
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await io.runTask('create-reminder-logs', async () => {
await prisma.documentReminderLog.createMany({
data: envelopeIds.map((eid) => ({ envelopeId: eid })),
});
});
await io.runTask('create-reminder-logs', async () => {
await prisma.documentReminderLog.createMany({
data: envelopes.map((envelope) => ({ envelopeId: envelope.id })),
});
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/lib/jobs/definitions/emails/send-owner-reminder-digest-email.handler.ts`
around lines 119 - 123, The createMany currently uses envelopeIds from the
payload which can include envelopes that were filtered out earlier; change the
log creation to use the actual queried envelopes collection (the envelopes
variable returned by the query) so only documents included in the digest get
logs. Inside the io.runTask('create-reminder-logs', ...) block, replace
prisma.documentReminderLog.createMany's data mapping from envelopeIds.map(...)
to envelopes.map(e => ({ envelopeId: e.id })) (or equivalent) and handle the
case of an empty envelopes array before calling createMany to avoid unnecessary
DB calls or FK errors.

};
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/require-await -- stub; implementer must remove this and add real async/await */
import { createElement } from 'react';

import { msg } from '@lingui/core/macro';
import { DocumentStatus, SigningStatus } from '@prisma/client';

import { mailer } from '@documenso/email/mailer';
import { DocumentReminderEmailTemplate } from '@documenso/email/templates/document-reminder';
Expand All @@ -11,7 +11,6 @@ import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
Expand All @@ -26,62 +25,97 @@ export const run = async ({
}) => {
const { recipientId, envelopeId } = payload;

// TODO(Person 2): Implement recipient reminder dispatch.
//
// Steps:
// 1. Fetch envelope (with documentMeta + team) and recipient. Pattern:
// send-owner-recipient-expired-email.handler.ts lines 27–62
//
// 2. Return early if envelope is no longer PENDING or recipient has signed.
//
// 3. Check email settings (reminderEnabled is sufficient — per-recipient reminders
// always fire when the sweep decides they're due):
// const settings = extractDerivedDocumentEmailSettings(documentMeta);
// No specific toggle blocks recipient reminders — the sweep's enabled check is sufficient.
//
// 4. Calculate daysRemaining from envelope expiration date (documentMeta.envelopeExpirationPeriod
// or envelope-level expiry). If no expiration is set, omit from email copy.
//
// 5. getEmailContext + getI18nInstance (same pattern as every email handler).
//
// 6. Build signing link:
// const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
//
// 7. createElement(DocumentReminderEmailTemplate, { ... }) + renderEmailWithI18N + mailer.sendMail
// Wrap in io.runTask('send-reminder-email').
//
// 8. INSERT DocumentReminderLog row (recipientId = recipient.id):
// await io.runTask('create-reminder-log', async () => {
// await prisma.documentReminderLog.create({
// data: { envelopeId, recipientId: recipient.id },
// });
// });
//
// 9. INSERT DocumentAuditLog (type: REMINDER_SENT):
// await io.runTask('create-audit-log', async () => {
// await prisma.documentAuditLog.create({
// data: createDocumentAuditLogData({
// type: DOCUMENT_AUDIT_LOG_TYPE.REMINDER_SENT,
// envelopeId,
// data: { recipientId, recipientEmail: recipient.email, recipientName: recipient.name },
// }),
// });
// });

void recipientId;
void envelopeId;
void createElement;
void msg;
void mailer;
void DocumentReminderEmailTemplate;
void prisma;
void getI18nInstance;
void NEXT_PUBLIC_WEBAPP_URL;
void getEmailContext;
void extractDerivedDocumentEmailSettings;
void DOCUMENT_AUDIT_LOG_TYPE;
void createDocumentAuditLogData;
void renderEmailWithI18N;

io.logger.info(`send-recipient-reminder-email: not yet implemented (recipient ${recipientId})`);
const envelope = await prisma.envelope.findFirst({
where: { id: envelopeId },
include: {
user: {
select: { id: true, email: true, name: true },
},
documentMeta: true,
team: {
select: { teamEmail: true, name: true, url: true },
},
},
});

if (!envelope) {
throw new Error(`Envelope ${envelopeId} not found`);
}

if (envelope.status !== DocumentStatus.PENDING) {
return;
}

const recipient = await prisma.recipient.findFirst({
where: { id: recipientId, envelopeId },
});

if (!recipient) {
throw new Error(`Recipient ${recipientId} not found on envelope ${envelopeId}`);
Comment on lines +41 to +54
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use AppError for these not-found branches.

Both branches throw plain Error, which bypasses the repo’s typed error-handling contract for jobs. Replace them with the project’s AppError variant for not-found failures.

As per coding guidelines: "Use custom AppError class when throwing errors"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/lib/jobs/definitions/emails/send-recipient-reminder-email.handler.ts`
around lines 41 - 54, Replace the plain Error throws in
send-recipient-reminder-email.handler (the envelope not-found and recipient
not-found branches) with the project's AppError variant so they participate in
the jobs' typed error-handling; specifically, where you currently throw new
Error(`Envelope ${envelopeId} not found`) and new Error(`Recipient
${recipientId} not found on envelope ${envelopeId}`), throw new AppError(...)
with an appropriate not-found code/message, and ensure AppError is imported into
this module; keep the existing checks around envelope, envelope.status
(DocumentStatus.PENDING), and the prisma.recipient.findFirst call unchanged.

}

if (recipient.signingStatus !== SigningStatus.NOT_SIGNED) {
return;
}

const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: { type: 'team', teamId: envelope.teamId },
meta: envelope.documentMeta,
});
Comment on lines +28 to +65
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider wrapping database reads in io.runTask for full idempotency compliance.

Per coding guidelines, all job handler steps should be wrapped in io.runTask. While read operations are naturally idempotent, wrapping them ensures consistent behavior if the job restarts mid-execution and prevents redundant queries on retry.

The write operations (lines 84-120) are correctly wrapped—this is the critical part. This is a minor consistency improvement.

♻️ Optional refactor to wrap reads
+  const envelope = await io.runTask('fetch-envelope', async () => {
+    return prisma.envelope.findFirst({
-  const envelope = await prisma.envelope.findFirst({
     where: { id: envelopeId },
     include: {
       user: {
         select: { id: true, email: true, name: true },
       },
       documentMeta: true,
       team: {
         select: { teamEmail: true, name: true, url: true },
       },
     },
   });
+  });

Apply similar wrapping to the recipient query and getEmailContext call.

As per coding guidelines: "All job handler steps must be wrapped in io.runTask('unique-key', fn) for idempotency"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/lib/jobs/definitions/emails/send-recipient-reminder-email.handler.ts`
around lines 28 - 65, Wrap the read steps in the job handler inside io.runTask
to follow idempotency guidelines: call io.runTask with distinct keys (e.g.,
`load-envelope-${envelopeId}`, `load-recipient-${envelopeId}-${recipientId}`,
`email-context-${envelopeId}-${recipientId}`) and move the
prisma.envelope.findFirst, prisma.recipient.findFirst and getEmailContext
invocations into the respective runTask callbacks so each read is executed under
io.runTask; keep existing error checks (Envelope/Recipient not found) and return
logic unchanged inside those callbacks.


const i18n = await getI18nInstance(emailLanguage);

const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;

const daysRemaining = recipient.expiresAt
? Math.max(0, Math.ceil((recipient.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
: null;

const template = createElement(DocumentReminderEmailTemplate, {
senderName: envelope.user.name || envelope.user.email,
recipientName: recipient.name || recipient.email,
documentName: envelope.title,
signDocumentLink,
daysRemaining,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});

await io.runTask('send-reminder-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);

await mailer.sendMail({
to: {
name: recipient.name || '',
address: recipient.email,
},
from: senderEmail,
subject: i18n._(msg`Reminder: please sign "${envelope.title}"`),
html,
text,
});
});

await io.runTask('create-reminder-log', async () => {
await prisma.documentReminderLog.create({
data: { envelopeId, recipientId: recipient.id },
});
});

await io.runTask('create-audit-log', async () => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.REMINDER_SENT,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name || '',
},
}),
});
});
};
Loading