mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
Merge branch 'main' into admin/stats
This commit is contained in:
12
packages/lib/jobs/client.ts
Normal file
12
packages/lib/jobs/client.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { JobClient } from './client/client';
|
||||
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/send-confirmation-email';
|
||||
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/send-signing-email';
|
||||
|
||||
/**
|
||||
* The `as const` assertion is load bearing as it provides the correct level of type inference for
|
||||
* triggering jobs.
|
||||
*/
|
||||
export const jobsClient = new JobClient([
|
||||
SEND_SIGNING_EMAIL_JOB_DEFINITION,
|
||||
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
|
||||
] as const);
|
||||
61
packages/lib/jobs/client/_internal/job.ts
Normal file
61
packages/lib/jobs/client/_internal/job.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Json } from './json';
|
||||
|
||||
export type SimpleTriggerJobOptions = {
|
||||
id?: string;
|
||||
name: string;
|
||||
payload: unknown;
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
export const ZSimpleTriggerJobOptionsSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string(),
|
||||
payload: z.unknown().refine((x) => x !== undefined, { message: 'payload is required' }),
|
||||
timestamp: z.number().optional(),
|
||||
});
|
||||
|
||||
// Map the array to create a union of objects we may accept
|
||||
export type TriggerJobOptions<Definitions extends ReadonlyArray<JobDefinition> = []> = {
|
||||
[K in keyof Definitions]: {
|
||||
id?: string;
|
||||
name: Definitions[K]['trigger']['name'];
|
||||
payload: Definitions[K]['trigger']['schema'] extends z.ZodType<infer Shape> ? Shape : unknown;
|
||||
timestamp?: number;
|
||||
};
|
||||
}[number];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type JobDefinition<Name extends string = string, Schema = any> = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
enabled?: boolean;
|
||||
trigger: {
|
||||
name: Name;
|
||||
schema?: z.ZodType<Schema>;
|
||||
};
|
||||
handler: (options: { payload: Schema; io: JobRunIO }) => Promise<Json | void>;
|
||||
};
|
||||
|
||||
export interface JobRunIO {
|
||||
// stableRun<T extends Json | void>(cacheKey: string, callback: (io: JobRunIO) => T | Promise<T>): Promise<T>;
|
||||
runTask<T extends Json | void | undefined>(
|
||||
cacheKey: string,
|
||||
callback: () => Promise<T>,
|
||||
): Promise<T>;
|
||||
triggerJob(cacheKey: string, options: SimpleTriggerJobOptions): Promise<unknown>;
|
||||
wait(cacheKey: string, ms: number): Promise<void>;
|
||||
logger: {
|
||||
info(...args: unknown[]): void;
|
||||
error(...args: unknown[]): void;
|
||||
debug(...args: unknown[]): void;
|
||||
warn(...args: unknown[]): void;
|
||||
log(...args: unknown[]): void;
|
||||
};
|
||||
}
|
||||
|
||||
export const defineJob = <N extends string, T = unknown>(
|
||||
job: JobDefinition<N, T>,
|
||||
): JobDefinition<N, T> => job;
|
||||
14
packages/lib/jobs/client/_internal/json.ts
Normal file
14
packages/lib/jobs/client/_internal/json.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Below type is borrowed from Trigger.dev's SDK, it may be moved elsewhere later.
|
||||
*/
|
||||
|
||||
export type JsonPrimitive = string | number | boolean | null | undefined | Date | symbol;
|
||||
|
||||
export type JsonArray = Json[];
|
||||
|
||||
export type JsonRecord<T> = {
|
||||
[Property in keyof T]: Json;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type Json<T = any> = JsonPrimitive | JsonArray | JsonRecord<T>;
|
||||
19
packages/lib/jobs/client/base.ts
Normal file
19
packages/lib/jobs/client/base.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import type { JobDefinition, SimpleTriggerJobOptions } from './_internal/job';
|
||||
|
||||
export abstract class BaseJobProvider {
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
public async triggerJob(_options: SimpleTriggerJobOptions): Promise<void> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
public defineJob<N extends string, T>(_job: JobDefinition<N, T>): void {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
public getApiHandler(): (req: NextApiRequest, res: NextApiResponse) => Promise<Response | void> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
30
packages/lib/jobs/client/client.ts
Normal file
30
packages/lib/jobs/client/client.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { JobDefinition, TriggerJobOptions } from './_internal/job';
|
||||
import type { BaseJobProvider as JobClientProvider } from './base';
|
||||
import { InngestJobProvider } from './inngest';
|
||||
import { LocalJobProvider } from './local';
|
||||
import { TriggerJobProvider } from './trigger';
|
||||
|
||||
export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
|
||||
private _provider: JobClientProvider;
|
||||
|
||||
public constructor(definitions: T) {
|
||||
this._provider = match(process.env.NEXT_PRIVATE_JOBS_PROVIDER)
|
||||
.with('inngest', () => InngestJobProvider.getInstance())
|
||||
.with('trigger', () => TriggerJobProvider.getInstance())
|
||||
.otherwise(() => LocalJobProvider.getInstance());
|
||||
|
||||
definitions.forEach((definition) => {
|
||||
this._provider.defineJob(definition);
|
||||
});
|
||||
}
|
||||
|
||||
public async triggerJob(options: TriggerJobOptions<T>) {
|
||||
return this._provider.triggerJob(options);
|
||||
}
|
||||
|
||||
public getApiHandler() {
|
||||
return this._provider.getApiHandler();
|
||||
}
|
||||
}
|
||||
120
packages/lib/jobs/client/inngest.ts
Normal file
120
packages/lib/jobs/client/inngest.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import type { Context, Handler, InngestFunction } from 'inngest';
|
||||
import { Inngest as InngestClient } from 'inngest';
|
||||
import type { Logger } from 'inngest/middleware/logger';
|
||||
import { serve as createPagesRoute } from 'inngest/next';
|
||||
import { json } from 'micro';
|
||||
|
||||
import type { JobDefinition, JobRunIO, SimpleTriggerJobOptions } from './_internal/job';
|
||||
import { BaseJobProvider } from './base';
|
||||
|
||||
export class InngestJobProvider extends BaseJobProvider {
|
||||
private static _instance: InngestJobProvider;
|
||||
|
||||
private _client: InngestClient;
|
||||
private _functions: Array<InngestFunction<InngestFunction.Options, Handler.Any, Handler.Any>> =
|
||||
[];
|
||||
|
||||
private constructor(options: { client: InngestClient }) {
|
||||
super();
|
||||
|
||||
this._client = options.client;
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!this._instance) {
|
||||
const client = new InngestClient({
|
||||
id: 'documenso-app',
|
||||
eventKey: process.env.NEXT_PRIVATE_INNGEST_EVENT_KEY,
|
||||
});
|
||||
|
||||
this._instance = new InngestJobProvider({ client });
|
||||
}
|
||||
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public defineJob<N extends string, T>(job: JobDefinition<N, T>): void {
|
||||
console.log('defining job', job.id);
|
||||
const fn = this._client.createFunction(
|
||||
{
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
},
|
||||
{
|
||||
event: job.trigger.name,
|
||||
},
|
||||
async (ctx) => {
|
||||
const io = this.convertInngestIoToJobRunIo(ctx);
|
||||
|
||||
// We need to cast to any so we can deal with parsing later.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||
let payload = ctx.event.data as any;
|
||||
|
||||
if (job.trigger.schema) {
|
||||
payload = job.trigger.schema.parse(payload);
|
||||
}
|
||||
|
||||
await job.handler({ payload, io });
|
||||
},
|
||||
);
|
||||
|
||||
this._functions.push(fn);
|
||||
}
|
||||
|
||||
public async triggerJob(options: SimpleTriggerJobOptions): Promise<void> {
|
||||
await this._client.send({
|
||||
id: options.id,
|
||||
name: options.name,
|
||||
data: options.payload,
|
||||
ts: options.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
public getApiHandler() {
|
||||
const handler = createPagesRoute({
|
||||
client: this._client,
|
||||
functions: this._functions,
|
||||
});
|
||||
|
||||
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// Since body-parser is disabled for this route we need to patch in the parsed body
|
||||
if (req.headers['content-type'] === 'application/json') {
|
||||
Object.assign(req, {
|
||||
body: await json(req),
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const nextReq = req as unknown as NextRequest;
|
||||
|
||||
return await handler(nextReq, res);
|
||||
};
|
||||
}
|
||||
|
||||
private convertInngestIoToJobRunIo(ctx: Context.Any & { logger: Logger }) {
|
||||
const { step } = ctx;
|
||||
|
||||
return {
|
||||
wait: step.sleep,
|
||||
logger: {
|
||||
...ctx.logger,
|
||||
log: ctx.logger.info,
|
||||
},
|
||||
runTask: async (cacheKey, callback) => {
|
||||
const result = await step.run(cacheKey, callback);
|
||||
|
||||
// !: Not dealing with this right now but it should be correct.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||
return result as any;
|
||||
},
|
||||
triggerJob: async (cacheKey, payload) =>
|
||||
step.sendEvent(cacheKey, {
|
||||
...payload,
|
||||
timestamp: payload.timestamp,
|
||||
}),
|
||||
} satisfies JobRunIO;
|
||||
}
|
||||
}
|
||||
351
packages/lib/jobs/client/local.ts
Normal file
351
packages/lib/jobs/client/local.ts
Normal file
@ -0,0 +1,351 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { json } from 'micro';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { BackgroundJobStatus, Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { sign } from '../../server-only/crypto/sign';
|
||||
import { verify } from '../../server-only/crypto/verify';
|
||||
import {
|
||||
type JobDefinition,
|
||||
type JobRunIO,
|
||||
type SimpleTriggerJobOptions,
|
||||
ZSimpleTriggerJobOptionsSchema,
|
||||
} from './_internal/job';
|
||||
import type { Json } from './_internal/json';
|
||||
import { BaseJobProvider } from './base';
|
||||
|
||||
export class LocalJobProvider extends BaseJobProvider {
|
||||
private static _instance: LocalJobProvider;
|
||||
|
||||
private _jobDefinitions: Record<string, JobDefinition> = {};
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!this._instance) {
|
||||
this._instance = new LocalJobProvider();
|
||||
}
|
||||
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public defineJob<N extends string, T>(definition: JobDefinition<N, T>) {
|
||||
this._jobDefinitions[definition.id] = {
|
||||
...definition,
|
||||
enabled: definition.enabled ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
public async triggerJob(options: SimpleTriggerJobOptions) {
|
||||
console.log({ jobDefinitions: this._jobDefinitions });
|
||||
|
||||
const eligibleJobs = Object.values(this._jobDefinitions).filter(
|
||||
(job) => job.trigger.name === options.name,
|
||||
);
|
||||
|
||||
console.log({ options });
|
||||
console.log(
|
||||
'Eligible jobs:',
|
||||
eligibleJobs.map((job) => job.name),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
eligibleJobs.map(async (job) => {
|
||||
// Ideally we will change this to a createMany with returning later once we upgrade Prisma
|
||||
// @see: https://github.com/prisma/prisma/releases/tag/5.14.0
|
||||
const pendingJob = await prisma.backgroundJob.create({
|
||||
data: {
|
||||
jobId: job.id,
|
||||
name: job.name,
|
||||
version: job.version,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
payload: options.payload as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
await this.submitJobToEndpoint({
|
||||
jobId: pendingJob.id,
|
||||
jobDefinitionId: pendingJob.jobId,
|
||||
data: options,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public getApiHandler() {
|
||||
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.status(405).send('Method not allowed');
|
||||
}
|
||||
|
||||
const jobId = req.headers['x-job-id'];
|
||||
const signature = req.headers['x-job-signature'];
|
||||
const isRetry = req.headers['x-job-retry'] !== undefined;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const options = await json(req)
|
||||
.then(async (data) => ZSimpleTriggerJobOptionsSchema.parseAsync(data))
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
.then((data) => data as SimpleTriggerJobOptions)
|
||||
.catch(() => null);
|
||||
|
||||
if (!options) {
|
||||
res.status(400).send('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = this._jobDefinitions[options.name];
|
||||
|
||||
if (
|
||||
typeof jobId !== 'string' ||
|
||||
typeof signature !== 'string' ||
|
||||
typeof options !== 'object'
|
||||
) {
|
||||
res.status(400).send('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!definition) {
|
||||
res.status(404).send('Job not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (definition && !definition.enabled) {
|
||||
console.log('Attempted to trigger a disabled job', options.name);
|
||||
|
||||
res.status(404).send('Job not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signature || !verify(options, signature)) {
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (definition.trigger.schema) {
|
||||
const result = definition.trigger.schema.safeParse(options.payload);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(400).send('Bad request');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[JOBS]: Triggering job ${options.name} with payload`, options.payload);
|
||||
|
||||
let backgroundJob = await prisma.backgroundJob
|
||||
.update({
|
||||
where: {
|
||||
id: jobId,
|
||||
status: BackgroundJobStatus.PENDING,
|
||||
},
|
||||
data: {
|
||||
status: BackgroundJobStatus.PROCESSING,
|
||||
retried: {
|
||||
increment: isRetry ? 1 : 0,
|
||||
},
|
||||
lastRetriedAt: isRetry ? new Date() : undefined,
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!backgroundJob) {
|
||||
res.status(404).send('Job not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await definition.handler({
|
||||
payload: options.payload,
|
||||
io: this.createJobRunIO(jobId),
|
||||
});
|
||||
|
||||
backgroundJob = await prisma.backgroundJob.update({
|
||||
where: {
|
||||
id: jobId,
|
||||
status: BackgroundJobStatus.PROCESSING,
|
||||
},
|
||||
data: {
|
||||
status: BackgroundJobStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[JOBS]: Job ${options.name} failed`, error);
|
||||
|
||||
const taskHasExceededRetries = error instanceof BackgroundTaskExceededRetriesError;
|
||||
const jobHasExceededRetries =
|
||||
backgroundJob.retried >= backgroundJob.maxRetries &&
|
||||
!(error instanceof BackgroundTaskFailedError);
|
||||
|
||||
if (taskHasExceededRetries || jobHasExceededRetries) {
|
||||
backgroundJob = await prisma.backgroundJob.update({
|
||||
where: {
|
||||
id: jobId,
|
||||
status: BackgroundJobStatus.PROCESSING,
|
||||
},
|
||||
data: {
|
||||
status: BackgroundJobStatus.FAILED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
res.status(500).send('Task exceeded retries');
|
||||
return;
|
||||
}
|
||||
|
||||
backgroundJob = await prisma.backgroundJob.update({
|
||||
where: {
|
||||
id: jobId,
|
||||
status: BackgroundJobStatus.PROCESSING,
|
||||
},
|
||||
data: {
|
||||
status: BackgroundJobStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
await this.submitJobToEndpoint({
|
||||
jobId,
|
||||
jobDefinitionId: backgroundJob.jobId,
|
||||
data: options,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).send('OK');
|
||||
};
|
||||
}
|
||||
|
||||
private async submitJobToEndpoint(options: {
|
||||
jobId: string;
|
||||
jobDefinitionId: string;
|
||||
data: SimpleTriggerJobOptions;
|
||||
isRetry?: boolean;
|
||||
}) {
|
||||
const { jobId, jobDefinitionId, data, isRetry } = options;
|
||||
|
||||
const endpoint = `${NEXT_PUBLIC_WEBAPP_URL()}/api/jobs/${jobDefinitionId}/${jobId}`;
|
||||
const signature = sign(data);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Job-Id': jobId,
|
||||
'X-Job-Signature': signature,
|
||||
};
|
||||
|
||||
if (isRetry) {
|
||||
headers['X-Job-Retry'] = '1';
|
||||
}
|
||||
|
||||
console.log('Submitting job to endpoint:', endpoint);
|
||||
await Promise.race([
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers,
|
||||
}).catch(() => null),
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, 150);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private createJobRunIO(jobId: string): JobRunIO {
|
||||
return {
|
||||
runTask: async <T extends void | Json>(cacheKey: string, callback: () => Promise<T>) => {
|
||||
const hashedKey = Buffer.from(sha256(cacheKey)).toString('hex');
|
||||
|
||||
let task = await prisma.backgroundJobTask.findFirst({
|
||||
where: {
|
||||
id: `task-${hashedKey}--${jobId}`,
|
||||
jobId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
task = await prisma.backgroundJobTask.create({
|
||||
data: {
|
||||
id: `task-${hashedKey}--${jobId}`,
|
||||
name: cacheKey,
|
||||
jobId,
|
||||
status: BackgroundJobStatus.PENDING,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (task.status === BackgroundJobStatus.COMPLETED) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return task.result as T;
|
||||
}
|
||||
|
||||
if (task.retried >= 3) {
|
||||
throw new BackgroundTaskExceededRetriesError('Task exceeded retries');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await callback();
|
||||
|
||||
task = await prisma.backgroundJobTask.update({
|
||||
where: {
|
||||
id: task.id,
|
||||
jobId,
|
||||
},
|
||||
data: {
|
||||
status: BackgroundJobStatus.COMPLETED,
|
||||
result: result === null ? Prisma.JsonNull : result,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
task = await prisma.backgroundJobTask.update({
|
||||
where: {
|
||||
id: task.id,
|
||||
jobId,
|
||||
},
|
||||
data: {
|
||||
status: BackgroundJobStatus.PENDING,
|
||||
retried: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
throw new BackgroundTaskFailedError('Task failed');
|
||||
}
|
||||
},
|
||||
triggerJob: async (_cacheKey, payload) => await this.triggerJob(payload),
|
||||
logger: {
|
||||
debug: (...args) => console.debug(`[${jobId}]`, ...args),
|
||||
error: (...args) => console.error(`[${jobId}]`, ...args),
|
||||
info: (...args) => console.info(`[${jobId}]`, ...args),
|
||||
log: (...args) => console.log(`[${jobId}]`, ...args),
|
||||
warn: (...args) => console.warn(`[${jobId}]`, ...args),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
wait: async () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class BackgroundTaskFailedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BackgroundTaskFailedError';
|
||||
}
|
||||
}
|
||||
|
||||
class BackgroundTaskExceededRetriesError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BackgroundTaskExceededRetriesError';
|
||||
}
|
||||
}
|
||||
73
packages/lib/jobs/client/trigger.ts
Normal file
73
packages/lib/jobs/client/trigger.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { createPagesRoute } from '@trigger.dev/nextjs';
|
||||
import type { IO } from '@trigger.dev/sdk';
|
||||
import { TriggerClient, eventTrigger } from '@trigger.dev/sdk';
|
||||
|
||||
import type { JobDefinition, JobRunIO, SimpleTriggerJobOptions } from './_internal/job';
|
||||
import { BaseJobProvider } from './base';
|
||||
|
||||
export class TriggerJobProvider extends BaseJobProvider {
|
||||
private static _instance: TriggerJobProvider;
|
||||
|
||||
private _client: TriggerClient;
|
||||
|
||||
private constructor(options: { client: TriggerClient }) {
|
||||
super();
|
||||
|
||||
this._client = options.client;
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!this._instance) {
|
||||
const client = new TriggerClient({
|
||||
id: 'documenso-app',
|
||||
apiKey: process.env.NEXT_PRIVATE_TRIGGER_API_KEY,
|
||||
apiUrl: process.env.NEXT_PRIVATE_TRIGGER_API_URL,
|
||||
});
|
||||
|
||||
this._instance = new TriggerJobProvider({ client });
|
||||
}
|
||||
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public defineJob<N extends string, T>(job: JobDefinition<N, T>): void {
|
||||
this._client.defineJob({
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
version: job.version,
|
||||
trigger: eventTrigger({
|
||||
name: job.trigger.name,
|
||||
schema: job.trigger.schema,
|
||||
}),
|
||||
run: async (payload, io) => job.handler({ payload, io: this.convertTriggerIoToJobRunIo(io) }),
|
||||
});
|
||||
}
|
||||
|
||||
public async triggerJob(options: SimpleTriggerJobOptions): Promise<void> {
|
||||
await this._client.sendEvent({
|
||||
id: options.id,
|
||||
name: options.name,
|
||||
payload: options.payload,
|
||||
timestamp: options.timestamp ? new Date(options.timestamp) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
public getApiHandler() {
|
||||
const { handler } = createPagesRoute(this._client);
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
private convertTriggerIoToJobRunIo(io: IO) {
|
||||
return {
|
||||
wait: io.wait,
|
||||
logger: io.logger,
|
||||
runTask: async (cacheKey, callback) => io.runTask(cacheKey, callback),
|
||||
triggerJob: async (cacheKey, payload) =>
|
||||
io.sendEvent(cacheKey, {
|
||||
...payload,
|
||||
timestamp: payload.timestamp ? new Date(payload.timestamp) : undefined,
|
||||
}),
|
||||
} satisfies JobRunIO;
|
||||
}
|
||||
}
|
||||
30
packages/lib/jobs/definitions/send-confirmation-email.ts
Normal file
30
packages/lib/jobs/definitions/send-confirmation-email.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { sendConfirmationToken } from '../../server-only/user/send-confirmation-token';
|
||||
import type { JobDefinition } from '../client/_internal/job';
|
||||
|
||||
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID = 'send.signup.confirmation.email';
|
||||
|
||||
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
email: z.string().email(),
|
||||
force: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Confirmation Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload }) => {
|
||||
await sendConfirmationToken({
|
||||
email: payload.email,
|
||||
force: payload.force,
|
||||
});
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
|
||||
z.infer<typeof SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA>
|
||||
>;
|
||||
171
packages/lib/jobs/definitions/send-signing-email.ts
Normal file
171
packages/lib/jobs/definitions/send-signing-email.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentSource,
|
||||
DocumentStatus,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
} from '../../constants/recipient-roles';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { ZRequestMetadataSchema } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
|
||||
import { type JobDefinition } from '../client/_internal/job';
|
||||
|
||||
const SEND_SIGNING_EMAIL_JOB_DEFINITION_ID = 'send.signing.requested.email';
|
||||
|
||||
const SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
userId: z.number(),
|
||||
documentId: z.number(),
|
||||
recipientId: z.number(),
|
||||
requestMetadata: ZRequestMetadataSchema.optional(),
|
||||
});
|
||||
|
||||
export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_SIGNING_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Signing Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_SIGNING_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const { userId, documentId, recipientId, requestMetadata } = payload;
|
||||
|
||||
const [user, document, recipient] = await Promise.all([
|
||||
prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
}),
|
||||
prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
},
|
||||
}),
|
||||
prisma.recipient.findFirstOrThrow({
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const { documentMeta } = document;
|
||||
|
||||
if (recipient.role === RecipientRole.CC) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customEmail = document?.documentMeta;
|
||||
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
|
||||
|
||||
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
|
||||
|
||||
const { email, name } = recipient;
|
||||
const selfSigner = email === user.email;
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
const recipientActionVerb = actionVerb.toLowerCase();
|
||||
|
||||
let emailMessage = customEmail?.message || '';
|
||||
let emailSubject = `Please ${recipientActionVerb} this document`;
|
||||
|
||||
if (selfSigner) {
|
||||
emailMessage = `You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`;
|
||||
emailSubject = `Please ${recipientActionVerb} your document`;
|
||||
}
|
||||
|
||||
if (isDirectTemplate) {
|
||||
emailMessage = `A document was created by your direct template that requires you to ${recipientActionVerb} it.`;
|
||||
emailSubject = `Please ${recipientActionVerb} this document created by your direct template`;
|
||||
}
|
||||
|
||||
const customEmailTemplate = {
|
||||
'signer.name': name,
|
||||
'signer.email': email,
|
||||
'document.name': document.title,
|
||||
};
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
|
||||
role: recipient.role,
|
||||
selfSigner,
|
||||
});
|
||||
|
||||
await io.runTask('send-signing-email', async () => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: renderCustomEmailTemplate(
|
||||
documentMeta?.subject || emailSubject,
|
||||
customEmailTemplate,
|
||||
),
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
});
|
||||
|
||||
await io.runTask('update-recipient', async () => {
|
||||
await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await io.runTask('store-audit-log', async () => {
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientId: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
recipientEmail: recipient.email,
|
||||
recipientRole: recipient.role,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_SIGNING_EMAIL_JOB_DEFINITION_ID,
|
||||
z.infer<typeof SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA>
|
||||
>;
|
||||
@ -14,11 +14,11 @@ import { prisma } from '@documenso/prisma';
|
||||
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
import { jobsClient } from '../jobs/client';
|
||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
|
||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
|
||||
import type { TAuthenticationResponseJSONSchema } from '../types/webauthn';
|
||||
import { ZAuthenticationResponseJSONSchema } from '../types/webauthn';
|
||||
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
|
||||
@ -108,7 +108,12 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
mostRecentToken.expires.valueOf() <= Date.now() ||
|
||||
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
|
||||
) {
|
||||
await sendConfirmationToken({ email });
|
||||
await jobsClient.triggerJob({
|
||||
name: 'send.signup.confirmation.email',
|
||||
payload: {
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(ErrorCode.UNVERIFIED_EMAIL);
|
||||
|
||||
@ -32,10 +32,14 @@
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@scure/base": "^1.1.3",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@trigger.dev/nextjs": "^2.3.18",
|
||||
"@trigger.dev/sdk": "^2.3.18",
|
||||
"@upstash/redis": "^1.20.6",
|
||||
"@vvo/tzdb": "^6.117.0",
|
||||
"inngest": "^3.19.13",
|
||||
"kysely": "^0.26.3",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "14.0.3",
|
||||
"next-auth": "4.24.5",
|
||||
@ -50,8 +54,8 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/browser-chromium": "1.43.0",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/pg": "^8.11.4",
|
||||
"@playwright/browser-chromium": "1.43.0"
|
||||
"@types/pg": "^8.11.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,31 +1,14 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
|
||||
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentSource,
|
||||
DocumentStatus,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
} from '../../constants/recipient-roles';
|
||||
import { jobsClient } from '../../jobs/client';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@ -82,8 +65,6 @@ export const sendDocument = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const customEmail = document?.documentMeta;
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
@ -98,8 +79,6 @@ export const sendDocument = async ({
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
|
||||
|
||||
if (!documentData.data) {
|
||||
throw new Error('Document data not found');
|
||||
}
|
||||
@ -109,6 +88,7 @@ export const sendDocument = async ({
|
||||
|
||||
const prefilled = await insertFormValuesInPdf({
|
||||
pdf: Buffer.from(file),
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
formValues: document.formValues as Record<string, string | number | boolean>,
|
||||
});
|
||||
|
||||
@ -130,6 +110,31 @@ export const sendDocument = async ({
|
||||
Object.assign(document, result);
|
||||
}
|
||||
|
||||
// Commented out server side checks for minimum 1 signature per signer now since we need to
|
||||
// decide if we want to enforce this for API & templates.
|
||||
// const fields = await getFieldsForDocument({
|
||||
// documentId: documentId,
|
||||
// userId: userId,
|
||||
// });
|
||||
|
||||
// const fieldsWithSignerEmail = fields.map((field) => ({
|
||||
// ...field,
|
||||
// signerEmail:
|
||||
// document.Recipient.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
// }));
|
||||
|
||||
// const everySignerHasSignature = document?.Recipient.every(
|
||||
// (recipient) =>
|
||||
// recipient.role !== RecipientRole.SIGNER ||
|
||||
// fieldsWithSignerEmail.some(
|
||||
// (field) => field.type === 'SIGNATURE' && field.signerEmail === recipient.email,
|
||||
// ),
|
||||
// );
|
||||
|
||||
// if (!everySignerHasSignature) {
|
||||
// throw new Error('Some signers have not been assigned a signature field.');
|
||||
// }
|
||||
|
||||
if (sendEmail) {
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
@ -137,92 +142,15 @@ export const sendDocument = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
|
||||
|
||||
const { email, name } = recipient;
|
||||
const selfSigner = email === user.email;
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
const recipientActionVerb = actionVerb.toLowerCase();
|
||||
|
||||
let emailMessage = customEmail?.message || '';
|
||||
let emailSubject = `Please ${recipientActionVerb} this document`;
|
||||
|
||||
if (selfSigner) {
|
||||
emailMessage = `You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`;
|
||||
emailSubject = `Please ${recipientActionVerb} your document`;
|
||||
}
|
||||
|
||||
if (isDirectTemplate) {
|
||||
emailMessage = `A document was created by your direct template that requires you to ${recipientActionVerb} it.`;
|
||||
emailSubject = `Please ${recipientActionVerb} this document created by your direct template`;
|
||||
}
|
||||
|
||||
const customEmailTemplate = {
|
||||
'signer.name': name,
|
||||
'signer.email': email,
|
||||
'document.name': document.title,
|
||||
};
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
|
||||
role: recipient.role,
|
||||
selfSigner,
|
||||
});
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: emailSubject,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
await tx.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
await jobsClient.triggerJob({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId,
|
||||
documentId,
|
||||
recipientId: recipient.id,
|
||||
requestMetadata,
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ export const getCompletedFieldsForToken = async ({ token }: GetCompletedFieldsFo
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
signingStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -5,6 +5,8 @@ export interface GetFieldsForDocumentOptions {
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export type DocumentField = Awaited<ReturnType<typeof getFieldsForDocument>>[number];
|
||||
|
||||
export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForDocumentOptions) => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: {
|
||||
@ -26,6 +28,16 @@ export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForD
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Signature: true,
|
||||
Recipient: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
signingStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
|
||||
@ -2,7 +2,7 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { sendConfirmationToken } from './send-confirmation-token';
|
||||
import { jobsClient } from '../../jobs/client';
|
||||
|
||||
export type VerifyEmailProps = {
|
||||
token: string;
|
||||
@ -40,7 +40,12 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
|
||||
!mostRecentToken ||
|
||||
DateTime.now().minus({ hours: 1 }).toJSDate() > mostRecentToken.createdAt
|
||||
) {
|
||||
await sendConfirmationToken({ email: verificationToken.user.email });
|
||||
await jobsClient.triggerJob({
|
||||
name: 'send.signup.confirmation.email',
|
||||
payload: {
|
||||
email: verificationToken.user.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return valid;
|
||||
|
||||
@ -5,10 +5,12 @@ import { z } from 'zod';
|
||||
|
||||
const ZIpSchema = z.string().ip();
|
||||
|
||||
export type RequestMetadata = {
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
};
|
||||
export const ZRequestMetadataSchema = z.object({
|
||||
ipAddress: ZIpSchema.optional(),
|
||||
userAgent: z.string().optional(),
|
||||
});
|
||||
|
||||
export type RequestMetadata = z.infer<typeof ZRequestMetadataSchema>;
|
||||
|
||||
export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetadata => {
|
||||
const parsedIp = ZIpSchema.safeParse(req.headers['x-forwarded-for'] || req.socket.remoteAddress);
|
||||
|
||||
Reference in New Issue
Block a user