feat: use server-actions for authoring flow

This change actually makes the authoring flow work for
the most part by tying in emailing and more.

We have also done a number of quality of life updates to
simplify the codebase overall making it easier to continue
work on the refresh.
This commit is contained in:
Mythie
2023-07-26 18:52:53 +10:00
parent 5e3752cdbf
commit 12d8cebd4c
54 changed files with 2890 additions and 860 deletions

View File

@ -1 +1 @@
export { render, renderAsync } from '@react-email/components';
export {};

50
packages/email/mailer.ts Normal file
View File

@ -0,0 +1,50 @@
import { createTransport } from 'nodemailer';
import { MailChannelsTransport } from './transports/mailchannels';
const getTransport = () => {
const transport = process.env.NEXT_PRIVATE_SMTP_TRANSPORT ?? 'smtp-auth';
if (transport === 'mailchannels') {
return createTransport(
MailChannelsTransport.makeTransport({
apiKey: process.env.NEXT_PRIVATE_MAILCHANNELS_API_KEY,
endpoint: process.env.NEXT_PRIVATE_MAILCHANNELS_ENDPOINT,
}),
);
}
if (transport === 'smtp-api') {
if (!process.env.NEXT_PRIVATE_SMTP_HOST || !process.env.NEXT_PRIVATE_SMTP_APIKEY) {
throw new Error(
'SMTP API transport requires NEXT_PRIVATE_SMTP_HOST and NEXT_PRIVATE_SMTP_APIKEY',
);
}
return createTransport({
host: process.env.NEXT_PRIVATE_SMTP_HOST,
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
auth: {
user: process.env.NEXT_PRIVATE_SMTP_APIKEY_USER ?? 'apikey',
pass: process.env.NEXT_PRIVATE_SMTP_APIKEY ?? '',
},
});
}
if (!process.env.NEXT_PRIVATE_SMTP_HOST) {
throw new Error('SMTP transport requires NEXT_PRIVATE_SMTP_HOST');
}
return createTransport({
host: process.env.NEXT_PRIVATE_SMTP_HOST,
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
auth: {
user: process.env.NEXT_PRIVATE_SMTP_USERNAME ?? '',
pass: process.env.NEXT_PRIVATE_SMTP_PASSWORD ?? '',
},
});
};
export const mailer = getTransport();

View File

@ -5,18 +5,26 @@
"types": "./index.ts",
"license": "MIT",
"files": [
"templates/"
"templates/",
"transports/",
"mailer.ts",
"render.ts",
"index.ts"
],
"scripts": {
"dev": "email dev --port 3002 --dir templates"
"dev": "email dev --port 3002 --dir templates",
"worker:test": "tsup worker/index.ts --format esm"
},
"dependencies": {
"@documenso/tsconfig": "*",
"@documenso/tailwind-config": "*",
"@documenso/ui": "*",
"@react-email/components": "^0.0.7"
"@react-email/components": "^0.0.7",
"nodemailer": "^6.9.3"
},
"devDependencies": {
"react-email": "^1.9.4"
"@types/nodemailer": "^6.4.8",
"react-email": "^1.9.4",
"tsup": "^7.1.0"
}
}

1
packages/email/render.ts Normal file
View File

@ -0,0 +1 @@
export { render, renderAsync } from '@react-email/components';

View File

