mirror of
https://github.com/hanzoai/captable.git
synced 2026-10-16 14:21:29 +00:00
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:
+3
-3
@@ -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",
|
||||
|
||||
Generated
+199
-1298
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;
|
||||
@@ -602,8 +602,7 @@ model TemplateField {
|
||||
enum TemplateStatus {
|
||||
DRAFT
|
||||
COMPLETE
|
||||
SENT
|
||||
WAITING
|
||||
PENDING
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
|
||||
+9
-18
@@ -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">
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,5 +8,5 @@ export const JOB_TYPES = {
|
||||
"esign-confirmation",
|
||||
"esign-notification",
|
||||
],
|
||||
generate: ["esign-pdf"],
|
||||
esign: ["generate-pdf", "complete-pdf"],
|
||||
} as const;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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,
|
||||
},
|
||||
})),
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,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
@@ -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] },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user