-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Person 2 — recipient reminder and owner digest email handlers #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||||||||||||||||||||||
|
|
@@ -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); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Create reminder logs only for envelopes included in the digest. The Use the queried 🐛 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| }; | ||||||||||||||||||||||
| 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'; | ||
|
|
@@ -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'; | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Use Both branches throw plain As per coding guidelines: "Use custom AppError class when throwing errors" 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Consider wrapping database reads in Per coding guidelines, all job handler steps should be wrapped in 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 As per coding guidelines: "All job handler steps must be wrapped in 🤖 Prompt for AI Agents |
||
|
|
||
| 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 || '', | ||
| }, | ||
| }), | ||
| }); | ||
| }); | ||
| }; | ||
There was a problem hiding this comment.
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/getI18nInstancecalls are still outsideio.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