@ -1,6 +1,5 @@
import {
Body,
Button,
Container,
Head,
Html,

View File

@ -0,0 +1,154 @@
import { SentMessageInfo, Transport } from 'nodemailer';
import type { Address } from 'nodemailer/lib/mailer';
import type MailMessage from 'nodemailer/lib/mailer/mail-message';
const VERSION = '1.0.0';
type NodeMailerAddress = string | Address | Array<string | Address> | undefined;
interface MailChannelsAddress {
email: string;
name?: string;
}
interface MailChannelsTransportOptions {
apiKey: string;
endpoint: string;
}
/**
* Transport for sending email through MailChannels via Cloudflare Workers.
*
* Optionally allows specifying a custom endpoint and API key so you can setup a worker
* to proxy requests to MailChannels with added security.
*
* @see https://blog.cloudflare.com/sending-email-from-workers-with-mailchannels/
*/
export class MailChannelsTransport implements Transport<SentMessageInfo> {
public name = 'CloudflareMailTransport';
public version = VERSION;
private _options: MailChannelsTransportOptions;
public static makeTransport(options: Partial<MailChannelsTransportOptions>) {
return new MailChannelsTransport(options);
}
constructor(options: Partial<MailChannelsTransportOptions>) {
const { apiKey = '', endpoint = 'https://api.mailchannels.net/tx/v1/send' } = options;
this._options = {
apiKey,
endpoint,
};
}
public send(mail: MailMessage, callback: (_err: Error | null, _info: SentMessageInfo) => void) {
if (!mail.data.to || !mail.data.from) {
return callback(new Error('Missing required fields "to" or "from"'), null);
}
const mailTo = this.toMailChannelsAddresses(mail.data.to);
const mailCc = this.toMailChannelsAddresses(mail.data.cc);
const mailBcc = this.toMailChannelsAddresses(mail.data.bcc);
const from: MailChannelsAddress =
typeof mail.data.from === 'string'
? { email: mail.data.from }
: {
email: mail.data.from?.address,
name: mail.data.from?.name,
};
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this._options.apiKey) {
requestHeaders['X-Auth-Token'] = this._options.apiKey;
}
fetch(this._options.endpoint, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify({
from: from,
subject: mail.data.subject,
personalizations: [
{
to: mailTo,
cc: mailCc.length > 0 ? mailCc : undefined,
bcc: mailBcc.length > 0 ? mailBcc : undefined,
dkim_domain: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN || undefined,
dkim_selector: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR || undefined,
dkim_private_key: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY || undefined,
},
],
content: [
{
type: 'text/plain',
value: mail.data.text?.toString('utf-8') ?? '',
},
{
type: 'text/html',
value: mail.data.html?.toString('utf-8') ?? '',
},
],
}),
})
.then((res) => {
if (res.status >= 200 && res.status <= 299) {
return callback(null, {
messageId: '',
envelope: {
from: mail.data.from,
to: mail.data.to,
},
accepted: mail.data.to,
rejected: [],
pending: [],
});
}
res.json().then((data) => {
return callback(new Error(`MailChannels error: ${data.message}`), null);
});
})
.catch((err) => {
return callback(err, null);
});
}
/**
* Converts a nodemailer address(s) to an array of MailChannel compatible address.
*/
private toMailChannelsAddresses(address: NodeMailerAddress): Array<MailChannelsAddress> {
if (!address) {
return [];
}
if (typeof address === 'string') {
return [{ email: address }];
}
if (Array.isArray(address)) {
return address.map((address) => {
if (typeof address === 'string') {
return { email: address };
}
return {
email: address.address,
name: address.name,
};
});
}
return [
{
email: address.address,
name: address.name,
},
];
}
}

View File

@ -7,7 +7,7 @@
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"eslint": "^8.40.0",
"eslint-config-next": "^13.4.1",
"eslint-config-next": "^13.4.9",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2",

View File

@ -13,6 +13,7 @@
"scripts": {
},
"dependencies": {
"@documenso/email": "*",
"@documenso/prisma": "*",
"@pdf-lib/fontkit": "^1.1.1",
"@next-auth/prisma-adapter": "^1.0.6",
@ -20,8 +21,9 @@
"bcrypt": "^5.1.0",
"pdf-lib": "^1.17.1",
"nanoid": "^4.0.2",
"next": "13.4.1",
"next": "13.4.9",
"next-auth": "^4.22.1",
"react": "18.2.0",
"stripe": "^12.7.0"
},
"devDependencies": {

View File

@ -0,0 +1,94 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
export interface SendDocumentOptions {
documentId: number;
userId: number;
}
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const document = await prisma.document.findUnique({
where: {
id: documentId,
userId,
},
include: {
Recipient: true,
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.Recipient.length === 0) {
throw new Error('Document has no recipients');
}
if (document.status === DocumentStatus.COMPLETED) {
throw new Error('Can not send completed document');
}
await Promise.all([
document.Recipient.map(async (recipient) => {
const { email, name } = recipient;
if (recipient.sendStatus === SendStatus.SENT) {
return;
}
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
assetBaseUrl: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
inviterName: user.name || undefined,
inviterEmail: user.email,
signDocumentLink: 'https://example.com',
});
mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Please sign this document',
html: render(template),
text: render(template, { plainText: true }),
});
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
}),
]);
const updatedDocument = await prisma.document.update({
where: {
id: documentId,
},
data: {
status: DocumentStatus.PENDING,
},
});
return updatedDocument;
};

