refactor: pg-boss jobs and esign (#493)

* feat: add queue utility

* feat: add QUEUE_DATABASE_URL

* feat: update member invite email

* feat: update password reset email

* feat: update share update email

* feat: update data room email job

* feat: update reinvite job

* feat: register jobs

* feat: add eSignNotificationWorker

* feat: add authVerificationEmailWorker

* feat: add esign confirmation job

* feat: add esign pdf worker

* feat: refactor name

* refactor: move lib

* feat: update

* feat: use singleton

* feat: split and register

* chore: fix connection

* fix: type

* chore: fix type

* chore: upgrade pg-boss

* chore: fix type

* fix: types

* feat:  move audit inside handler

* feat: refactor sign-template

* fix: remove sent status

* feat: add status complete

* feat: add new enums

* fix: status map

* fix: status

* fix: job

* feat: refactor esign

* fix: status enum

* feat: changes

* chore: upgrade react email

* feat: use render method
This commit is contained in:
Nafees Nazik
2024-09-18 23:46:26 -05:00
committed by GitHub
parent e20809e31b
commit e5204b5edb
40 changed files with 1096 additions and 2098 deletions
+3 -3
View File
@@ -57,7 +57,7 @@
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toolbar": "^1.0.4",
"@radix-ui/react-tooltip": "^1.1.2",
"@react-email/components": "0.0.22",
"@react-email/components": "0.0.25",
"@react-pdf/renderer": "^3.4.4",
"@remixicon/react": "^4.0.1",
"@scalar/nextjs-api-reference": "^0.4.18",
@@ -96,7 +96,7 @@
"nodemailer": "^6.9.14",
"papaparse": "^5.4.1",
"pdf-lib": "^1.17.1",
"pg-boss": "^9.0.3",
"pg-boss": "^10.1.1",
"pino": "^9.3.1",
"pino-pretty": "^11.2.2",
"prisma-extension-pagination": "^0.7.4",
@@ -105,7 +105,7 @@
"react": "18.3.1",
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-email": "2.1.6",
"react-email": "3.0.1",
"react-hook-form": "^7.52.1",
"react-number-format": "^5.3.4",
"react-pdf": "^8.0.2",
+199 -1298
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
/*
Warnings:
- The values [SENT,WAITING] on the enum `TemplateStatus` will be removed. If these variants are still used in the database, this will fail.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "TemplateStatus_new" AS ENUM ('DRAFT', 'COMPLETE', 'PENDING', 'CANCELLED');
ALTER TABLE "Template" ALTER COLUMN "status" DROP DEFAULT;
ALTER TABLE "Template" ALTER COLUMN "status" TYPE "TemplateStatus_new" USING ("status"::text::"TemplateStatus_new");
ALTER TYPE "TemplateStatus" RENAME TO "TemplateStatus_old";
ALTER TYPE "TemplateStatus_new" RENAME TO "TemplateStatus";
DROP TYPE "TemplateStatus_old";
ALTER TABLE "Template" ALTER COLUMN "status" SET DEFAULT 'DRAFT';
COMMIT;
+1 -2
View File
@@ -602,8 +602,7 @@ model TemplateField {
enum TemplateStatus {
DRAFT
COMPLETE
SENT
WAITING
PENDING
CANCELLED
}
@@ -3,6 +3,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { serverAccessControl } from "@/lib/rbac/access-control";
import type { TemplateStatus } from "@/prisma/enums";
import { TemplateSigningFieldProvider } from "@/providers/template-signing-field-provider";
import { api } from "@/trpc/server";
import { RiCheckFill } from "@remixicon/react";
@@ -14,6 +15,13 @@ type BadgeVariant =
| "secondary"
| "destructive";
const variantMap: Record<TemplateStatus, BadgeVariant> = {
CANCELLED: "destructive",
COMPLETE: "success",
DRAFT: "info",
PENDING: "secondary",
};
export default async function TemplateDetailViewPage({
params: { templatePublicId },
}: {
@@ -36,29 +44,12 @@ export default async function TemplateDetailViewPage({
),
]);
const badgeVariant = (): BadgeVariant => {
let result: BadgeVariant = "warning";
if (status === "DRAFT") {
result = "warning";
} else if (status === "SENT") {
result = "info";
} else if (status === "COMPLETE") {
result = "success";
} else if (status === "WAITING") {
result = "secondary";
} else if (status === "CANCELLED") {
result = "destructive";
}
return result;
};
return (
<TemplateSigningFieldProvider fields={fields}>
<div className="flex min-h-screen bg-gray-50">
<div className="flex h-full flex-grow flex-col">
<div className="col-span-12 flex align-middle">
<Badge variant={badgeVariant()} className="h-7 align-middle">
<Badge variant={variantMap[status]} className="h-7 align-middle">
{status}
</Badge>
<span className="ml-2 align-middle text-xl font-semibold">
+2 -2
View File
@@ -7,7 +7,7 @@ import UpdateRenderer from "@/components/update/renderer";
import { type JWTVerifyResult, decode } from "@/lib/jwt";
import { UpdateStatusEnum } from "@/prisma/enums";
import { db } from "@/server/db";
import { renderAsync } from "@react-email/components";
import { render } from "@react-email/components";
import { RiLock2Line } from "@remixicon/react";
import { notFound } from "next/navigation";
import { Fragment } from "react";
@@ -100,7 +100,7 @@ const PublicUpdatePage = async ({
const company = update?.company;
const author = update?.author;
const html = await renderAsync(<UpdateRenderer html={update.html} />);
const html = await render(<UpdateRenderer html={update.html} />);
return (
<SharePageLayout
@@ -108,7 +108,7 @@ export function CanvasToolbar({ recipients }: CanvasToolbarProps) {
// biome-ignore lint/correctness/useExhaustiveDependencies: reason
const onSignatureRequest = useCallback((canSubmit: boolean) => {
if (canSubmit && submitRef.current) {
setValue("status", "COMPLETE");
setValue("status", "PENDING");
submitRef.current.click();
}
}, []);
@@ -31,7 +31,7 @@ export const TemplateFieldForm = ({
toast.error(`${title}, ${message}`);
}
if (status === "COMPLETE") {
if (status === "PENDING") {
router.push(`/${companyPublicId}/documents/esign`);
}
},
+1 -1
View File
@@ -8,5 +8,5 @@ export const JOB_TYPES = {
"esign-confirmation",
"esign-notification",
],
generate: ["esign-pdf"],
esign: ["generate-pdf", "complete-pdf"],
} as const;
+2 -2
View File
@@ -10,9 +10,9 @@ import {
Text,
} from "@react-email/components";
import type { ConfirmationEmailPayloadType } from "../jobs/esign-confirmation-email";
import type { TSchema } from "../jobs/esign-confirmation-email";
type Payload = Omit<ConfirmationEmailPayloadType, "fileUrl">;
type Payload = Omit<TSchema, "fileUrl">;
const ESignConfirmationEmail = ({
documentName,
+7 -2
View File
@@ -1,4 +1,4 @@
import type { EsignEmailPayloadType } from "@/jobs/esign-email";
import type { TESignNotificationEmailJobInput } from "@/jobs/esign-email";
import {
Body,
Button,
@@ -21,7 +21,12 @@ import {
UserAvatarIcon,
} from "../components/common/icons";
type TEsignEmailProps = EsignEmailPayloadType & { signingLink: string };
type TEsignEmailProps = Omit<
TESignNotificationEmailJobInput,
"token" | "email" | "userAgent" | "requestIp" | "companyId"
> & {
signingLink: string;
};
const EsignEmail = ({
signingLink,
+6
View File
@@ -60,6 +60,9 @@ export const env = createEnv({
// sentry
SENTRY_ORG: z.string().optional(),
SENTRY_PROJECT: z.string().optional(),
//job queue
QUEUE_DATABASE_URL: z.string().optional(),
},
/**
@@ -116,6 +119,9 @@ export const env = createEnv({
NEXT_PUBLIC_SENTRY_DSN: readRuntimePublicEnvVariable(
"NEXT_PUBLIC_SENTRY_DSN",
),
/// job queue
WORKER_DATABASE_URL: process.env.WORKER_DATABASE_URL,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
+3 -2
View File
@@ -2,8 +2,9 @@ import { isSentryEnabled } from "@/constants/sentry";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { startJobs } = await import("@/jobs/start");
await startJobs();
const { registerJobs } = await import("@/jobs/register");
await registerJobs();
if (isSentryEnabled) {
await import("../sentry.server.config");
+15 -21
View File
@@ -1,23 +1,25 @@
import AccountVerificationEmail from "@/emails/AccountVerificationEmail";
import { env } from "@/env";
import { BaseJob } from "@/jobs/base";
import { sendMail } from "@/server/mailer";
import { renderAsync } from "@react-email/components";
import type { Job } from "pg-boss";
import { render } from "@react-email/components";
import { z } from "zod";
import { defineJob, defineWorker, defineWorkerConfig } from "../lib/queue";
export type AuthVerificationPayloadType = {
email: string;
token: string;
};
const config = defineWorkerConfig({
name: "email.auth-verify",
schema: z.object({
email: z.string(),
token: z.string(),
}),
});
export const sendAuthVerificationEmail = async (
payload: AuthVerificationPayloadType,
) => {
const { email, token } = payload;
export const authVerificationEmailJob = defineJob(config);
export const authVerificationEmailWorker = defineWorker(config, async (job) => {
const { email, token } = job.data;
const baseUrl = env.NEXT_PUBLIC_BASE_URL;
const confirmLink = `${baseUrl}/verify-email/${token}`;
const html = await renderAsync(
const html = await render(
AccountVerificationEmail({
verifyLink: confirmLink,
}),
@@ -28,12 +30,4 @@ export const sendAuthVerificationEmail = async (
subject: "Confirm your email",
html,
});
};
export class AuthVerificationEmailJob extends BaseJob<AuthVerificationPayloadType> {
readonly type = "email.auth-verify";
async work(job: Job<AuthVerificationPayloadType>): Promise<void> {
await sendAuthVerificationEmail(job.data);
}
}
});
-106
View File
@@ -1,106 +0,0 @@
import type { JOB_TYPES } from "@/constants/job";
import { env } from "@/env";
import { logger } from "@/lib/logger";
import { singleton } from "@/lib/singleton";
import pgBoss from "pg-boss";
type JobTypes = typeof JOB_TYPES;
const log = logger.child({ module: "jobs" });
export type JobType = {
[Key in keyof JobTypes]: `${Key}.${JobTypes[Key][number]}`;
}[keyof JobTypes];
interface Job<T extends object, U = void> {
type: JobType;
options: pgBoss.SendOptions;
start: () => Promise<void>;
work: (job: pgBoss.Job<T>) => Promise<U>;
emit: (data: T) => Promise<void>;
}
export abstract class BaseJob<T extends object, U = void> implements Job<T, U> {
protected boss: pgBoss;
abstract readonly type: JobType;
readonly options = { retryLimit: 3, retryDelay: 1000 };
constructor() {
this.boss = boss;
}
async start(): Promise<void> {
await this.boss.work(this.type, this.work.bind(this));
}
abstract work(job: pgBoss.Job<T>): Promise<U>;
async emit(data: T, options?: pgBoss.SendOptions): Promise<void> {
await this.boss.send(this.type, data, options ?? this.options);
}
async bulkEmit(data: Omit<pgBoss.JobInsert<T>, "name">[]): Promise<void> {
await this.boss.insert(
data.map((items) => ({ ...items, name: this.type })),
);
}
}
export class JobManager {
private readonly boss: pgBoss;
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
private jobs = new Map<string, Job<any>>();
constructor(boss: pgBoss) {
this.boss = boss;
}
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
register(job: new (boss: pgBoss) => Job<any>): JobManager {
const jobInstance = new job(this.boss);
this.jobs.set(jobInstance.type, jobInstance);
return this;
}
async start(): Promise<void> {
const startTime = Date.now();
log.info("Starting pg-boss job queue manager");
await this.boss.start();
const endTime = Date.now();
const elapsedTime = endTime - startTime;
log.info(
`Successfully started pg-boss job queue manager in ${elapsedTime}ms`,
);
for (const [jobName, job] of this.jobs.entries()) {
const jobStartTime = Date.now();
log.info(`Registering pg-boss job:${jobName}`);
await job.start();
const jobEndTime = Date.now();
const jobElapsedTime = jobEndTime - jobStartTime;
log.info(
`Successfully registered pg-boss job:${jobName} in ${jobElapsedTime}ms`,
);
}
}
}
export const boss = singleton(
"pg-boss",
() =>
new pgBoss({
connectionString: env.DATABASE_URL,
max: 5,
retryBackoff: true,
retryLimit: 4,
expireInHours: 48,
archiveCompletedAfterSeconds: 60 * 60 * 2, // 2 hours
deleteAfterDays: 2,
retentionDays: 2,
}),
);
+83
View File
@@ -0,0 +1,83 @@
import { db } from "@/server/db";
import { completeEsignDocuments } from "@/server/esign";
import { getPresignedGetUrl } from "@/server/file-uploads";
import { z } from "zod";
import { defineJob, defineWorker, defineWorkerConfig } from "../lib/queue";
import { eSignConfirmationEmailJob } from "./esign-confirmation-email";
export const Schema = z.object({
company: z.object({
name: z.string(),
logo: z.string().nullish(),
}),
templateId: z.string(),
requestIp: z.string(),
userAgent: z.string(),
templateName: z.string(),
companyId: z.string(),
recipients: z.array(
z.object({
email: z.string(),
name: z.string().nullish(),
}),
),
sender: z.object({
email: z.string().email(),
name: z.string(),
}),
bucketData: z.object({
key: z.string(),
name: z.string(),
mimeType: z.string(),
size: z.number(),
}),
});
const config = defineWorkerConfig({
name: "esign.complete-pdf",
schema: Schema,
});
export const eSignCompletePDFJob = defineJob(config);
export const eSignCompletePDFWorker = defineWorker(config, async (job) => {
const {
companyId,
templateName,
requestIp,
userAgent,
sender,
templateId,
recipients,
company,
bucketData,
} = job.data;
await db.$transaction(async (tx) => {
await completeEsignDocuments({
bucketData: bucketData,
companyId,
db: tx,
requestIp,
templateId,
uploaderName: sender.name,
userAgent,
});
});
const file = await getPresignedGetUrl(bucketData.key);
await eSignConfirmationEmailJob.bulkEmit(
recipients.map((recipient) => ({
data: {
fileUrl: file.url,
documentName: templateName,
recipient,
company,
senderName: sender.name,
senderEmail: sender.email,
},
})),
);
});
+56 -50
View File
@@ -1,55 +1,61 @@
import ESignConfirmationEmail from "@/emails/EsignConfirmationEmail";
import { BaseJob } from "@/jobs/base";
import { sendMail } from "@/server/mailer";
import { renderAsync } from "@react-email/components";
import type { Job } from "pg-boss";
import { render } from "@react-email/components";
import { z } from "zod";
import { defineJob, defineWorker, defineWorkerConfig } from "../lib/queue";
export type ConfirmationEmailPayloadType = {
fileUrl: string;
documentName: string;
senderName: string | null;
senderEmail: string | null;
company: {
name: string;
logo?: string | null;
};
recipient: { name?: string | null; email: string };
};
const Schema = z.object({
fileUrl: z.string(),
documentName: z.string(),
senderName: z.string().nullable(),
senderEmail: z.string().nullable(),
company: z.object({
name: z.string(),
logo: z.string().nullish(),
}),
recipient: z.object({
name: z.string().nullish(),
email: z.string().email(),
}),
});
export const sendEsignConfirmationEmail = async (
payload: ConfirmationEmailPayloadType,
) => {
const html = await renderAsync(
ESignConfirmationEmail({
documentName: payload.documentName,
recipient: payload.recipient,
senderName: payload.senderName,
senderEmail: payload.senderEmail,
company: payload.company,
}),
);
await sendMail({
to: payload.recipient.email,
...(payload.senderEmail && { replyTo: payload.senderEmail }),
subject: "Completed e-signed documents from all parties",
html,
attachments: [
{
filename: payload.documentName,
path: payload.fileUrl,
const config = defineWorkerConfig({
name: "email.esign-confirmation",
schema: Schema,
});
export type TSchema = z.infer<typeof Schema>;
export const eSignConfirmationEmailJob = defineJob(config);
export const eSignConfirmationEmailWorker = defineWorker(
config,
async (job) => {
const payload = job.data;
const html = await render(
ESignConfirmationEmail({
documentName: payload.documentName,
recipient: payload.recipient,
senderName: payload.senderName,
senderEmail: payload.senderEmail,
company: payload.company,
}),
);
await sendMail({
to: payload.recipient.email,
...(payload.senderEmail && { replyTo: payload.senderEmail }),
subject: "Completed e-signed documents from all parties",
html,
attachments: [
{
filename: payload.documentName,
path: payload.fileUrl,
},
],
headers: {
"X-From-Name": payload.senderName || "Captable",
},
],
headers: {
"X-From-Name": payload.senderName || "Captable",
},
});
};
export class EsignConfirmationEmailJob extends BaseJob<ConfirmationEmailPayloadType> {
readonly type = "email.esign-confirmation";
async work(job: Job<ConfirmationEmailPayloadType>): Promise<void> {
await sendEsignConfirmationEmail(job.data);
}
}
});
},
);
+85 -58
View File
@@ -1,62 +1,64 @@
import { dayjsExt } from "@/common/dayjs";
import EsignEmail from "@/emails/EsignEmail";
import { env } from "@/env";
import { BaseJob } from "@/jobs/base";
import { EsignAudit } from "@/server/audit";
import { db } from "@/server/db";
import { sendMail } from "@/server/mailer";
import { renderAsync } from "@react-email/components";
import type { Job } from "pg-boss";
import { render } from "@react-email/components";
import { z } from "zod";
import { defineJob, defineWorker, defineWorkerConfig } from "../lib/queue";
export interface EsignEmailPayloadType {
documentName?: string;
message?: string | null;
recipient: {
id: string;
name: string | null | undefined;
email: string;
};
sender?: {
name: string | null | undefined;
email: string | null | undefined;
};
company?: {
name: string;
logo: string | null | undefined;
};
}
const Schema = z.object({
documentName: z.string().optional(),
message: z.string().nullish(),
recipient: z.object({
id: z.string(),
name: z.string().nullish(),
email: z.string().email(),
}),
sender: z
.object({
name: z.string().nullish(),
email: z.string().email().nullish(),
})
.optional(),
company: z
.object({
name: z.string(),
logo: z.string().nullish(),
})
.optional(),
email: z.string().email(),
token: z.string(),
userAgent: z.string(),
requestIp: z.string(),
companyId: z.string(),
});
interface AdditionalPayloadType {
email: string;
token: string;
}
export type TESignNotificationEmailJobInput = z.infer<typeof Schema>;
export type ExtendedEsignPayloadType = EsignEmailPayloadType &
AdditionalPayloadType;
const config = defineWorkerConfig({
name: "email.esign-notification",
schema: Schema,
});
export const sendEsignEmail = async (payload: ExtendedEsignPayloadType) => {
const { email, token, sender, ...rest } = payload;
const baseUrl = env.NEXT_PUBLIC_BASE_URL;
const html = await renderAsync(
EsignEmail({
signingLink: `${baseUrl}/esign/${token}`,
export const eSignNotificationEmailJob = defineJob(config);
export const eSignNotificationEmailWorker = defineWorker(
config,
async (job) => {
const {
email,
token,
sender,
...rest,
}),
);
await sendMail({
to: email,
...(sender?.email && { replyTo: sender.email }),
subject: "eSign Document Request",
html,
headers: {
"X-From-Name": sender?.name || "Captable",
},
});
};
userAgent,
requestIp,
documentName,
companyId,
...rest
} = job.data;
export class EsignNotificationEmailJob extends BaseJob<ExtendedEsignPayloadType> {
readonly type = "email.esign-notification";
const baseUrl = env.NEXT_PUBLIC_BASE_URL;
async work(job: Job<ExtendedEsignPayloadType>): Promise<void> {
await db.$transaction(async (tx) => {
const recipient = await tx.esignRecipient.update({
where: {
@@ -67,16 +69,41 @@ export class EsignNotificationEmailJob extends BaseJob<ExtendedEsignPayloadType>
},
});
await tx.template.update({
where: {
id: recipient.templateId,
await EsignAudit.create(
{
action: "document.email.sent",
companyId,
recipientId: recipient.id,
templateId: recipient.templateId,
ip: requestIp,
location: "",
userAgent,
summary: `${
sender?.name ? sender.name : ""
} sent "${documentName}" to ${
recipient.name ? recipient.name : ""
} for eSignature at ${dayjsExt(new Date()).format("lll")}`,
},
data: {
status: "SENT",
},
});
tx,
);
});
await sendEsignEmail(job.data);
}
}
const html = await render(
EsignEmail({
signingLink: `${baseUrl}/esign/${token}`,
sender,
documentName,
...rest,
}),
);
await sendMail({
to: email,
...(sender?.email && { replyTo: sender.email }),
subject: "eSign Document Request",
html,
headers: {
"X-From-Name": sender?.name || "Captable",
},
});
},
);
+78 -86
View File
@@ -1,100 +1,92 @@
import { BaseJob } from "@/jobs/base";
import { db } from "@/server/db";
import {
type EsignGetTemplateType,
completeEsignDocuments,
generateEsignPdf,
getEsignAudits,
uploadEsignDocuments,
} from "@/server/esign";
import { getPresignedGetUrl } from "@/server/file-uploads";
import type { Job } from "pg-boss";
import { dayjsExt } from "@/common/dayjs";
import { EsignAudit } from "@/server/audit";
import { db } from "@/server/db";
import { z } from "zod";
import { defineJob, defineWorker, defineWorkerConfig } from "../lib/queue";
import {
type ConfirmationEmailPayloadType,
EsignConfirmationEmailJob,
} from "./esign-confirmation-email";
Schema as EsignCompleteSchema,
eSignCompletePDFJob,
} from "./esign-complete-pdf";
export type EsignPdfPayloadType = {
fields: EsignGetTemplateType["fields"];
company: {
name: string;
logo?: string | null | undefined;
};
data: Record<string, string>;
templateId: string;
requestIp: string;
userAgent: string;
audits: {
id: string;
summary: string;
action: string;
occurredAt: Date;
}[];
bucketKey: string;
templateName: string;
companyId: string;
recipients: { email: string; name?: string | null }[];
sender: { email: string; name?: string | null };
};
const fields = z.array(z.any()) as z.ZodType<EsignGetTemplateType["fields"]>;
export class EsignPdfJob extends BaseJob<EsignPdfPayloadType> {
readonly type = "generate.esign-pdf";
const schema = z
.object({
fields,
data: z.record(z.string()),
bucketKey: z.string(),
})
.merge(EsignCompleteSchema.omit({ bucketData: true }));
async work(job: Job<EsignPdfPayloadType>): Promise<void> {
const {
bucketKey,
data,
audits,
fields,
export type TEsignPdfSchema = z.infer<typeof schema>;
const config = defineWorkerConfig({
name: "esign.generate-pdf",
schema,
});
export const eSignPdfJob = defineJob(config);
export const eSignPdfWorker = defineWorker(config, async (job) => {
const {
bucketKey,
data,
fields,
companyId,
templateName,
requestIp,
userAgent,
sender,
templateId,
recipients,
company,
} = job.data;
await EsignAudit.create(
{
action: "document.complete",
companyId,
templateName,
requestIp,
userAgent,
sender,
templateId,
recipients,
company,
} = job.data;
ip: requestIp,
location: "",
userAgent: userAgent,
summary: `"${templateName}" completely signed at ${dayjsExt(
new Date(),
).format("lll")}`,
},
db,
);
const modifiedPdfBytes = await generateEsignPdf({
bucketKey,
data,
fields,
audits,
templateName,
});
const { fileUrl: _fileUrl, ...bucketData } = await uploadEsignDocuments({
buffer: Buffer.from(modifiedPdfBytes),
companyId,
templateName,
});
const audits = await getEsignAudits({ templateId, tx: db });
await db.$transaction(async (tx) => {
await completeEsignDocuments({
bucketData: bucketData,
companyId,
db: tx,
requestIp,
templateId,
templateName,
uploaderName: sender.name || "Captable",
userAgent,
});
});
const modifiedPdfBytes = await generateEsignPdf({
bucketKey,
data,
fields,
audits,
templateName,
});
const { fileUrl: _fileUrl, ...bucketData } = await uploadEsignDocuments({
buffer: Buffer.from(modifiedPdfBytes),
companyId,
templateName,
});
const file = await getPresignedGetUrl(bucketData.key);
const recipientData: { data: ConfirmationEmailPayloadType }[] =
recipients.map((recipient) => ({
data: {
fileUrl: file.url,
documentName: templateName,
recipient,
company,
senderName: sender.name || "Captable",
senderEmail: sender.email as string,
},
}));
await new EsignConfirmationEmailJob().bulkEmit(recipientData);
}
}
await eSignCompletePDFJob.emit({
requestIp,
userAgent,
sender,
templateId,
recipients,
company,
bucketData,
companyId,
templateName,
});
});
+24 -30
View File
@@ -1,30 +1,32 @@
import MemberInviteEmail from "@/emails/MemberInviteEmail";
import { env } from "@/env";
import { BaseJob } from "@/jobs/base";
import { constants } from "@/lib/constants";
import { sendMail } from "@/server/mailer";
import { renderAsync } from "@react-email/components";
import type { Job } from "pg-boss";
import { render } from "@react-email/components";
import { z } from "zod";
import { defineJob, defineWorker, defineWorkerConfig } from "../lib/queue";
type MemberInvitePayloadType = {
email: string;
passwordResetToken: string;
user: {
email?: string | null | undefined;
name?: string | null | undefined;
};
verificationToken: string;
company: {
name: string;
id: string;
};
};
const config = defineWorkerConfig({
name: "email.member-invite",
schema: z.object({
email: z.string(),
passwordResetToken: z.string(),
user: z.object({
email: z.string().email().nullish(),
name: z.string().nullish(),
}),
verificationToken: z.string(),
company: z.object({
name: z.string(),
id: z.string(),
}),
}),
});
export const sendMemberInviteEmail = async (
payload: MemberInvitePayloadType,
) => {
export const sendMemberInviteEmailJob = defineJob(config);
export const sendMemberInviteEmailWorker = defineWorker(config, async (job) => {
const { email, passwordResetToken, verificationToken, company, user } =
payload;
job.data;
const baseUrl = env.NEXT_PUBLIC_BASE_URL;
@@ -39,7 +41,7 @@ export const sendMemberInviteEmail = async (
await sendMail({
to: email,
subject: `Join ${company.name} on ${constants.title}`,
html: await renderAsync(
html: await render(
MemberInviteEmail({
inviteLink,
companyName: company.name,
@@ -47,12 +49,4 @@ export const sendMemberInviteEmail = async (
}),
),
});
};
export class SendMemberInviteEmailJob extends BaseJob<MemberInvitePayloadType> {
readonly type = "email.member-invite";
async work(job: Job<MemberInvitePayloadType>): Promise<void> {
await sendMemberInviteEmail(job.data);
}
}
});
+15 -21
View File
@@ -1,24 +1,26 @@
import PasswordResetEmail from "@/emails/PasswordResetEmail";
import { env } from "@/env";
import { BaseJob } from "@/jobs/base";
import { sendMail } from "@/server/mailer";
import { renderAsync } from "@react-email/components";
import type { Job } from "pg-boss";
import { render } from "@react-email/components";
import { z } from "zod";
import { defineJob, defineWorker, defineWorkerConfig } from "../lib/queue";
export type PasswordResetPayloadType = {
email: string;
token: string;
};
const config = defineWorkerConfig({
name: "email.password-reset",
schema: z.object({
email: z.string(),
token: z.string(),
}),
});
export const sendPasswordResetEmail = async (
payload: PasswordResetPayloadType,
) => {
const { email, token } = payload;
export const passwordResetEmailJob = defineJob(config);
export const passwordResetEmailWorker = defineWorker(config, async (job) => {
const { email, token } = job.data;
const baseUrl = env.NEXT_PUBLIC_BASE_URL;
const confirmLink = `${baseUrl}/reset-password/${token}`;
const html = await renderAsync(
const html = await render(
PasswordResetEmail({
resetLink: confirmLink,
}),
@@ -29,12 +31,4 @@ export const sendPasswordResetEmail = async (
subject: "Reset your password",
html,
});
};
export class PasswordResetEmailJob extends BaseJob<PasswordResetPayloadType> {
readonly type = "email.password-reset";
async work(job: Job<PasswordResetPayloadType>): Promise<void> {
await sendPasswordResetEmail(job.data);
}
}
});
+25
View File
@@ -0,0 +1,25 @@
import { queueManager } from "../lib/queue";
import { authVerificationEmailWorker } from "./auth-verification-email";
import { eSignCompletePDFWorker } from "./esign-complete-pdf";
import { eSignConfirmationEmailWorker } from "./esign-confirmation-email";
import { eSignNotificationEmailWorker } from "./esign-email";
import { eSignPdfWorker } from "./esign-pdf";
import { sendMemberInviteEmailWorker } from "./member-inivite-email";
import { passwordResetEmailWorker } from "./password-reset-email";
import { shareDataRoomEmailWorker } from "./share-data-room-email";
import { shareUpdateEmailWorker } from "./share-update-email";
export async function registerJobs() {
queueManager.register(shareDataRoomEmailWorker);
queueManager.register(shareUpdateEmailWorker);
queueManager.register(passwordResetEmailWorker);
queueManager.register(sendMemberInviteEmailWorker);
queueManager.register(eSignNotificationEmailWorker);
queueManager.register(authVerificationEmailWorker);
queueManager.register(eSignConfirmationEmailWorker);
queueManager.register(eSignPdfWorker);
queueManager.register(eSignCompletePDFWorker);
//start
await queueManager.start();
}
+21 -26
View File
@@ -1,22 +1,25 @@
import ShareDataRoomEmail from "@/emails/ShareDataRoomEmail";
import { BaseJob } from "@/jobs/base";
import { sendMail } from "@/server/mailer";
import { renderAsync } from "@react-email/components";
import type { Job } from "pg-boss";
import { render } from "@react-email/components";
import { z } from "zod";
import { defineJob, defineWorker, defineWorkerConfig } from "../lib/queue";
export type DataRoomEmailPayloadType = {
link: string;
dataRoom: string;
email: string;
senderName: string;
companyName: string;
recipientName?: string | null | undefined;
senderEmail?: string | null | undefined;
};
const config = defineWorkerConfig({
name: "email.share-data-room",
schema: z.object({
link: z.string(),
dataRoom: z.string(),
email: z.string().email(),
senderName: z.string(),
companyName: z.string(),
recipientName: z.string().nullish(),
senderEmail: z.string().email().nullish(),
}),
});
export const sendShareDataRoomEmail = async (
payload: DataRoomEmailPayloadType,
) => {
export const shareDataRoomEmailJob = defineJob(config);
export const shareDataRoomEmailWorker = defineWorker(config, async (job) => {
const {
dataRoom,
link,
@@ -25,12 +28,12 @@ export const sendShareDataRoomEmail = async (
senderName,
email,
senderEmail,
} = payload;
} = job.data;
await sendMail({
to: email,
...(senderEmail && { replyTo: senderEmail }),
subject: `${senderName} shared a data room - ${dataRoom}`,
html: await renderAsync(
html: await render(
ShareDataRoomEmail({
senderName: senderName,
recipientName,
@@ -44,12 +47,4 @@ export const sendShareDataRoomEmail = async (
"X-From-Name": senderName,
},
});
};
export class ShareDataRoomEmailJob extends BaseJob<DataRoomEmailPayloadType> {
readonly type = "email.share-data-room";
async work(job: Job<DataRoomEmailPayloadType>): Promise<void> {
await sendShareDataRoomEmail(job.data);
}
}
});
+23 -26
View File
@@ -1,22 +1,27 @@
import ShareUpdateEmail from "@/emails/ShareUpdateEmail";
import { BaseJob } from "@/jobs/base";
import { sendMail } from "@/server/mailer";
import { renderAsync } from "@react-email/components";
import type { Job } from "pg-boss";
import { render } from "@react-email/components";
import { z } from "zod";
import { defineJob, defineWorker, defineWorkerConfig } from "../lib/queue";
export type UpdateSharePayloadType = {
update: {
title: string;
};
link: string;
companyName: string;
senderName: string;
email: string;
recipientName?: string | null | undefined;
senderEmail?: string | null | undefined;
};
const config = defineWorkerConfig({
name: "email.share-update",
schema: z.object({
update: z.object({
title: z.string(),
}),
link: z.string(),
companyName: z.string(),
senderName: z.string(),
email: z.string().email(),
recipientName: z.string().nullish(),
senderEmail: z.string().email().nullish(),
}),
});
export const sendShareUpdateEmail = async (payload: UpdateSharePayloadType) => {
export const shareUpdateEmailJob = defineJob(config);
export const shareUpdateEmailWorker = defineWorker(config, async (job) => {
const {
update,
link,
@@ -25,12 +30,12 @@ export const sendShareUpdateEmail = async (payload: UpdateSharePayloadType) => {
senderName,
email,
senderEmail,
} = payload;
} = job.data;
await sendMail({
to: email,
...(senderEmail && { replyTo: senderEmail }),
subject: `${senderName} shared an update - ${update.title}`,
html: await renderAsync(
html: await render(
ShareUpdateEmail({
senderName: senderName,
recipientName,
@@ -44,12 +49,4 @@ export const sendShareUpdateEmail = async (payload: UpdateSharePayloadType) => {
"X-From-Name": senderName,
},
});
};
export class ShareUpdateEmailJob extends BaseJob<UpdateSharePayloadType> {
readonly type = "email.share-update";
async work(job: Job<UpdateSharePayloadType>): Promise<void> {
await sendShareUpdateEmail(job.data);
}
}
});
-23
View File
@@ -1,23 +0,0 @@
import { AuthVerificationEmailJob } from "@/jobs/auth-verification-email";
import { JobManager, boss } from "@/jobs/base";
import { EsignConfirmationEmailJob } from "@/jobs/esign-confirmation-email";
import { EsignNotificationEmailJob } from "@/jobs/esign-email";
import { EsignPdfJob } from "@/jobs/esign-pdf";
import { SendMemberInviteEmailJob } from "@/jobs/member-inivite-email";
import { PasswordResetEmailJob } from "@/jobs/password-reset-email";
import { ShareDataRoomEmailJob } from "@/jobs/share-data-room-email";
import { ShareUpdateEmailJob } from "@/jobs/share-update-email";
export async function startJobs() {
const jobs = new JobManager(boss)
.register(AuthVerificationEmailJob)
.register(ShareUpdateEmailJob)
.register(ShareDataRoomEmailJob)
.register(SendMemberInviteEmailJob)
.register(PasswordResetEmailJob)
.register(EsignNotificationEmailJob)
.register(EsignConfirmationEmailJob)
.register(EsignPdfJob);
await jobs.start();
}
+121
View File
@@ -0,0 +1,121 @@
import type { JOB_TYPES } from "@/constants/job";
import { logger } from "@/lib/logger";
import { singleton } from "@/lib/singleton";
import PgBoss from "pg-boss";
import type { z } from "zod";
const CONNECTION_URL =
process.env.QUEUE_DATABASE_URL ?? (process.env.DATABASE_URL as string);
const queue = singleton(
"pg-boss",
() =>
new PgBoss({
connectionString: CONNECTION_URL,
max: 5,
retryBackoff: true,
retryLimit: 4,
archiveCompletedAfterSeconds: 60 * 60 * 2, // 2 hours
deleteAfterDays: 2,
retentionDays: 2,
schema: "captable_queue",
}),
);
type JobTypes = typeof JOB_TYPES;
const log = logger.child({ module: "queue" });
const logPrefix = (msg: string) => `[Queue] ${msg}`;
export type JobType = {
[Key in keyof JobTypes]: `${Key}.${JobTypes[Key][number]}`;
}[keyof JobTypes];
interface WorkerFactory {
name: JobType;
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
handler: PgBoss.WorkHandler<any>;
}
function createQueue() {
const jobs = new Map<string, WorkerFactory>();
async function start() {
log.info(logPrefix("Starting queue manager"));
queue.on("error", (error) => {
log.error(logPrefix("error"), {
type: "queue",
error,
});
});
await queue.start();
for (const [jobName, job] of jobs.entries()) {
log.info(logPrefix(`Registering job:${jobName}`));
await queue.createQueue(jobName);
await queue.work(jobName, job.handler);
}
}
function register(jobFactory: WorkerFactory) {
jobs.set(jobFactory.name, jobFactory);
}
return { start, register };
}
const queueManager = createQueue();
function defineWorker<
U extends ReturnType<typeof defineWorkerConfig>,
T extends U["schema"],
V,
>(config: U, handler: (data: PgBoss.Job<z.infer<T>>) => Promise<V>) {
return {
name: config.name,
handler: ([job]: PgBoss.Job<z.infer<T>>[]) => {
if (!job) {
throw new Error("");
}
return handler(job);
},
};
}
function defineJob<U extends ReturnType<typeof defineWorkerConfig>>(config: U) {
type TSchema = U["schema"];
return {
emit: (data: z.infer<TSchema>, options?: PgBoss.JobOptions) => {
const data_ = config.schema.parse(data);
return queue.send(config.name, data_, options ?? {});
},
bulkEmit: (data: Omit<PgBoss.JobInsert<z.infer<TSchema>>, "name">[]) => {
for (const item of data) {
config.schema.parse(item.data);
}
return queue.insert(
data.map((items) => ({ ...items, name: config.name })),
);
},
};
}
interface defineWorkerConfigOptions<T> {
schema: T;
name: JobType;
}
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
function defineWorkerConfig<T extends z.ZodObject<any>>(
opts: defineWorkerConfigOptions<T>,
) {
return opts;
}
export { queue, queueManager, defineWorkerConfig, defineJob, defineWorker };
+3 -3
View File
@@ -3,9 +3,9 @@
import { Form } from "@/components/ui/form";
import { COLORS } from "@/constants/esign";
import { FieldTypes, TemplateStatus } from "@/prisma/enums";
import { type RouterOutputs } from "@/trpc/shared";
import type { RouterOutputs } from "@/trpc/shared";
import { zodResolver } from "@hookform/resolvers/zod";
import { type ReactNode } from "react";
import type { ReactNode } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -19,7 +19,7 @@ interface TemplateFieldProviderProps {
}
const formSchema = z.object({
status: z.nativeEnum(TemplateStatus),
status: z.enum([TemplateStatus.PENDING, TemplateStatus.DRAFT]),
fieldType: z.nativeEnum(FieldTypes).optional(),
fields: z
.array(
+1 -17
View File
@@ -224,7 +224,6 @@ export async function uploadEsignDocuments({
}
export interface CompleteEsignDocumentsOptionsType {
templateName: string;
companyId: string;
db: PrismaTransactionalClient;
requestIp: string;
@@ -239,7 +238,6 @@ export async function completeEsignDocuments({
db,
requestIp,
templateId,
templateName,
uploaderName,
userAgent,
bucketData,
@@ -250,24 +248,10 @@ export async function completeEsignDocuments({
},
data: {
completedOn: new Date(),
status: "COMPLETE",
},
});
await EsignAudit.create(
{
action: "document.complete",
companyId,
templateId,
ip: requestIp,
location: "",
userAgent: userAgent,
summary: `"${templateName}" completely signed at ${dayjsExt(
new Date(),
).format("lll")}`,
},
db,
);
const { id: bucketId, name } = await createBucketHandler({
db,
input: { ...bucketData, tags: [TAG.ESIGN] },
+192
View File
@@ -0,0 +1,192 @@
import { dayjsExt } from "@/common/dayjs";
import {
type TESignNotificationEmailJobInput,
eSignNotificationEmailJob,
} from "@/jobs/esign-email";
import { type TEsignPdfSchema, eSignPdfJob } from "@/jobs/esign-pdf";
import { EsignAudit } from "@/server/audit";
import type { TEsignAuditSchema } from "@/server/audit/schema";
import type { TPrismaOrTransaction } from "@/server/db";
import { type EsignGetTemplateType, getEsignTemplate } from "@/server/esign";
import { EncodeEmailToken } from "@/trpc/routers/template-field-router/procedures/add-fields";
import type { EsignRecipientStatus } from "@prisma/client";
export class EsignService {
getTemplate(templateId: string, tx: TPrismaOrTransaction) {
return getEsignTemplate({
templateId,
tx,
});
}
getRecipient(
recipientId: string,
templateId: string,
tx: TPrismaOrTransaction,
) {
return tx.esignRecipient.findFirstOrThrow({
where: {
id: recipientId,
templateId,
},
});
}
updateRecipientStatus(
recipientId: string,
status: EsignRecipientStatus,
tx: TPrismaOrTransaction,
) {
return tx.esignRecipient.update({
where: { id: recipientId },
data: { status },
});
}
async handleOrderedDelivery(
template: EsignGetTemplateType,
data: Pick<
TESignNotificationEmailJobInput,
"sender" | "requestIp" | "userAgent"
>,
tx: TPrismaOrTransaction,
) {
const nextDelivery = await tx.esignRecipient.findFirst({
where: {
templateId: template.id,
status: "PENDING",
},
select: {
id: true,
email: true,
name: true,
},
});
if (nextDelivery) {
const token = await EncodeEmailToken({
recipientId: nextDelivery.id,
templateId: template.id,
});
const email = nextDelivery.email;
await eSignNotificationEmailJob.emit({
email,
token,
sender: data.sender,
message: template.message,
documentName: template.name,
recipient: nextDelivery,
company: template.company,
companyId: template.companyId,
requestIp: data.requestIp,
userAgent: data.userAgent,
});
}
}
async createSignedAuditLog(
data: Omit<TEsignAuditSchema, "action" | "summary"> & {
recipientName?: string | null;
templateName: string;
browser?: string;
},
tx: TPrismaOrTransaction,
) {
const { recipientName, templateName, browser, ...rest } = data;
await EsignAudit.create(
{
action: "recipient.signed",
...rest,
summary: `${
recipientName ?? "unknown recipient"
} signed "${templateName}" on ${
browser ?? "unknown browser"
} at ${dayjsExt(new Date()).format("lll")}`,
},
tx,
);
}
async completeDocument(
options: Omit<TEsignPdfSchema, "sender"> & {
sender: {
name: string | null;
email: string | null;
};
},
) {
await eSignPdfJob.emit(
{
...options,
sender: {
name: options.sender.name ?? "Captable",
email: options.sender.email ?? "Unknown email",
},
},
{
singletonKey: `esign-${options.templateId}`,
},
);
}
getAllRecipients(templateId: string, tx: TPrismaOrTransaction) {
return tx.esignRecipient.findMany({
where: {
templateId,
},
select: {
email: true,
name: true,
status: true,
},
});
}
async saveFieldValues(
fields: EsignGetTemplateType["fields"],
data: Record<string, string>,
tx: TPrismaOrTransaction,
) {
for (const field of fields) {
const value = data?.[field?.id];
if (value) {
await tx.templateField.update({
where: {
id: field.id,
},
data: {
prefilledValue: value,
},
});
}
}
}
async getFieldValues(templateId: string, tx: TPrismaOrTransaction) {
const values = await tx.templateField.findMany({
where: {
templateId,
prefilledValue: {
not: null,
},
},
select: {
id: true,
prefilledValue: true,
},
});
const data: Record<string, string> = values.reduce<Record<string, string>>(
(prev, curr) => {
prev[curr.id] = curr.prefilledValue ?? "";
return prev;
},
{},
);
return data;
}
}
@@ -1,4 +1,4 @@
import { PasswordResetEmailJob } from "@/jobs/password-reset-email";
import { passwordResetEmailJob } from "@/jobs/password-reset-email";
import { generatePasswordResetToken } from "@/lib/token";
import { getUserByEmail } from "@/server/user";
import { withoutAuth } from "@/trpc/api/trpc";
@@ -18,7 +18,7 @@ export const forgotPasswordProcedure = withoutAuth
}
const { email, token } = await generatePasswordResetToken(input);
await new PasswordResetEmailJob().emit({ email, token });
await passwordResetEmailJob.emit({ email, token });
return {
success: true,
@@ -1,4 +1,4 @@
import { sendAuthVerificationEmail } from "@/jobs/auth-verification-email";
import { authVerificationEmailJob } from "@/jobs/auth-verification-email";
import { generateVerificationToken } from "@/lib/token";
import { getVerificationTokenByEmail } from "@/server/verification-token";
@@ -19,7 +19,7 @@ export const resendEmailProcedure = withoutAuth
}
const verificationToken = await generateVerificationToken(input);
await sendAuthVerificationEmail({
await authVerificationEmailJob.emit({
email: verificationToken.identifier,
token: verificationToken.token,
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { sendAuthVerificationEmail } from "@/jobs/auth-verification-email";
import { authVerificationEmailJob } from "@/jobs/auth-verification-email";
import { generateVerificationToken } from "@/lib/token";
import { Audit } from "@/server/audit";
@@ -52,7 +52,7 @@ export const signupProcedure = withoutAuth
const verificationToken = await generateVerificationToken(email);
await sendAuthVerificationEmail({
await authVerificationEmailJob.emit({
email: verificationToken.identifier,
token: verificationToken.token,
});
+3 -8
View File
@@ -1,9 +1,6 @@
import { generatePublicId } from "@/common/id";
import { env } from "@/env";
import {
type DataRoomEmailPayloadType,
ShareDataRoomEmailJob,
} from "@/jobs/share-data-room-email";
import { shareDataRoomEmailJob } from "@/jobs/share-data-room-email";
import { encode } from "@/lib/jwt";
import { ShareRecipientSchema } from "@/schema/contacts";
import { Audit } from "@/server/audit";
@@ -285,7 +282,7 @@ export const dataRoomRouter = createTRPCRouter({
const link = `${baseUrl}/data-rooms/${dataRoom.publicId}?token=${token}`;
const payload: DataRoomEmailPayloadType = {
await shareDataRoomEmailJob.emit({
senderName: `${senderName}`,
recipientName: recipient.name,
companyName: company.name,
@@ -293,9 +290,7 @@ export const dataRoomRouter = createTRPCRouter({
link,
email,
senderEmail,
};
await new ShareDataRoomEmailJob().emit(payload);
});
await db.$transaction(async (tx) => {
await Audit.create(
@@ -1,4 +1,4 @@
import { SendMemberInviteEmailJob } from "@/jobs/member-inivite-email";
import { sendMemberInviteEmailJob } from "@/jobs/member-inivite-email";
import { getRoleById } from "@/lib/rbac/access-control";
import { generatePasswordResetToken } from "@/lib/token";
import { Audit } from "@/server/audit";
@@ -141,7 +141,7 @@ export const inviteMemberProcedure = withAccessControl
},
);
const payload = {
await sendMemberInviteEmailJob.emit({
verificationToken,
passwordResetToken,
email,
@@ -150,9 +150,7 @@ export const inviteMemberProcedure = withAccessControl
email: user.email,
name: user.name,
},
};
await new SendMemberInviteEmailJob().emit(payload);
});
return { success: true };
});
@@ -1,4 +1,4 @@
import { SendMemberInviteEmailJob } from "@/jobs/member-inivite-email";
import { sendMemberInviteEmailJob } from "@/jobs/member-inivite-email";
import { generatePasswordResetToken } from "@/lib/token";
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
@@ -93,7 +93,7 @@ export const reInviteProcedure = withAuth
return { verificationToken, company, email, passwordResetToken };
});
const payload = {
await sendMemberInviteEmailJob.emit({
verificationToken,
passwordResetToken,
email,
@@ -102,9 +102,7 @@ export const reInviteProcedure = withAuth
email: user.email,
name: user.name,
},
};
await new SendMemberInviteEmailJob().emit(payload);
});
return { success: true };
});
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/prefer-for-of */
import {
EsignNotificationEmailJob,
type ExtendedEsignPayloadType,
type TESignNotificationEmailJobInput,
eSignNotificationEmailJob,
} from "@/jobs/esign-email";
import { decode, encode } from "@/lib/jwt";
import { Audit } from "@/server/audit";
@@ -46,9 +45,9 @@ export const addFieldProcedure = withAuth
try {
const user = ctx.session.user;
const { userAgent, requestIp } = ctx;
const mails: ExtendedEsignPayloadType[] = [];
const mails: TESignNotificationEmailJobInput[] = [];
if (input.status === "COMPLETE" && (!user.email || !user.name)) {
if (input.status === "PENDING" && (!user.email || !user.name)) {
return {
success: false,
title: "Validation failed",
@@ -147,7 +146,7 @@ export const addFieldProcedure = withAuth
},
});
if (input.status === "COMPLETE") {
if (input.status === "PENDING") {
const nonDeletableRecipientIdList = recipientList.map(
(item) => item.id,
);
@@ -190,6 +189,9 @@ export const addFieldProcedure = withAuth
name: template.company.name,
logo: template.company.logo,
},
requestIp,
companyId,
userAgent,
});
if (template.orderedDelivery) {
@@ -202,7 +204,7 @@ export const addFieldProcedure = withAuth
});
if (mails.length) {
new EsignNotificationEmailJob().bulkEmit(
await eSignNotificationEmailJob.bulkEmit(
mails.map((data) => ({
data,
singletonKey: `esign-notify-${template.id}-${data.recipient.id}`,
@@ -213,9 +215,9 @@ export const addFieldProcedure = withAuth
return {
success: true,
title:
input.status === "COMPLETE" ? "Sent for e-sign" : "Saved in draft",
input.status === "PENDING" ? "Sent for e-sign" : "Saved in draft",
message:
input.status === "COMPLETE"
input.status === "PENDING"
? "Successfully sent document for e-signature."
: "Your template fields has been created.",
};
@@ -12,7 +12,7 @@ const TemplateFieldMetaType = z.object({
export type TTemplateFieldMetaType = z.infer<typeof TemplateFieldMetaType>;
export const ZodAddFieldMutationSchema = z.object({
status: z.nativeEnum(TemplateStatus),
status: z.enum([TemplateStatus.DRAFT, TemplateStatus.PENDING]),
templatePublicId: z.string(),
data: z.array(
z.object({
@@ -25,6 +25,9 @@ export const getSigningFieldsProcedure = withoutAuth
const { bucket, status } = await tx.template.findFirstOrThrow({
where: {
id: recipient.templateId,
status: {
notIn: ["COMPLETE", "DRAFT"],
},
},
select: {
bucket: {
@@ -1,19 +1,6 @@
/* eslint-disable @typescript-eslint/prefer-for-of */
import { dayjsExt } from "@/common/dayjs";
import { EsignNotificationEmailJob } from "@/jobs/esign-email";
import { EsignPdfJob } from "@/jobs/esign-pdf";
import type { TemplateStatus } from "@/prisma/enums";
import { EsignAudit } from "@/server/audit";
import {
type CompleteEsignDocumentsOptionsType,
type GenerateEsignSignPdfOptionsType,
getEsignAudits,
getEsignTemplate,
type uploadEsignDocumentsOptions,
} from "@/server/esign";
import { EsignService } from "@/services/esign-service";
import { withoutAuth } from "@/trpc/api/trpc";
import { EncodeEmailToken } from "../../template-field-router/procedures/add-fields";
import { UAParser } from "ua-parser-js";
import { SignTemplateMutationSchema } from "../schema";
export const signTemplateProcedure = withoutAuth
@@ -21,165 +8,55 @@ export const signTemplateProcedure = withoutAuth
.mutation(async ({ ctx, input }) => {
const { db, requestIp, userAgent } = ctx;
const userAgentParser = new UAParser(userAgent);
const userAgentResult = userAgentParser.getResult();
const browser = userAgentResult.browser.name;
const eSignService = new EsignService();
await db.$transaction(async (tx) => {
const template = await getEsignTemplate({
templateId: input.templateId,
tx,
});
const template = await eSignService.getTemplate(input.templateId, tx);
const bucketKey = template.bucket.key;
const companyId = template.companyId;
const templateName = template.name;
const sender = template.uploader.user;
let templateStatus: TemplateStatus = "WAITING";
const totalGroups = new Set(
const allRecipients = new Set(
template.fields.map((item) => item.recipientId),
);
const isMultiRecipient = allRecipients.size > 1;
const isOrderedDelivery = template.orderedDelivery;
const recipient = await tx.esignRecipient.findFirstOrThrow({
where: {
id: input.recipientId,
const recipient = await eSignService.getRecipient(
input.recipientId,
template.id,
tx,
);
await eSignService.updateRecipientStatus(recipient.id, "SIGNED", tx);
await eSignService.createSignedAuditLog(
{
browser,
companyId,
recipientId: recipient.id,
templateId: template.id,
ip: ctx.requestIp,
location: "",
userAgent: ctx.userAgent,
templateName: template.name,
recipientName: recipient.name,
},
});
tx,
);
await tx.esignRecipient.update({
where: { id: recipient.id },
data: { status: "SIGNED" },
});
if (totalGroups.size > 1) {
for (const field of template.fields) {
const value = input?.data?.[field?.id];
if (value) {
await tx.templateField.update({
where: {
id: field.id,
},
data: {
prefilledValue: value,
},
});
}
}
await EsignAudit.create(
{
action: "recipient.signed",
companyId: template.companyId,
recipientId: recipient.id,
templateId: template.id,
ip: ctx.requestIp,
location: "",
userAgent: ctx.userAgent,
summary: `${recipient.name ? recipient.name : ""} signed "${
template.name
}" on ${ctx.userAgent} at ${dayjsExt(new Date()).format("lll")}`,
},
tx,
);
const allRecipients = await tx.esignRecipient.findMany({
where: {
templateId: template.id,
},
select: {
email: true,
name: true,
status: true,
},
});
const signableRecipients = allRecipients.filter(
(item) => item.status !== "SIGNED",
).length;
if (signableRecipients === 0) {
const values = await tx.templateField.findMany({
where: {
templateId: template.id,
prefilledValue: {
not: null,
},
},
select: {
id: true,
prefilledValue: true,
},
});
const data: Record<string, string> = values.reduce<
Record<string, string>
>((prev, curr) => {
prev[curr.id] = curr.prefilledValue ?? "";
return prev;
}, {});
await EsignAudit.create(
{
action: "recipient.signed",
companyId: template.companyId,
recipientId: recipient.id,
templateId: template.id,
ip: ctx.requestIp,
location: "",
userAgent: ctx.userAgent,
summary: `${recipient.name ? recipient.name : ""} signed "${
template.name
}" on ${ctx.userAgent} at ${dayjsExt(new Date()).format("lll")}`,
},
tx,
);
await signPdf({
bucketKey,
companyId,
templateName,
fields: template.fields,
data,
templateId: template.id,
db: tx,
requestIp,
userAgent,
company: template.company,
recipients: allRecipients.map((item) => ({
email: item.email,
name: item.name,
})),
sender,
uploaderName: sender.name || "Captable",
});
templateStatus = "COMPLETE";
}
} else {
await EsignAudit.create(
{
action: "recipient.signed",
companyId: template.companyId,
recipientId: recipient.id,
templateId: template.id,
ip: ctx.requestIp,
location: "",
userAgent: ctx.userAgent,
summary: `${recipient.name ? recipient.name : ""} signed "${
template.name
}" on ${ctx.userAgent} at ${dayjsExt(new Date()).format("lll")}`,
},
tx,
);
await signPdf({
bucketKey,
if (!isMultiRecipient) {
await eSignService.completeDocument({
companyId,
templateName,
fields: template.fields,
data: input.data,
templateId: template.id,
db: tx,
requestIp,
userAgent,
sender,
@@ -190,118 +67,54 @@ export const signTemplateProcedure = withoutAuth
},
],
company: template.company,
uploaderName: sender.name || "Captable",
bucketKey,
});
templateStatus = "COMPLETE";
}
await tx.template.update({
where: {
id: template.id,
},
data: {
status: templateStatus,
},
});
if (isMultiRecipient) {
await eSignService.saveFieldValues(template.fields, input.data, tx);
if (template.orderedDelivery) {
const nextDelivery = await tx.esignRecipient.findFirst({
where: {
templateId: template.id,
status: "PENDING",
},
select: {
id: true,
email: true,
name: true,
},
});
if (nextDelivery) {
const token = await EncodeEmailToken({
recipientId: nextDelivery.id,
templateId: template.id,
});
const email = nextDelivery.email;
const allRecipients = await eSignService.getAllRecipients(
template.id,
tx,
);
const areAllRecipientsSigned = allRecipients.every(
(recipient) => recipient.status === "SIGNED",
);
await EsignAudit.create(
{
action: "document.email.sent",
companyId: template.companyId,
recipientId: recipient.id,
templateId: template.id,
ip: ctx.requestIp,
location: "",
userAgent: ctx.userAgent,
summary: `${sender.name ? sender.name : ""} sent "${
template.name
}" to ${
recipient.name ? recipient.name : ""
} for eSignature at ${dayjsExt(new Date()).format("lll")}`,
},
if (areAllRecipientsSigned) {
const fieldValues = await eSignService.getFieldValues(
template.id,
tx,
);
await new EsignNotificationEmailJob().emit({
email,
token,
sender,
message: template.message,
documentName: template.name,
recipient: nextDelivery,
await eSignService.completeDocument({
bucketKey,
companyId,
templateName,
fields: template.fields,
data: fieldValues,
templateId: template.id,
requestIp,
userAgent,
company: template.company,
recipients: allRecipients.map((item) => ({
email: item.email,
name: item.name,
})),
sender,
});
}
}
if (isOrderedDelivery) {
await eSignService.handleOrderedDelivery(
template,
{ sender, requestIp, userAgent },
tx,
);
}
});
return {};
});
interface TSignPdfOptions
extends Omit<GenerateEsignSignPdfOptionsType, "audits">,
Omit<uploadEsignDocumentsOptions, "buffer">,
Omit<CompleteEsignDocumentsOptionsType, "bucketData"> {
templateId: string;
company: {
name: string;
logo?: string | null;
};
sender: { name: string | null; email: string | null };
recipients: { name: string | null; email: string }[];
}
async function signPdf({
userAgent,
requestIp,
db,
bucketKey,
companyId,
templateName,
data,
fields,
sender,
templateId,
recipients,
company,
}: TSignPdfOptions) {
const audits = await getEsignAudits({ templateId, tx: db });
await new EsignPdfJob().emit(
{
bucketKey,
data,
fields,
audits,
companyId,
requestIp,
templateId,
templateName,
userAgent,
recipients,
sender: sender as { email: string; name: string },
company,
},
{ singletonKey: `esign-${templateId}`, useSingletonQueue: true },
);
}
@@ -1,8 +1,5 @@
import { env } from "@/env";
import {
ShareUpdateEmailJob,
type UpdateSharePayloadType,
} from "@/jobs/share-update-email";
import { shareUpdateEmailJob } from "@/jobs/share-update-email";
import { encode } from "@/lib/jwt";
import { UpdateStatusEnum } from "@/prisma/enums";
import { ShareRecipientSchema } from "@/schema/contacts";
@@ -88,7 +85,7 @@ export const shareUpdateProcedure = withAuth
const link = `${baseUrl}/updates/${update.publicId}?token=${token}`;
const payload: UpdateSharePayloadType = {
await shareUpdateEmailJob.emit({
senderName: `${senderName}`,
recipientName: recipient.name,
companyName: company.name,
@@ -98,9 +95,7 @@ export const shareUpdateProcedure = withAuth
link,
email,
senderEmail,
};
await new ShareUpdateEmailJob().emit(payload);
});
}
};