Compare commits

..

14 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan 2b1b042097 chore: code changes based of review 2024-11-21 21:21:42 +00:00
Ephraim Duncan 31dc403500 Merge branch 'main' into expiry-links 2024-11-21 18:14:35 +00:00
Mythie 11e483f1c4 chore: update changelog 2024-11-21 13:10:31 +11:00
Ephraim Duncan cfb57c8c27 Merge branch 'main' into expiry-links 2024-11-19 11:09:46 +00:00
Ephraim Atta-Duncan 2d7988f484 feat: update recipient expiry handling 2024-11-17 22:57:40 +00:00
Ephraim Atta-Duncan ba627e22c5 feat: show recipient as expired on document page view 2024-11-17 17:52:28 +00:00
Ephraim Atta-Duncan 6e9d17f8ea feat: audit logS 2024-11-17 16:56:15 +00:00
Ephraim Atta-Duncan 8491c69e8c feat: period select for expiry 2024-11-17 16:06:10 +00:00
Ephraim Atta-Duncan c422317566 feat: recipient expired on dashboard 2024-11-17 12:55:37 +00:00
Ephraim Atta-Duncan 316dbee446 feat: prevent signing when expired 2024-11-17 12:12:27 +00:00
Ephraim Atta-Duncan 79d0cd7de5 feat: use existing expiry date if available 2024-11-17 11:11:45 +00:00
Ephraim Atta-Duncan e31a10a943 feat: expiry endpoint 2024-11-17 11:02:52 +00:00
Ephraim Atta-Duncan ca2b6bea95 feat: expiry dialog 2024-11-17 09:46:41 +00:00
Ephraim Atta-Duncan 63830fb257 chore: add expired signing status 2024-11-16 09:34:59 +00:00
57 changed files with 1175 additions and 521 deletions
+53
View File
@@ -8,6 +8,59 @@ Check out what's new in the latest version and read our thoughts on it. For more
---
# Documenso v1.8.0: Team Preferences, Signature Rejection, and Document Distribution
We're excited to announce the release of Documenso v1.8.0! This update brings powerful new features to enhance your document signing process. Here's what's new:
## 🌟 Key New Features
### 1. Team Preferences
Introducing **Team Preferences**, allowing administrators to configure settings and preferences that apply to documents across the entire team. This feature ensures consistency and simplifies management by letting you set default options, permissions, and preferences that automatically apply to all team members.
![Team Preferences](/changelog/v1_8_0/team-global-settings.jpeg)
### 2. Signature Rejection
Recipients now have the option to **reject signatures**. This feature enhances communication by allowing recipients to decline signing, providing feedback or requesting changes before the document is finalized.
<video
src="/changelog/v1_8_0/reject-document.mp4"
className="aspect-video w-full"
autoPlay
loop
controls
/>
### 3. Document Distribution Settings
With the new **Document Distribution Settings**, you have greater control over how your documents are shared. Distribute communications via our automated emails and templates or take full control using our API and your own notifications infrastructure.
## 🔧 Other Improvements
- **Support for Gmail SMTP Service**: Adds support for using Gmail as your SMTP service provider.
- **Certificate and Email Translations**: Added support for multiple languages in document certificates and emails, enhancing the experience for international users.
- **Field Movement Fixes**: Resolved issues related to moving fields within documents, improving the document preparation experience.
- **Docker Environment Update**: Improved Docker setup for smoother deployments and better environment consistency.
- **Billing Access Improvements**: Users now have uninterrupted access to billing information, simplifying account management.
- **Support Time Windows for 2FA Tokens**: Enhanced two-factor authentication by supporting time windows in 2FA tokens, improving flexibility.
## 💡 Recent Features
Don't forget to take advantage of these powerful features from our recent releases:
- **Signing Order**: Define the sequence in which recipients sign your documents for a structured signing process.
- **Document Visibility Controls**: Manage who can view your documents and at what stages, offering greater privacy and control.
- **Embedded Signing Experience**: Integrate the signing process directly into your own applications for a seamless user experience.
**👏 Thank You**
As always, we're grateful for the community's contributions and feedback. Your support helps us improve Documenso and deliver a top-notch open-source document signing solution.
We hope you enjoy the new features in Documenso v1.8.0. Happy signing!
---
# Documenso v1.7.1: Signing order and document visibility
We're excited to introduce Documenso v1.7.1, bringing you improved control over your document signing process. Here are the key updates:
Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