View File

@ -48,23 +48,21 @@ export const setFieldsForDocument = async ({
),
);
const linkedFields = fields.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
const linkedFields = fields
.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
return {
...field,
...existing,
};
});
for (const field of linkedFields) {
if (
field.Recipient?.sendStatus === SendStatus.SENT ||
field.Recipient?.signingStatus === SigningStatus.SIGNED
) {
throw new Error('Cannot modify fields after sending');
}
}
return {
...field,
...existing,
};
})
.filter((field) => {
return (
field.Recipient?.sendStatus !== SendStatus.SENT &&
field.Recipient?.signingStatus !== SigningStatus.SIGNED
);
});
const persistedFields = await prisma.$transaction(
linkedFields.map((field) =>

View File

@ -43,26 +43,23 @@ export const setRecipientsForDocument = async ({
),
);
const linkedRecipients = recipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
const linkedRecipients = recipients
.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
return {
...recipient,
...existing,
};
});
for (const recipient of linkedRecipients) {
if (
recipient.sendStatus === SendStatus.SENT ||
recipient.signingStatus === SigningStatus.SIGNED
) {
throw new Error('Cannot modify recipients after sending');
}
}
return {
...recipient,
...existing,
};
})
.filter((recipient) => {
return (
recipient.sendStatus !== SendStatus.SENT && recipient.signingStatus !== SigningStatus.SIGNED
);
});
const persistedRecipients = await prisma.$transaction(
linkedRecipients.map((recipient) =>

View File

@ -44,6 +44,10 @@ module.exports = {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
widget: {
DEFAULT: 'hsl(var(--widget))',
// foreground: 'hsl(var(--widget-foreground))',
},
documenso: {
DEFAULT: '#A2E771',
50: '#FFFFFF',

View File

@ -1,10 +1,12 @@
import { TRPCError } from '@trpc/server';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { authenticatedProcedure, router } from '../trpc';
import {
ZSendDocumentMutationSchema,
ZSetFieldsForDocumentMutationSchema,
ZSetRecipientsForDocumentMutationSchema,
} from './schema';
@ -52,4 +54,24 @@ export const documentRouter = router({
});
}
}),
sendDocument: authenticatedProcedure
.input(ZSendDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId } = input;
return await sendDocument({
userId: ctx.user.id,
documentId,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to send this document. Please try again later.',
});
}
}),
});

View File

@ -36,3 +36,9 @@ export const ZSetFieldsForDocumentMutationSchema = z.object({
export type TSetFieldsForDocumentMutationSchema = z.infer<
typeof ZSetFieldsForDocumentMutationSchema
>;
export const ZSendDocumentMutationSchema = z.object({
documentId: z.number(),
});
export type TSendDocumentMutationSchema = z.infer<typeof ZSendDocumentMutationSchema>;

View File

@ -16,7 +16,8 @@
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2018"
"target": "ES2018",
"types": ["@documenso/tsconfig/process-env.d.ts"]
},
"exclude": ["node_modules"]
}

36
packages/tsconfig/process-env.d.ts vendored Normal file
View File

@ -0,0 +1,36 @@
declare namespace NodeJS {
export interface ProcessEnv {
NEXT_PUBLIC_SITE_URL?: string;
NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: string;
NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'smtp-auth' | 'smtp-api';
NEXT_PRIVATE_MAILCHANNELS_API_KEY?: string;
NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN?: string;
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR?: string;
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY?: string;
NEXT_PRIVATE_MAILCHANNELS_ENDPOINT?: string;
NEXT_PRIVATE_SMTP_HOST?: string;
NEXT_PRIVATE_SMTP_PORT?: string;
NEXT_PRIVATE_SMTP_USERNAME?: string;
NEXT_PRIVATE_SMTP_PASSWORD?: string;
NEXT_PRIVATE_SMTP_APIKEY_USER?: string;
NEXT_PRIVATE_SMTP_APIKEY?: string;
NEXT_PRIVATE_SMTP_SECURE?: string;
NEXT_PRIVATE_SMTP_FROM_NAME?: string;
NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string;
}
}

View File

@ -16,15 +16,17 @@ const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
className,
)}
{...props}
/>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;