@@ -12,13 +12,14 @@ import {
MailOpenIcon,
PenIcon,
PlusIcon,
Timer,
} from 'lucide-react';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Document, Recipient } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@@ -132,6 +133,14 @@ export const DocumentPageViewRecipients = ({
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.EXPIRED && (
<Badge variant="destructive">
<Timer className="mr-1 h-3 w-3" />
<Trans>Expired</Trans>
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.REJECTED && (
<PopoverHover
@@ -15,9 +15,8 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client';
import type { Team, TeamEmail } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import { DocumentStatus, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -218,7 +217,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
</div>
<p className="text-muted-foreground mt-2 px-4 text-sm ">
<p className="text-muted-foreground mt-2 px-4 text-sm">
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans>
@@ -228,8 +227,52 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
))
.with(DocumentStatus.PENDING, () => {
const pendingRecipients = recipients.filter(
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
(recipient) => recipient.signingStatus === SigningStatus.NOT_SIGNED,
);
const rejectedCount = recipients.filter(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
).length;
const expiredCount = recipients.filter(
(recipient) => recipient.signingStatus === SigningStatus.EXPIRED,
).length;
if (rejectedCount > 0 && expiredCount > 0) {
return (
<>
<Plural
value={rejectedCount}
one="1 recipient has rejected the document"
other="# recipients have rejected the document"
/>
{' and '}
<Plural
value={expiredCount}
one="1 recipient's signing link has expired"
other="# recipients' signing links have expired"
/>
</>
);
}
if (rejectedCount > 0) {
return (
<Plural
value={rejectedCount}
one="1 recipient has rejected the document"
other="# recipients have rejected the document"
/>
);
}
if (expiredCount > 0) {
return (
<Plural
value={expiredCount}
one="1 recipient's signing link has expired"
other="# recipients' signing links have expired"
/>
);
}
return (
<Plural
@@ -203,7 +203,7 @@ export const EditDocumentForm = ({
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
try {
const { timezone, dateFormat, redirectUrl, language, reminderInterval } = data.meta;
const { timezone, dateFormat, redirectUrl, language } = data.meta;
await setSettingsForDocument({
documentId: document.id,
@@ -220,7 +220,6 @@ export const EditDocumentForm = ({
dateFormat,
redirectUrl,
language: isValidLanguageCode(language) ? language : undefined,
reminderInterval,
},
});
@@ -420,6 +419,8 @@ export const EditDocumentForm = ({
isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
documentId={document.id}
// teamId={team?.id}
/>
<AddFieldsFormPartial
@@ -100,7 +100,7 @@ export const ResendDocumentActionItem = ({
});
setIsOpen(false);
} catch (err) {
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be re-sent at this time. Please try again.`),
@@ -177,12 +177,7 @@ export const ResendDocumentActionItem = ({
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={isSubmitting}
>
<Button type="button" className="flex-1" variant="secondary" disabled={isSubmitting}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
@@ -0,0 +1,99 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { Timer } from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { truncateTitle } from '~/helpers/truncate-title';
import { SigningAuthPageView } from '../signing-auth-page';
export type ExpiredSigningPageProps = {
params: {
token?: string;
};
};
export default async function ExpiredSigningPage({ params: { token } }: ExpiredSigningPageProps) {
await setupI18nSSR();
if (!token) {
return notFound();
}
const { user } = await getServerComponentSession();
const document = await getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
}).catch(() => null);
if (!document) {
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
const recipient = await getRecipientByToken({ token }).catch(() => null);
if (!recipient) {
return notFound();
}
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
if (!isDocumentAccessValid) {
return <SigningAuthPageView email={recipient.email} />;
}
return (
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<Timer className="text-destructive h-10 w-10" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Expired</Trans>
</h2>
</div>
<div className="text-destructive mt-4 flex items-center text-center text-sm">
<Trans>This document has expired and is no longer available to sign</Trans>
</div>
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
<Trans>
{/* TODO: send email to owner when a user tried to sign an expired document??? */}
The document owner has been notified. They may send you a new signing link if required.
</Trans>
</p>
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
<Trans>No further action is required from you at this time.</Trans>
</p>
{user && (
<Button className="mt-6" asChild>
<Link href={`/`}>Return Home</Link>
</Button>
)}
</div>
</div>
);
}
@@ -12,6 +12,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { isRecipientExpired } from '@documenso/lib/server-only/recipient/is-recipient-expired';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -43,6 +44,16 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const isExpired = await isRecipientExpired({ token });
if (isExpired) {
return redirect(`/sign/${token}/expired`);
}
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) {
return redirect(`/sign/${token}/waiting`);
}
const [document, fields, recipient, completedFields] = await Promise.all([
getDocumentAndSenderByToken({
token,
@@ -63,12 +74,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound();
}
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) {
return redirect(`/sign/${token}/waiting`);
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
@@ -8,9 +8,7 @@ import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { FieldType } from '@documenso/prisma/client';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
@@ -44,10 +42,7 @@ export default async function RejectedSigningPage({ params: { token } }: Rejecte
const truncatedTitle = truncateTitle(document.title);
const [fields, recipient] = await Promise.all([
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
]);
const recipient = await getRecipientByToken({ token }).catch(() => null);
if (!recipient) {
return notFound();
@@ -64,11 +59,6 @@ export default async function RejectedSigningPage({ params: { token } }: Rejecte
return <SigningAuthPageView email={recipient.email} />;
}
const recipientName =
recipient.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
recipient.email;
return (
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
@@ -39,8 +39,10 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
classes = 'bg-documenso-200 text-documenso-800';
break;
case RecipientStatusType.REJECTED:
case RecipientStatusType.EXPIRED:
classes = 'bg-red-200 text-red-800';
break;
default:
break;
}
@@ -50,6 +50,10 @@ export const StackAvatarsWithTooltip = ({
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
);
const expiredRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.EXPIRED,
);
const sortedRecipients = useMemo(() => {
const otherRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
@@ -119,6 +123,30 @@ export const StackAvatarsWithTooltip = ({
</div>
)}
{expiredRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
<Trans>Expired</Trans>
</h1>
{expiredRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
@@ -0,0 +1,71 @@
'use client';
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type DocumentExpirySettingsProps = {
onChange: (value: number | undefined, unit: 'day' | 'week' | 'month' | undefined) => void;
};
export const DocumentExpirySettings = ({ onChange }: DocumentExpirySettingsProps) => {
const [expiryValue, setExpiryValue] = useState<number | undefined>(undefined);
const [expiryUnit, setExpiryUnit] = useState<'day' | 'week' | 'month'>();
const { _ } = useLingui();
const handleExpiryValueChange = (value: string) => {
const parsedValue = parseInt(value, 10);
if (Number.isNaN(parsedValue) || parsedValue <= 0) {
setExpiryValue(undefined);
return;
} else {
setExpiryValue(parsedValue);
onChange(parsedValue, expiryUnit);
}
};
const handleExpiryUnitChange = (value: 'day' | 'week' | 'month') => {
setExpiryUnit(value);
onChange(expiryValue, value);
};
return (
<div className="mt-2 flex flex-row gap-4">
<Input
type="number"
placeholder={_(msg`Enter a number`)}
className="w-16"
value={expiryValue}
onChange={(e) => handleExpiryValueChange(e.target.value)}
min={1}
/>
<Select value={expiryUnit} onValueChange={handleExpiryUnitChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue placeholder={_(msg`Select...`)} />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">
<Trans>Day</Trans>
</SelectItem>
<SelectItem value="week">
<Trans>Week</Trans>
</SelectItem>
<SelectItem value="month">
<Trans>Month</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
);
};
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Clock, EyeOffIcon } from 'lucide-react';
import { AlertTriangle, Clock, EyeOffIcon, Timer } from 'lucide-react';
import { P, match } from 'ts-pattern';
import {
@@ -75,6 +75,9 @@ export const DocumentReadOnlyFields = ({
variant={
field.Recipient.signingStatus === SigningStatus.SIGNED
? 'default'
: field.Recipient.signingStatus === SigningStatus.REJECTED ||
field.Recipient.signingStatus === SigningStatus.EXPIRED
? 'destructive'
: 'secondary'
}
>
@@ -83,6 +86,16 @@ export const DocumentReadOnlyFields = ({
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
) : field.Recipient.signingStatus === SigningStatus.REJECTED ? (
<>
<AlertTriangle className="mr-1 h-3 w-3" />
<Trans>Rejected</Trans>
</>
) : field.Recipient.signingStatus === SigningStatus.EXPIRED ? (
<>
<Timer className="mr-1 h-3 w-3" />
<Trans>Expired</Trans>
</>
) : (
<>
<Clock className="mr-1 h-3 w-3" />
@@ -7,6 +7,7 @@ export enum RecipientStatusType {
WAITING = 'waiting',
UNSIGNED = 'unsigned',
REJECTED = 'rejected',
EXPIRED = 'expired',
}
export const getRecipientType = (recipient: Recipient) => {
@@ -36,6 +37,10 @@ export const getRecipientType = (recipient: Recipient) => {
return RecipientStatusType.WAITING;
}
if (recipient.signingStatus === SigningStatus.EXPIRED) {
return RecipientStatusType.EXPIRED;
}
return RecipientStatusType.UNSIGNED;
};
@@ -54,5 +59,9 @@ export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
return RecipientStatusType.WAITING;
}
if (types.includes(RecipientStatusType.EXPIRED)) {
return RecipientStatusType.EXPIRED;
}
return RecipientStatusType.COMPLETED;
};
-2
View File
@@ -2,7 +2,6 @@ import { JobClient } from './client/client';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
import { SEND_SIGNING_REMINDER_EMAIL_JOB } from './definitions/emails/send-signing-reminder-email';
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email';
@@ -20,7 +19,6 @@ export const jobsClient = new JobClient([
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_SIGNING_REMINDER_EMAIL_JOB,
] as const);
export const jobs = jobsClient;
+4 -14
View File
@@ -26,26 +26,16 @@ export type TriggerJobOptions<Definitions extends ReadonlyArray<JobDefinition> =
};
}[number];
export type CronTrigger<N extends string = string> = {
type: 'cron';
schedule: string;
name: N;
};
export type EventTrigger<N extends string = string> = {
type: 'event';
name: N;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type JobDefinition<Name extends string = string, Schema = any> = {
id: string;
name: string;
version: string;
enabled?: boolean;
trigger:
| (EventTrigger<Name> & { schema?: z.ZodType<Schema> })
| (CronTrigger<Name> & { schema?: z.ZodType<Schema> });
trigger: {
name: Name;
schema?: z.ZodType<Schema>;
};
handler: (options: { payload: Schema; io: JobRunIO }) => Promise<Json | void>;
};
+20 -48
View File
@@ -38,58 +38,30 @@ export class InngestJobProvider extends BaseJobProvider {
public defineJob<N extends string, T>(job: JobDefinition<N, T>): void {
console.log('defining job', job.id);
const fn = this._client.createFunction(
{
id: job.id,
name: job.name,
},
{
event: job.trigger.name,
},
async (ctx) => {
const io = this.convertInngestIoToJobRunIo(ctx);
if (job.trigger.type === 'cron') {
const fn = this._client.createFunction(
{
id: job.id,
name: job.name,
},
{
cron: job.trigger.schedule,
},
async (ctx) => {
const io = this.convertInngestIoToJobRunIo(ctx);
// We need to cast to any so we can deal with parsing later.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
let payload = ctx.event.data as any;
// We need to cast to any so we can deal with parsing later.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
let payload = ctx.event.data as any;
if (job.trigger.schema) {
payload = job.trigger.schema.parse(payload);
}
if (job.trigger.schema) {
payload = job.trigger.schema.parse(payload);
}
await job.handler({ payload, io });
},
);
await job.handler({ payload, io });
},
);
this._functions.push(fn);
} else {
const fn = this._client.createFunction(
{
id: job.id,
name: job.name,
},
{
event: job.trigger.name,
},
async (ctx) => {
const io = this.convertInngestIoToJobRunIo(ctx);
// We need to cast to any so we can deal with parsing later.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
let payload = ctx.event.data as any;
if (job.trigger.schema) {
payload = job.trigger.schema.parse(payload);
}
await job.handler({ payload, io });
},
);
this._functions.push(fn);
}
this._functions.push(fn);
}
public async triggerJob(options: SimpleTriggerJobOptions): Promise<void> {
+11 -25
View File
@@ -1,6 +1,6 @@
import { createPagesRoute } from '@trigger.dev/nextjs';
import type { IO } from '@trigger.dev/sdk';
import { TriggerClient, cronTrigger, eventTrigger } from '@trigger.dev/sdk';
import { TriggerClient, eventTrigger } from '@trigger.dev/sdk';
import type { JobDefinition, JobRunIO, SimpleTriggerJobOptions } from './_internal/job';
import { BaseJobProvider } from './base';
@@ -31,30 +31,16 @@ export class TriggerJobProvider extends BaseJobProvider {
}
public defineJob<N extends string, T>(job: JobDefinition<N, T>): void {
if (job.trigger.type === 'cron') {
this._client.defineJob({
id: job.id,
name: job.name,
version: job.version,
trigger: cronTrigger({
cron: job.trigger.schedule,
}),
run: async (payload, io) =>
job.handler({ payload: payload as T, io: this.convertTriggerIoToJobRunIo(io) }),
});
} else {
this._client.defineJob({
id: job.id,
name: job.name,
version: job.version,
trigger: eventTrigger({
name: job.trigger.name,
schema: job.trigger.schema,
}),
run: async (payload, io) =>
job.handler({ payload, io: this.convertTriggerIoToJobRunIo(io) }),
});
}
this._client.defineJob({
id: job.id,
name: job.name,
version: job.version,
trigger: eventTrigger({
name: job.trigger.name,
schema: job.trigger.schema,
}),
run: async (payload, io) => job.handler({ payload, io: this.convertTriggerIoToJobRunIo(io) }),
});
}
public async triggerJob(options: SimpleTriggerJobOptions): Promise<void> {
@@ -15,7 +15,6 @@ export const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION = {
name: 'Send Confirmation Email',
version: '1.0.0',
trigger: {
type: 'event',
name: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
schema: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA,
},
@@ -30,7 +30,6 @@ export const SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION = {
name: 'Send Rejection Emails',
version: '1.0.0',
trigger: {
type: 'event',
name: SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_ID,
schema: SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_SCHEMA,
},
@@ -43,7 +43,6 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
name: 'Send Signing Email',
version: '1.0.0',
trigger: {
type: 'event',
name: SEND_SIGNING_EMAIL_JOB_DEFINITION_ID,
schema: SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA,
},
@@ -1,169 +0,0 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import { ReminderInterval, SigningStatus } from '@documenso/prisma/generated/types';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { shouldSendReminder } from '../../../utils/should-send-reminder';
import type { JobDefinition, JobRunIO } from '../../client/_internal/job';
export type SendSigningReminderEmailHandlerOptions = {
io: JobRunIO;
};
const SEND_SIGNING_REMINDER_EMAIL_JOB_ID = 'send.signing.reminder.email';
export const SEND_SIGNING_REMINDER_EMAIL_JOB = {
id: SEND_SIGNING_REMINDER_EMAIL_JOB_ID,
name: 'Send Signing Reminder Email',
version: '1.0.0',
trigger: {
type: 'cron',
schedule: '*/5 * * * *',
name: SEND_SIGNING_REMINDER_EMAIL_JOB_ID,
},
handler: async ({ io }) => {
const now = new Date();
const documentWithReminders = await prisma.document.findMany({
where: {
status: DocumentStatus.PENDING,
documentMeta: {
reminderInterval: {
not: ReminderInterval.NONE,
},
},
deletedAt: null,
},
include: {
documentMeta: true,
User: true,
Recipient: {
where: {
signingStatus: SigningStatus.NOT_SIGNED,
role: {
not: RecipientRole.CC,
},
},
},
},
});
console.log(documentWithReminders);
for (const document of documentWithReminders) {
if (!extractDerivedDocumentEmailSettings(document.documentMeta).recipientSigningRequest) {
continue;
}
const { documentMeta } = document;
if (!documentMeta) {
return;
}
const { reminderInterval, lastReminderSentAt } = documentMeta;
if (
!shouldSendReminder({
reminderInterval,
lastReminderSentAt,
now,
})
) {
continue;
}
for (const recipient of document.Recipient) {
const i18n = await getI18nInstance(document.documentMeta?.language);
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
const emailSubject = i18n._(
msg`Reminder: Please ${recipientActionVerb} the document "${document.title}"`,
);
const emailMessage = i18n._(
msg`This is a reminder to ${recipientActionVerb} the document "${document.title}". Please complete this at your earliest convenience.`,
);
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
inviterName: document.User.name || undefined,
inviterEmail: document.User.email,
assetBaseUrl,
signDocumentLink,
customBody: emailMessage,
role: recipient.role,
selfSigner: recipient.email === document.User.email,
});
await io.runTask('send-reminder-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: emailSubject,
html,
text,
});
});
await io.runTask('update-recipient-status', async () => {
await prisma.recipient.update({
where: { id: recipient.id },
data: { sendStatus: SendStatus.SENT },
});
});
// TODO: Duncan == Audit log
// await io.runTask('store-reminder-audit-log', async () => {
// await prisma.documentAuditLog.create({
// data: createDocumentAuditLogData({
// type: DOCUMENT_AUDIT_LOG_TYPE.REMINDER_SENT,
// documentId: document.id,
// user,
// requestMetadata,
// data: {
// recipientId: recipient.id,
// recipientName: recipient.name,
// recipientEmail: recipient.email,
// recipientRole: recipient.role,
// },
// }),
// });
// });
}
await prisma.documentMeta.update({
where: { id: document.documentMeta?.id },
data: { lastReminderSentAt: now },
});
}
},
} as const satisfies JobDefinition<typeof SEND_SIGNING_REMINDER_EMAIL_JOB_ID>;
@@ -40,7 +40,6 @@ export const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION = {
name: 'Send Team Deleted Email',
version: '1.0.0',
trigger: {
type: 'event',
name: SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA,
},
@@ -27,7 +27,6 @@ export const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION = {
name: 'Send Team Member Joined Email',
version: '1.0.0',
trigger: {
type: 'event',
name: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA,
},
@@ -27,7 +27,6 @@ export const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION = {
name: 'Send Team Member Left Email',
version: '1.0.0',
trigger: {
type: 'event',
name: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
schema: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA,
},
@@ -41,7 +41,6 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
name: 'Seal Document',
version: '1.0.0',
trigger: {
type: 'event',
name: SEAL_DOCUMENT_JOB_DEFINITION_ID,
schema: SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA,
},
@@ -7,11 +7,7 @@ import {
diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type {
DocumentDistributionMethod,
DocumentSigningOrder,
ReminderInterval,
} from '@documenso/prisma/client';
import type { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import type { TDocumentEmailSettings } from '../../types/document-email';
@@ -28,7 +24,6 @@ export type CreateDocumentMetaOptions = {
signingOrder?: DocumentSigningOrder;
distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean;
reminderInterval?: ReminderInterval;
language?: SupportedLanguageCodes;
userId: number;
requestMetadata: RequestMetadata;
@@ -47,7 +42,6 @@ export const upsertDocumentMeta = async ({
emailSettings,
distributionMethod,
typedSignatureEnabled,
reminderInterval,
language,
requestMetadata,
}: CreateDocumentMetaOptions) => {
@@ -102,7 +96,6 @@ export const upsertDocumentMeta = async ({
emailSettings,
distributionMethod,
typedSignatureEnabled,
reminderInterval,
language,
},
update: {
@@ -116,7 +109,6 @@ export const upsertDocumentMeta = async ({
emailSettings,
distributionMethod,
typedSignatureEnabled,
reminderInterval,
language,
},
});
@@ -131,7 +123,7 @@ export const upsertDocumentMeta = async ({
user,
requestMetadata,
data: {
changes,
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
},
}),
});
@@ -16,6 +16,7 @@ import type { TRecipientActionAuth } from '../../types/document-auth';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendPendingEmail } from './send-pending-email';
import { updateExpiredRecipients } from './update-expired-recipients';
export type CompleteDocumentWithTokenOptions = {
token: string;
@@ -61,12 +62,22 @@ export const completeDocumentWithToken = async ({
throw new Error(`Document ${document.id} has no recipient with token ${token}`);
}
await updateExpiredRecipients(documentId);
const [recipient] = document.Recipient;
if (recipient.expired && recipient.expired < new Date()) {
throw new Error(`Recipient ${recipient.id} signature period has expired`);
}
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
if (recipient.signingStatus === SigningStatus.EXPIRED) {
throw new Error(`Recipient ${recipient.id} signature period has expired`);
}
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
@@ -178,10 +189,6 @@ export const completeDocumentWithToken = async ({
requestMetadata,
},
});
// TODO: Duncan -- trigger cron job to send reminder email
// TODO: Duncan -- audit log
// TODO: Trigger cron job if cron is activated
});
}
}
@@ -14,8 +14,8 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@@ -166,6 +166,22 @@ export const resendDocument = async ({
await prisma.$transaction(
async (tx) => {
if (recipient.expired) {
const durationInMs = recipient.expired.getTime() - document.updatedAt.getTime();
const newExpiryDate = new Date(Date.now() + durationInMs);
await tx.recipient.update({
where: { id: recipient.id },
data: {
expired: newExpiryDate,
signingStatus:
recipient.signingStatus === SigningStatus.EXPIRED
? SigningStatus.NOT_SIGNED
: recipient.signingStatus,
},
});
}
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
@@ -9,8 +9,8 @@ import {
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
@@ -180,16 +180,6 @@ export const sendDocument = async ({
requestMetadata,
},
});
// TODO: Duncan: Audit Log
await jobs.triggerJob({
name: 'send.signing.reminder.email',
payload: {
documentId,
recipientId: recipient.id,
initialDelay: 24 * 60 * 60 * 1000, // 24 hours in milliseconds
},
});
}),
);
}
@@ -0,0 +1,55 @@
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export const updateExpiredRecipients = async (documentId: number) => {
const now = new Date();
const expiredRecipients = await prisma.recipient.findMany({
where: {
documentId,
expired: {
lt: now,
},
signingStatus: {
not: SigningStatus.EXPIRED,
},
},
});
if (expiredRecipients.length > 0) {
await prisma.recipient.updateMany({
where: {
id: {
in: expiredRecipients.map((recipient) => recipient.id),
},
},
data: {
signingStatus: SigningStatus.EXPIRED,
},
});
await prisma.documentAuditLog.createMany({
data: expiredRecipients.map((recipient) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED,
documentId,
user: {
name: recipient.name,
email: recipient.email,
},
data: {
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
recipientEmail: recipient.email,
},
}),
),
});
}
return expiredRecipients;
};
@@ -0,0 +1,36 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
export type IsRecipientExpiredOptions = {
token: string;
};
export const isRecipientExpired = async ({ token }: IsRecipientExpiredOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
token,
},
});
if (!recipient) {
throw new Error('Recipient not found');
}
const now = DateTime.now();
const hasExpired = recipient.expired && DateTime.fromJSDate(recipient.expired) <= now;
if (hasExpired && recipient.signingStatus !== SigningStatus.EXPIRED) {
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signingStatus: SigningStatus.EXPIRED,
},
});
}
return hasExpired;
};
@@ -0,0 +1,112 @@
import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
export type SetRecipientExpiryOptions = {
documentId: number;
recipientId: number;
expiry: Date;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const setRecipientExpiry = async ({
documentId,
recipientId,
expiry,
userId,
teamId,
requestMetadata,
}: SetRecipientExpiryOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
Document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
},
});
if (!recipient) {
throw new Error('Recipient not found');
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
const updatedRecipient = await prisma.$transaction(async (tx) => {
const persisted = await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
expired: new Date(expiry),
},
});
const changes = diffRecipientChanges(recipient, persisted);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: documentId,
user: {
id: team?.id ?? user.id,
name: team?.name ?? user.name,
email: team ? '' : user.email,
},
requestMetadata,
data: {
changes,
recipientId,
recipientEmail: persisted.email,
recipientName: persisted.name,
recipientRole: persisted.role,
},
}),
});
}
return persisted;
});
return updatedRecipient;
};
+17 -1
View File
@@ -34,6 +34,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_META_UPDATED', // When the document meta data is updated.
'DOCUMENT_OPENED', // When the document is opened by a recipient.
'DOCUMENT_RECIPIENT_REJECTED', // When a recipient rejects the document.
'DOCUMENT_RECIPIENT_EXPIRED', // When the recipient cannot access the document anymore.
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
@@ -65,6 +66,7 @@ export const ZRecipientDiffTypeSchema = z.enum([
'EMAIL',
'ACCESS_AUTH',
'ACTION_AUTH',
'EXPIRY',
]);
export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum;
@@ -146,12 +148,17 @@ export const ZRecipientDiffEmailSchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.EMAIL),
});
export const ZRecipientDiffExpirySchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.EXPIRY),
});
export const ZDocumentAuditLogRecipientDiffSchema = z.discriminatedUnion('type', [
ZRecipientDiffActionAuthSchema,
ZRecipientDiffAccessAuthSchema,
ZRecipientDiffNameSchema,
ZRecipientDiffRoleSchema,
ZRecipientDiffEmailSchema,
ZRecipientDiffExpirySchema,
]);
const ZBaseFieldEventDataSchema = z.object({
@@ -365,7 +372,7 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
});
/**
* Event: Document recipient completed the document (the recipient has fully actioned and completed their required steps for the document).
* Event: Document recipient rejected the document
*/
export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED),
@@ -374,6 +381,14 @@ export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
}),
});
/**
* Event: Recipient expired
*/
export const ZDocumentAuditLogEventDocumentRecipientExpiredSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED),
data: ZBaseRecipientDataSchema,
});
/**
* Event: Document sent.
*/
@@ -499,6 +514,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentOpenedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
ZDocumentAuditLogEventDocumentRecipientRejectedSchema,
ZDocumentAuditLogEventDocumentRecipientExpiredSchema,
ZDocumentAuditLogEventDocumentSentSchema,
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,
+26 -3
View File
@@ -1,5 +1,6 @@
import type { I18n } from '@lingui/core';
import { type I18n, i18n } from '@lingui/core';
import { msg } from '@lingui/macro';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client';
@@ -73,7 +74,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument
return data.data;
};
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role' | 'authOptions'>;
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role' | 'authOptions' | 'expired'>;
export const diffRecipientChanges = (
oldRecipient: PartialRecipient,
@@ -131,6 +132,18 @@ export const diffRecipientChanges = (
});
}
if (oldRecipient.expired !== newRecipient.expired) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.EXPIRY,
from: DateTime.fromJSDate(oldRecipient.expired!)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toLocaleString(DateTime.DATETIME_FULL),
to: DateTime.fromJSDate(newRecipient.expired!)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toLocaleString(DateTime.DATETIME_FULL),
});
}
return diffs;
};
@@ -349,7 +362,7 @@ export const formatDocumentAuditLogAction = (
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, () => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} rejected the document`;
@@ -359,6 +372,16 @@ export const formatDocumentAuditLogAction = (
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED }, () => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName}'s signing period has expired`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
identified: data.isResending
@@ -1,49 +0,0 @@
import { DateTime } from 'luxon';
import { ReminderInterval } from '@documenso/prisma/client';
export type ShouldSendReminderOptions = {
reminderInterval: ReminderInterval;
lastReminderSentAt: Date | null;
now: Date;
};
export const shouldSendReminder = ({
lastReminderSentAt,
now = new Date(),
reminderInterval,
}: ShouldSendReminderOptions): boolean => {
if (!lastReminderSentAt) {
return true;
}
const hoursSinceLastReminder = DateTime.fromJSDate(now).diff(
DateTime.fromJSDate(lastReminderSentAt),
'hours',
).hours;
const monthsSinceLastReminder = DateTime.fromJSDate(now).diff(
DateTime.fromJSDate(lastReminderSentAt),
'months',
).months;
switch (reminderInterval) {
case ReminderInterval.EVERY_1_HOUR:
return hoursSinceLastReminder >= 1;
case ReminderInterval.EVERY_6_HOURS:
return hoursSinceLastReminder >= 6;
case ReminderInterval.EVERY_12_HOURS:
return hoursSinceLastReminder >= 12;
case ReminderInterval.DAILY:
return hoursSinceLastReminder >= 24;
case ReminderInterval.EVERY_3_DAYS:
return hoursSinceLastReminder >= 72;
case ReminderInterval.WEEKLY:
return hoursSinceLastReminder >= 168;
case ReminderInterval.EVERY_2_WEEKS:
return hoursSinceLastReminder >= 336;
case ReminderInterval.MONTHLY:
return monthsSinceLastReminder >= 1;
default:
return false;
}
};
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "SigningStatus" ADD VALUE 'EXPIRED';
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "reminderDays" INTEGER DEFAULT 0;
@@ -1,12 +0,0 @@
/*
Warnings:
- You are about to drop the column `reminderDays` on the `DocumentMeta` table. All the data in the column will be lost.
*/
-- CreateEnum
CREATE TYPE "ReminderInterval" AS ENUM ('NONE', 'EVERY_1_HOUR', 'EVERY_6_HOURS', 'EVERY_12_HOURS', 'DAILY', 'EVERY_3_DAYS', 'WEEKLY', 'EVERY_2_WEEKS', 'MONTHLY');
-- AlterTable
ALTER TABLE "DocumentMeta" DROP COLUMN "reminderDays",
ADD COLUMN "reminderInterval" "ReminderInterval" NOT NULL DEFAULT 'NONE';
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "lastReminderSentAt" TIMESTAMP(3);
+1 -14
View File
@@ -363,18 +363,6 @@ enum DocumentDistributionMethod {
NONE
}
enum ReminderInterval {
NONE
EVERY_1_HOUR
EVERY_6_HOURS
EVERY_12_HOURS
DAILY
EVERY_3_DAYS
WEEKLY
EVERY_2_WEEKS
MONTHLY
}
model DocumentMeta {
id String @id @default(cuid())
subject String?
@@ -390,8 +378,6 @@ model DocumentMeta {
language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL)
emailSettings Json?
reminderInterval ReminderInterval @default(NONE)
lastReminderSentAt DateTime?
}
enum ReadStatus {
@@ -408,6 +394,7 @@ enum SigningStatus {
NOT_SIGNED
SIGNED
REJECTED
EXPIRED
}
enum RecipientRole {
+2 -11
View File
@@ -259,20 +259,13 @@ export const documentRouter = router({
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
if (
meta.timezone ||
meta.dateFormat ||
meta.redirectUrl ||
meta.language ||
meta.reminderInterval
) {
if (meta.timezone || meta.dateFormat || meta.redirectUrl) {
await upsertDocumentMeta({
documentId,
dateFormat: meta.dateFormat,
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
language: meta.language,
reminderInterval: meta.reminderInterval,
userId: ctx.user.id,
requestMetadata,
});
@@ -427,8 +420,7 @@ export const documentRouter = router({
meta.dateFormat ||
meta.redirectUrl ||
meta.distributionMethod ||
meta.emailSettings ||
meta.reminderInterval
meta.emailSettings
) {
await upsertDocumentMeta({
documentId,
@@ -438,7 +430,6 @@ export const documentRouter = router({
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
reminderInterval: meta.reminderInterval,
userId: ctx.user.id,
emailSettings: meta.emailSettings,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
@@ -16,7 +16,6 @@ import {
DocumentVisibility,
FieldType,
RecipientRole,
ReminderInterval,
} from '@documenso/prisma/client';
export const ZFindDocumentsQuerySchema = ZBaseTableSearchParamsSchema.extend({
@@ -99,7 +98,6 @@ export const ZSetSettingsForDocumentMutationSchema = z.object({
'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
}),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
reminderInterval: z.nativeEnum(ReminderInterval).optional().default(ReminderInterval.NONE),
}),
});
@@ -169,7 +167,6 @@ export const ZSendDocumentMutationSchema = z.object({
'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
}),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
reminderInterval: z.nativeEnum(ReminderInterval).optional().default(ReminderInterval.NONE),
}),
});
@@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-document-with-token';
import { setRecipientExpiry } from '@documenso/lib/server-only/recipient/set-recipient-expiry';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -12,6 +13,7 @@ import {
ZAddTemplateSignersMutationSchema,
ZCompleteDocumentWithTokenMutationSchema,
ZRejectDocumentWithTokenMutationSchema,
ZSetSignerExpirySchema,
} from './schema';
export const recipientRouter = router({
@@ -45,6 +47,30 @@ export const recipientRouter = router({
}
}),
setSignerExpiry: authenticatedProcedure
.input(ZSetSignerExpirySchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, signerId, expiry, teamId } = input;
return await setRecipientExpiry({
documentId,
recipientId: signerId,
expiry,
teamId,
userId: ctx.user.id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (error) {
console.log(error);
throw new TRPCError({
code: 'BAD_REQUEST',
message: "We're unable to set the expiry for this signer. Please try again later.",
});
}
}),
addTemplateSigners: authenticatedProcedure
.input(ZAddTemplateSignersMutationSchema)
.mutation(async ({ input, ctx }) => {
@@ -80,3 +80,12 @@ export const ZRejectDocumentWithTokenMutationSchema = z.object({
export type TRejectDocumentWithTokenMutationSchema = z.infer<
typeof ZRejectDocumentWithTokenMutationSchema
>;
export const ZSetSignerExpirySchema = z.object({
documentId: z.number(),
signerId: z.number(),
expiry: z.date().min(new Date(), { message: 'Expiry date must be in the future' }),
teamId: z.number().optional(),
});
export type TSetSignerExpirySchema = z.infer<typeof ZSetSignerExpirySchema>;
+16
View File
@@ -0,0 +1,16 @@
import { differenceInDays, differenceInMonths, differenceInWeeks } from 'date-fns';
export const calculatePeriod = (expiryDate: Date) => {
const now = new Date();
const daysDiff = differenceInDays(expiryDate, now);
const weeksDiff = differenceInWeeks(expiryDate, now);
const monthsDiff = differenceInMonths(expiryDate, now);
if (monthsDiff > 0) {
return { amount: monthsDiff, unit: 'months' as const };
} else if (weeksDiff > 0) {
return { amount: weeksDiff, unit: 'weeks' as const };
} else {
return { amount: daysDiff, unit: 'days' as const };
}
};
+12 -4
View File
@@ -29,17 +29,25 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
head_cell: 'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: 'text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
cell: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md',
props.mode === 'range'
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
: '[&:has([aria-selected])]:rounded-md',
),
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
'h-8 w-8 p-0 font-normal aria-selected:opacity-100',
),
day_range_start: 'day-range-start',
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside: 'text-muted-foreground opacity-50',
day_outside:
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
+8 -8
View File
@@ -135,13 +135,13 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogOverlay,
DialogTitle,
DialogDescription,
DialogPortal,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -13,7 +13,6 @@ import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import type { TeamMemberRole } from '@documenso/prisma/client';
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
import { ReminderInterval } from '@documenso/prisma/generated/types';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import {
DocumentGlobalAuthAccessSelect,
@@ -101,7 +100,6 @@ export const AddSettingsFormPartial = ({
?.value ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: document.documentMeta?.redirectUrl ?? '',
language: document.documentMeta?.language ?? 'en',
reminderInterval: document.documentMeta?.reminderInterval ?? ReminderInterval.NONE,
},
},
});
@@ -392,59 +390,6 @@ export const AddSettingsFormPartial = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.reminderInterval"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Reminder Interval</Trans>{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>Set the interval between reminders for this document.</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ReminderInterval.NONE}>No reminders</SelectItem>
<SelectItem value={ReminderInterval.EVERY_1_HOUR}>
Every hour
</SelectItem>
<SelectItem value={ReminderInterval.EVERY_6_HOURS}>
Every 6 hours
</SelectItem>
<SelectItem value={ReminderInterval.EVERY_12_HOURS}>
Every 12 hours
</SelectItem>
<SelectItem value={ReminderInterval.DAILY}>Daily</SelectItem>
<SelectItem value={ReminderInterval.EVERY_3_DAYS}>
Every 3 days
</SelectItem>
<SelectItem value={ReminderInterval.WEEKLY}>Weekly</SelectItem>
<SelectItem value={ReminderInterval.EVERY_2_WEEKS}>
Every 2 weeks
</SelectItem>
<SelectItem value={ReminderInterval.MONTHLY}>Monthly</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
@@ -9,7 +9,6 @@ import {
} from '@documenso/lib/types/document-auth';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { DocumentVisibility } from '@documenso/prisma/client';
import { ReminderInterval } from '@documenso/prisma/generated/types';
export const ZMapNegativeOneToUndefinedSchema = z
.string()
@@ -46,7 +45,6 @@ export const ZAddSettingsFormSchema = z.object({
.union([z.string(), z.enum(SUPPORTED_LANGUAGE_CODES)])
.optional()
.default('en'),
reminderInterval: z.nativeEnum(ReminderInterval).optional().default(ReminderInterval.NONE),
}),
});
@@ -8,7 +8,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { motion } from 'framer-motion';
import { GripVerticalIcon, Plus, Trash } from 'lucide-react';
import { GripVerticalIcon, Plus } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useFieldArray, useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
@@ -41,6 +41,7 @@ import {
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import { SignerActionDropdown } from './signer-action-dropdown';
import type { DocumentFlowStep } from './types';
export type AddSignersFormProps = {
@@ -51,6 +52,7 @@ export type AddSignersFormProps = {
isDocumentEnterprise: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void;
isDocumentPdfLoaded: boolean;
documentId: number;
};
export const AddSignersFormPartial = ({
@@ -61,6 +63,7 @@ export const AddSignersFormPartial = ({
isDocumentEnterprise,
onSubmit,
isDocumentPdfLoaded,
documentId,
}: AddSignersFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@@ -81,6 +84,7 @@ export const AddSignersFormPartial = ({
email: '',
role: RecipientRole.SIGNER,
signingOrder: 1,
expiry: undefined,
actionAuth: undefined,
},
];
@@ -97,6 +101,7 @@ export const AddSignersFormPartial = ({
name: recipient.name,
email: recipient.email,
role: recipient.role,
expiry: recipient.expired ?? undefined,
signingOrder: recipient.signingOrder ?? index + 1,
actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
@@ -181,6 +186,7 @@ export const AddSignersFormPartial = ({
email: '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
expiry: undefined,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
};
@@ -215,6 +221,7 @@ export const AddSignersFormPartial = ({
email: user?.email ?? '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
expiry: undefined,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
}
@@ -252,6 +259,7 @@ export const AddSignersFormPartial = ({
'email',
'name',
'role',
'expiry',
'signingOrder',
'actionAuth',
];
@@ -628,24 +636,20 @@ export const AddSignersFormPartial = ({
)}
/>
<button
type="button"
className={cn(
'mt-auto inline-flex h-10 w-10 items-center justify-center hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
{
'mb-6': form.formState.errors.signers?.[index],
},
)}
disabled={
<SignerActionDropdown
className={cn({
'mb-6': form.formState.errors.signers?.[index],
})}
onDelete={() => onRemoveSigner(index)}
signer={signer}
documentId={documentId}
deleteDisabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.nativeId) ||
signers.length === 1
}
onClick={() => onRemoveSigner(index)}
>
<Trash className="h-4 w-4" />
</button>
/>
</div>
</motion.fieldset>
</div>
@@ -6,24 +6,23 @@ import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-a
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
import { DocumentSigningOrder, RecipientRole } from '.prisma/client';
export const ZAddSignerSchema = z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
expiry: z.date().min(new Date(), { message: 'Expiry date must be in the future' }).optional(),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(ZRecipientActionAuthTypesSchema.optional()),
});
export const ZAddSignersFormSchema = z
.object({
signers: z.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
}),
),
signers: z.array(ZAddSignerSchema),
signingOrder: z.nativeEnum(DocumentSigningOrder),
})
.refine(
@@ -37,3 +36,4 @@ export const ZAddSignersFormSchema = z
);
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
export type TAddSignerSchema = z.infer<typeof ZAddSignerSchema>;
@@ -0,0 +1,335 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { addDays, addMonths, addWeeks, format } from 'date-fns';
import { Calendar as CalendarIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Calendar } from '@documenso/ui/primitives/calendar';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { calculatePeriod } from '../../lib/calculate-period';
import { cn } from '../../lib/utils';
import { useToast } from '../use-toast';
import type { TAddSignerSchema as Signer } from './add-signers.types';
const dateFormSchema = z.object({
expiry: z.date({
required_error: 'Please select an expiry date.',
}),
});
const periodFormSchema = z.object({
amount: z.number().min(1, 'Please enter a number greater than 0.'),
unit: z.enum(['days', 'weeks', 'months']),
});
type DocumentExpiryDialogProps = {
open: boolean;
onOpenChange: (_open: boolean) => void;
signer: Signer;
documentId: number;
};
export function DocumentExpiryDialog({
open,
onOpenChange,
signer,
documentId,
}: DocumentExpiryDialogProps) {
const { _ } = useLingui();
const router = useRouter();
const { toast } = useToast();
const [activeTab, setActiveTab] = useState<'date' | 'period'>('date');
const dateForm = useForm<z.infer<typeof dateFormSchema>>({
resolver: zodResolver(dateFormSchema),
defaultValues: {
expiry: signer.expiry,
},
});
const periodForm = useForm<z.infer<typeof periodFormSchema>>({
resolver: zodResolver(periodFormSchema),
defaultValues: signer.expiry
? calculatePeriod(signer.expiry)
: {
amount: undefined,
unit: undefined,
},
});
const watchAmount = periodForm.watch('amount');
const watchUnit = periodForm.watch('unit');
const { mutateAsync: setSignerExpiry, isLoading } = trpc.recipient.setSignerExpiry.useMutation({
onSuccess: (updatedRecipient) => {
router.refresh();
periodForm.reset(
updatedRecipient?.expired
? calculatePeriod(updatedRecipient.expired)
: {
amount: undefined,
unit: undefined,
},
);
dateForm.reset(
{
expiry: updatedRecipient?.expired ?? undefined,
},
{
keepValues: false,
},
);
toast({
title: _(msg`Signer Expiry Set`),
description: _(msg`The expiry date for the signer has been set.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (error) => {
toast({
title: _(msg`Error`),
description: error.message || _(msg`An error occurred while setting the expiry date.`),
variant: 'destructive',
duration: 7500,
});
},
});
const onSetExpiry = async (
values: z.infer<typeof dateFormSchema> | z.infer<typeof periodFormSchema>,
) => {
if (!signer.nativeId) {
return toast({
title: _(msg`Error`),
description: _(msg`An error occurred while setting the expiry date.`),
variant: 'destructive',
duration: 7500,
});
}
let expiryDate: Date;
if ('expiry' in values) {
expiryDate = values.expiry;
} else {
const now = new Date();
switch (values.unit) {
case 'days':
expiryDate = addDays(now, values.amount);
break;
case 'weeks':
expiryDate = addWeeks(now, values.amount);
break;
case 'months':
expiryDate = addMonths(now, values.amount);
break;
default:
throw new Error(`Invalid unit: ${values.unit}`);
}
}
await setSignerExpiry({
documentId,
signerId: signer.nativeId,
expiry: expiryDate,
});
// TODO: Duncan => Implement logic to update expiry when resending document
// This should be handled on the server-side when a document is resent
// TODO: Duncan => Implement logic to mark recipients as expired
// This should be a scheduled task or part of the completion process on the server
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle>Set Recipient Expiry</DialogTitle>
<DialogDescription>
Set the expiry date for the document signing recipient. The recipient will not be able
to sign the document after this date.
</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return setActiveTab(value as 'date' | 'period');
}}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="date">Specific Date</TabsTrigger>
<TabsTrigger value="period">Time Period</TabsTrigger>
</TabsList>
<TabsContent value="date">
<Form {...dateForm}>
<form onSubmit={dateForm.handleSubmit(onSetExpiry)} className="space-y-8">
<FormField
control={dateForm.control}
name="expiry"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Expiry Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={'outline'}
className={cn(
'w-[240px] pl-3 text-left font-normal',
!field.value && 'text-muted-foreground',
)}
>
{field.value ? format(field.value, 'PPP') : <span>Pick a date</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="z-[1100] w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) => date < new Date() || date < new Date('1900-01-01')}
initialFocus
/>
</PopoverContent>
</Popover>
<FormDescription>
The document will expire at 11:59 PM on the selected date.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="submit" loading={isLoading}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</TabsContent>
<TabsContent value="period">
<Form {...periodForm}>
<form onSubmit={periodForm.handleSubmit(onSetExpiry)} className="space-y-8">
<div className="flex space-x-4">
<FormField
control={periodForm.control}
name="amount"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Amount</FormLabel>
<FormControl>
<Select
onValueChange={(value) => field.onChange(parseInt(value, 10))}
value={watchAmount?.toString()}
>
<SelectTrigger>
<SelectValue placeholder="Select amount" />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<SelectItem key={num} value={num.toString()}>
{num}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={periodForm.control}
name="unit"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Unit</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={watchUnit}>
{' '}
<SelectTrigger>
<SelectValue placeholder="Select unit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="days">Days</SelectItem>
<SelectItem value="weeks">Weeks</SelectItem>
<SelectItem value="months">Months</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormDescription>
The document will expire after the selected time period from now.
</FormDescription>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="submit" loading={isLoading}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
@@ -36,7 +36,7 @@ export const ShowFieldItem = ({ field, recipients }: ShowFieldItemProps) => {
return createPortal(
<div
className={cn('pointer-events-none absolute z-10 opacity-75')}
className={cn('pointer-events-none absolute opacity-75')}
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
@@ -0,0 +1,66 @@
'use client';
import { useState } from 'react';
import { MoreHorizontal, Timer, Trash } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { cn } from '../../lib/utils';
import type { TAddSignerSchema as Signer } from './add-signers.types';
import { DocumentExpiryDialog } from './document-expiry-dialog';
type SignerActionDropdownProps = {
onDelete: () => void;
deleteDisabled?: boolean;
className?: string;
signer: Signer;
documentId: number;
};
export function SignerActionDropdown({
deleteDisabled,
className,
signer,
documentId,
onDelete,
}: SignerActionDropdownProps) {
const [isExpiryDialogOpen, setExpiryDialogOpen] = useState(false);
return (
<>
<div className={cn('flex items-center justify-center', className)}>
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuItem className="gap-x-2" onClick={() => setExpiryDialogOpen(true)}>
<Timer className="h-4 w-4" />
Expiry
</DropdownMenuItem>
<DropdownMenuItem disabled={deleteDisabled} className="gap-x-2" onClick={onDelete}>
<Trash className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<DocumentExpiryDialog
open={isExpiryDialogOpen}
onOpenChange={setExpiryDialogOpen}
signer={signer}
documentId={documentId}
/>
</>
);
}
+1 -1
View File
@@ -92,4 +92,4 @@ const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) =>
);
};
export { Popover, PopoverTrigger, PopoverContent, PopoverHover };
export { Popover, PopoverContent, PopoverHover, PopoverTrigger };