mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 08:42:12 +10:00
chore: merge feat/refresh
This commit is contained in:
61
packages/lib/client-only/hooks/use-analytics.ts
Normal file
61
packages/lib/client-only/hooks/use-analytics.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { posthog } from 'posthog-js';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import {
|
||||
FEATURE_FLAG_GLOBAL_SESSION_RECORDING,
|
||||
extractPostHogConfig,
|
||||
} from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
export function useAnalytics() {
|
||||
const featureFlags = useFeatureFlags();
|
||||
const isPostHogEnabled = extractPostHogConfig();
|
||||
|
||||
/**
|
||||
* Capture an analytic event.
|
||||
*
|
||||
* @param event The event name.
|
||||
* @param properties Properties to attach to the event.
|
||||
*/
|
||||
const capture = (event: string, properties?: Record<string, unknown>) => {
|
||||
if (!isPostHogEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.capture(event, properties);
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the session recording.
|
||||
*
|
||||
* @param eventFlag The event to check against feature flags to determine whether tracking is enabled.
|
||||
*/
|
||||
const startSessionRecording = (eventFlag?: string) => {
|
||||
const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
|
||||
const isSessionRecordingEnabledForEvent = Boolean(eventFlag && featureFlags.getFlag(eventFlag));
|
||||
|
||||
if (!isPostHogEnabled || !isSessionRecordingEnabled || !isSessionRecordingEnabledForEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.startSessionRecording();
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop the current session recording.
|
||||
*/
|
||||
const stopSessionRecording = () => {
|
||||
const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
|
||||
|
||||
if (!isPostHogEnabled || !isSessionRecordingEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.stopSessionRecording();
|
||||
};
|
||||
|
||||
return {
|
||||
capture,
|
||||
startSessionRecording,
|
||||
stopSessionRecording,
|
||||
};
|
||||
}
|
||||
18
packages/lib/client-only/hooks/use-debounced-value.ts
Normal file
18
packages/lib/client-only/hooks/use-debounced-value.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delay: number) {
|
||||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
85
packages/lib/client-only/hooks/use-element-scale-size.ts
Normal file
85
packages/lib/client-only/hooks/use-element-scale-size.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions */
|
||||
import { RefObject, useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Calculate the width and height of a text element.
|
||||
*
|
||||
* @param text The text to calculate the width and height of.
|
||||
* @param fontSize The font size to apply to the text.
|
||||
* @param fontFamily The font family to apply to the text.
|
||||
* @returns Returns the width and height of the text.
|
||||
*/
|
||||
function calculateTextDimensions(
|
||||
text: string,
|
||||
fontSize: string,
|
||||
fontFamily: string,
|
||||
): { width: number; height: number } {
|
||||
// Reuse old canvas if available.
|
||||
let canvas = (calculateTextDimensions as { canvas?: HTMLCanvasElement }).canvas;
|
||||
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
(calculateTextDimensions as { canvas?: HTMLCanvasElement }).canvas = canvas;
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
context.font = `${fontSize} ${fontFamily}`;
|
||||
const metrics = context.measureText(text);
|
||||
|
||||
return {
|
||||
width: metrics.width,
|
||||
height: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the scaling size to apply to a text to fit it within a container.
|
||||
*
|
||||
* @param container The container dimensions to fit the text within.
|
||||
* @param text The text to fit within the container.
|
||||
* @param fontSize The font size to apply to the text.
|
||||
* @param fontFamily The font family to apply to the text.
|
||||
* @returns Returns a value between 0 and 1 which represents the scaling factor to apply to the text.
|
||||
*/
|
||||
export const calculateTextScaleSize = (
|
||||
container: { width: number; height: number },
|
||||
text: string,
|
||||
fontSize: string,
|
||||
fontFamily: string,
|
||||
) => {
|
||||
const { width, height } = calculateTextDimensions(text, fontSize, fontFamily);
|
||||
return Math.min(container.width / width, container.height / height, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a container and child element, calculate the scaling size to apply to the child.
|
||||
*/
|
||||
export function useElementScaleSize(
|
||||
container: { width: number; height: number },
|
||||
child: RefObject<HTMLElement | null>,
|
||||
fontSize: number,
|
||||
fontFamily: string,
|
||||
) {
|
||||
const [scalingFactor, setScalingFactor] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!child.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaleSize = calculateTextScaleSize(
|
||||
container,
|
||||
child.current.innerText,
|
||||
`${fontSize}px`,
|
||||
fontFamily,
|
||||
);
|
||||
|
||||
setScalingFactor(scaleSize);
|
||||
}, [child, container, fontFamily, fontSize]);
|
||||
|
||||
return scalingFactor;
|
||||
}
|
||||
78
packages/lib/client-only/hooks/use-field-page-coords.ts
Normal file
78
packages/lib/client-only/hooks/use-field-page-coords.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { Field } from '@documenso/prisma/client';
|
||||
|
||||
export const useFieldPageCoords = (field: Field) => {
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
const calculateCoords = useCallback(() => {
|
||||
const $page = document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, left, height, width } = getBoundingClientRect($page);
|
||||
|
||||
// X and Y are percentages of the page's height and width
|
||||
const fieldX = (Number(field.positionX) / 100) * width + left;
|
||||
const fieldY = (Number(field.positionY) / 100) * height + top;
|
||||
|
||||
const fieldHeight = (Number(field.height) / 100) * height;
|
||||
const fieldWidth = (Number(field.width) / 100) * width;
|
||||
|
||||
setCoords({
|
||||
x: fieldX,
|
||||
y: fieldY,
|
||||
height: fieldHeight,
|
||||
width: fieldWidth,
|
||||
});
|
||||
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateCoords();
|
||||
}, [calculateCoords]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
calculateCoords();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [calculateCoords]);
|
||||
|
||||
useEffect(() => {
|
||||
const $page = document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
calculateCoords();
|
||||
});
|
||||
|
||||
observer.observe($page);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [calculateCoords, field.page]);
|
||||
|
||||
return coords;
|
||||
};
|
||||
11
packages/lib/client-only/hooks/use-is-mounted.ts
Normal file
11
packages/lib/client-only/hooks/use-is-mounted.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useIsMounted = () => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
return isMounted;
|
||||
};
|
||||
27
packages/lib/client-only/hooks/use-window-size.ts
Normal file
27
packages/lib/client-only/hooks/use-window-size.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useWindowSize() {
|
||||
const [size, setSize] = useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const onResize = () => {
|
||||
setSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onResize();
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return size;
|
||||
}
|
||||
95
packages/lib/client-only/providers/feature-flag.tsx
Normal file
95
packages/lib/client-only/providers/feature-flag.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
FEATURE_FLAG_POLL_INTERVAL,
|
||||
LOCAL_FEATURE_FLAGS,
|
||||
isFeatureFlagEnabled,
|
||||
} from '@documenso/lib/constants/feature-flags';
|
||||
import { getAllFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||
|
||||
import { TFeatureFlagValue } from './feature-flag.types';
|
||||
|
||||
export type FeatureFlagContextValue = {
|
||||
getFlag: (_key: string) => TFeatureFlagValue;
|
||||
};
|
||||
|
||||
export const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
|
||||
|
||||
export const useFeatureFlags = () => {
|
||||
const context = useContext(FeatureFlagContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export function FeatureFlagProvider({
|
||||
children,
|
||||
initialFlags,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialFlags: Record<string, TFeatureFlagValue>;
|
||||
}) {
|
||||
const [flags, setFlags] = useState(initialFlags);
|
||||
|
||||
const getFlag = useCallback(
|
||||
(flag: string) => {
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return LOCAL_FEATURE_FLAGS[flag] ?? true;
|
||||
}
|
||||
|
||||
return flags[flag] ?? false;
|
||||
},
|
||||
[flags],
|
||||
);
|
||||
|
||||
/**
|
||||
* Refresh the flags every `FEATURE_FLAG_POLL_INTERVAL` amount of time if the window is focused.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (document.hasFocus()) {
|
||||
void getAllFlags().then((newFlags) => setFlags(newFlags));
|
||||
}
|
||||
}, FEATURE_FLAG_POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Refresh the flags when the window is focused.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onFocus = () => void getAllFlags().then((newFlags) => setFlags(newFlags));
|
||||
|
||||
window.addEventListener('focus', onFocus);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FeatureFlagContext.Provider
|
||||
value={{
|
||||
getFlag,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FeatureFlagContext.Provider>
|
||||
);
|
||||
}
|
||||
10
packages/lib/client-only/providers/feature-flag.types.ts
Normal file
10
packages/lib/client-only/providers/feature-flag.types.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZFeatureFlagValueSchema = z.union([
|
||||
z.boolean(),
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.undefined(),
|
||||
]);
|
||||
|
||||
export type TFeatureFlagValue = z.infer<typeof ZFeatureFlagValueSchema>;
|
||||
8
packages/lib/constants/app.ts
Normal file
8
packages/lib/constants/app.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
|
||||
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
|
||||
|
||||
export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web';
|
||||
|
||||
export const APP_BASE_URL = IS_APP_WEB
|
||||
? process.env.NEXT_PUBLIC_WEBAPP_URL
|
||||
: process.env.NEXT_PUBLIC_MARKETING_URL;
|
||||
4
packages/lib/constants/email.ts
Normal file
4
packages/lib/constants/email.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const FROM_ADDRESS = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
|
||||
export const FROM_NAME = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
|
||||
|
||||
export const SERVICE_USER_EMAIL = 'serviceaccount@documenso.com';
|
||||
@ -1,3 +1,10 @@
|
||||
import { APP_BASE_URL } from './app';
|
||||
|
||||
/**
|
||||
* The flag name for global session recording feature flag.
|
||||
*/
|
||||
export const FEATURE_FLAG_GLOBAL_SESSION_RECORDING = 'global_session_recording';
|
||||
|
||||
/**
|
||||
* How frequent to poll for new feature flags in milliseconds.
|
||||
*/
|
||||
@ -10,6 +17,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
|
||||
*/
|
||||
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
|
||||
marketing_header_single_player_mode: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@ -17,7 +25,7 @@ export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||
*/
|
||||
export function extractPostHogConfig(): { key: string; host: string } | null {
|
||||
const postHogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
||||
const postHogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
|
||||
const postHogHost = `${APP_BASE_URL}/ingest`;
|
||||
|
||||
if (!postHogKey || !postHogHost) {
|
||||
return null;
|
||||
|
||||
9
packages/lib/constants/pdf.ts
Normal file
9
packages/lib/constants/pdf.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { APP_BASE_URL } from './app';
|
||||
|
||||
export const DEFAULT_STANDARD_FONT_SIZE = 15;
|
||||
export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
|
||||
|
||||
export const MIN_STANDARD_FONT_SIZE = 8;
|
||||
export const MIN_HANDWRITING_FONT_SIZE = 20;
|
||||
|
||||
export const CAVEAT_FONT_PATH = `${APP_BASE_URL}/fonts/caveat.ttf`;
|
||||
@ -15,7 +15,7 @@ export const getServerSession = async ({ req, res }: GetServerSessionOptions) =>
|
||||
const session = await getNextAuthServerSession(req, res, NEXT_AUTH_OPTIONS);
|
||||
|
||||
if (!session || !session.user?.email) {
|
||||
return null;
|
||||
return { user: null, session: null };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@ -24,14 +24,14 @@ export const getServerSession = async ({ req, res }: GetServerSessionOptions) =>
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
return { user, session };
|
||||
};
|
||||
|
||||
export const getServerComponentSession = async () => {
|
||||
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
|
||||
|
||||
if (!session || !session.user?.email) {
|
||||
return null;
|
||||
return { user: null, session: null };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@ -40,15 +40,15 @@ export const getServerComponentSession = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
return { user, session };
|
||||
};
|
||||
|
||||
export const getRequiredServerComponentSession = async () => {
|
||||
const session = await getServerComponentSession();
|
||||
const { user, session } = await getServerComponentSession();
|
||||
|
||||
if (!session) {
|
||||
if (!user || !session) {
|
||||
throw new Error('No session found');
|
||||
}
|
||||
|
||||
return session;
|
||||
return { user, session };
|
||||
};
|
||||
|
||||
@ -10,13 +10,16 @@
|
||||
"universal/",
|
||||
"next-auth/"
|
||||
],
|
||||
"scripts": {},
|
||||
"scripts": {
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.410.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.410.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.410.0",
|
||||
"@documenso/email": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/signing": "*",
|
||||
"@next-auth/prisma-adapter": "1.0.7",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@scure/base": "^1.1.3",
|
||||
@ -25,7 +28,7 @@
|
||||
"bcrypt": "^5.1.0",
|
||||
"luxon": "^3.4.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.12",
|
||||
"next": "13.4.19",
|
||||
"next-auth": "4.22.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"react": "18.2.0",
|
||||
|
||||
@ -18,8 +18,6 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
|
||||
console.log({ assetBaseUrl });
|
||||
|
||||
const template = createElement(ResetPasswordTemplate, {
|
||||
assetBaseUrl,
|
||||
userEmail: user.email,
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type CreateDocumentMetaOptions = {
|
||||
documentId: number;
|
||||
subject: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const upsertDocumentMeta = async ({
|
||||
subject,
|
||||
message,
|
||||
documentId,
|
||||
}: CreateDocumentMetaOptions) => {
|
||||
return await prisma.documentMeta.upsert({
|
||||
where: {
|
||||
documentId,
|
||||
},
|
||||
create: {
|
||||
subject,
|
||||
message,
|
||||
documentId,
|
||||
},
|
||||
update: {
|
||||
subject,
|
||||
message,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -4,6 +4,7 @@ import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { sealDocument } from './seal-document';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
|
||||
export type CompleteDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
@ -69,6 +70,19 @@ export const completeDocumentWithToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const pendingRecipients = await prisma.recipient.count({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (pendingRecipients > 0) {
|
||||
await sendPendingEmail({ documentId, recipientId: recipient.id });
|
||||
}
|
||||
|
||||
const documents = await prisma.document.updateMany({
|
||||
where: {
|
||||
id: document.id,
|
||||
|
||||
13
packages/lib/server-only/document/delete-draft-document.ts
Normal file
13
packages/lib/server-only/document/delete-draft-document.ts
Normal file
@ -0,0 +1,13 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type DeleteDraftDocumentOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const deleteDraftDocument = async ({ id, userId }: DeleteDraftDocumentOptions) => {
|
||||
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
|
||||
};
|
||||
@ -13,6 +13,7 @@ export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) =>
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,12 +1,21 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
|
||||
export interface GetDocumentAndSenderByTokenOptions {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface GetDocumentAndRecipientByTokenOptions {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const getDocumentAndSenderByToken = async ({
|
||||
token,
|
||||
}: GetDocumentAndSenderByTokenOptions) => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const result = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
Recipient: {
|
||||
@ -29,3 +38,33 @@ export const getDocumentAndSenderByToken = async ({
|
||||
User,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a Document and a Recipient by the recipient token.
|
||||
*/
|
||||
export const getDocumentAndRecipientByToken = async ({
|
||||
token,
|
||||
}: GetDocumentAndRecipientByTokenOptions): Promise<DocumentWithRecipient> => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const result = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
Recipient: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
Recipient: result.Recipient[0],
|
||||
};
|
||||
};
|
||||
|
||||
@ -5,10 +5,12 @@ import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { signPdf } from '@documenso/signing';
|
||||
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { putFile } from '../../universal/upload/put-file';
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
import { sendCompletedEmail } from './send-completed-email';
|
||||
|
||||
export type SealDocumentOptions = {
|
||||
documentId: number;
|
||||
@ -70,12 +72,14 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
||||
|
||||
const pdfBytes = await doc.save();
|
||||
|
||||
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
|
||||
|
||||
const { name, ext } = path.parse(document.title);
|
||||
|
||||
const { data: newData } = await putFile({
|
||||
name: `${name}_signed${ext}`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(Buffer.from(pdfBytes)),
|
||||
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
||||
});
|
||||
|
||||
await prisma.documentData.update({
|
||||
@ -86,4 +90,6 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
||||
data: newData,
|
||||
},
|
||||
});
|
||||
|
||||
await sendCompletedEmail({ documentId });
|
||||
};
|
||||
|
||||
57
packages/lib/server-only/document/send-completed-email.ts
Normal file
57
packages/lib/server-only/document/send-completed-email.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface SendDocumentOptions {
|
||||
documentId: number;
|
||||
}
|
||||
|
||||
export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) => {
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (document.Recipient.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const { email, name, token } = recipient;
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentCompletedEmailTemplate, {
|
||||
documentName: document.title,
|
||||
assetBaseUrl,
|
||||
downloadLink: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${token}/complete`,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Signing Complete!',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
}),
|
||||
]);
|
||||
};
|
||||
@ -3,13 +3,15 @@ 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 { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
||||
|
||||
export interface SendDocumentOptions {
|
||||
export type SendDocumentOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
}
|
||||
};
|
||||
|
||||
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@ -25,9 +27,12 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
const customEmail = document?.documentMeta;
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
@ -44,6 +49,12 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const { email, name } = recipient;
|
||||
|
||||
const customEmailTemplate = {
|
||||
'signer.name': name,
|
||||
'signer.email': email,
|
||||
'document.name': document.title,
|
||||
};
|
||||
|
||||
if (recipient.sendStatus === SendStatus.SENT) {
|
||||
return;
|
||||
}
|
||||
@ -57,6 +68,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
@ -65,10 +77,12 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Please sign this document',
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: 'Please sign this document',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
64
packages/lib/server-only/document/send-pending-email.ts
Normal file
64
packages/lib/server-only/document/send-pending-email.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface SendPendingEmailOptions {
|
||||
documentId: number;
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingEmailOptions) => {
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
Recipient: {
|
||||
some: {
|
||||
id: recipientId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Recipient: {
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (document.Recipient.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
const [recipient] = document.Recipient;
|
||||
|
||||
const { email, name } = recipient;
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentPendingEmailTemplate, {
|
||||
documentName: document.title,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Waiting for others to complete signing.',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
};
|
||||
21
packages/lib/server-only/document/update-document.ts
Normal file
21
packages/lib/server-only/document/update-document.ts
Normal file
@ -0,0 +1,21 @@
|
||||
'use server';
|
||||
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type UpdateDocumentOptions = {
|
||||
documentId: number;
|
||||
data: Prisma.DocumentUpdateInput;
|
||||
};
|
||||
|
||||
export const updateDocument = async ({ documentId, data }: UpdateDocumentOptions) => {
|
||||
return await prisma.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -14,7 +14,6 @@ export const viewedDocument = async ({ token }: ViewedDocumentOptions) => {
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
console.warn(`No recipient found for token ${token}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
51
packages/lib/server-only/feature-flags/all.ts
Normal file
51
packages/lib/server-only/feature-flags/all.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
|
||||
import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
|
||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||
|
||||
import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
|
||||
|
||||
/**
|
||||
* Get all the evaluated feature flags based on the current user if possible.
|
||||
*/
|
||||
export default async function handlerFeatureFlagAll(req: Request) {
|
||||
const requestHeaders = Object.fromEntries(req.headers.entries());
|
||||
|
||||
const nextReq = new NextRequest(req, {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
|
||||
const token = await getToken({ req: nextReq });
|
||||
|
||||
const postHog = PostHogServerClient();
|
||||
|
||||
// Return the local feature flags if PostHog is not enabled, true by default.
|
||||
// The front end should not call this API if PostHog is not enabled to reduce network requests.
|
||||
if (!postHog) {
|
||||
return NextResponse.json(LOCAL_FEATURE_FLAGS);
|
||||
}
|
||||
|
||||
const distinctId = extractDistinctUserId(token, nextReq);
|
||||
|
||||
const featureFlags = await postHog.getAllFlags(distinctId, mapJwtToFlagProperties(token));
|
||||
|
||||
const res = NextResponse.json(featureFlags);
|
||||
|
||||
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
|
||||
|
||||
const origin = req.headers.get('origin');
|
||||
|
||||
if (origin) {
|
||||
if (origin.startsWith(process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000')) {
|
||||
res.headers.set('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
|
||||
if (origin.startsWith(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001')) {
|
||||
res.headers.set('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { PostHog } from 'posthog-node';
|
||||
|
||||
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
export default function PostHogServerClient() {
|
||||
const postHogConfig = extractPostHogConfig();
|
||||
|
||||
if (!postHogConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PostHog(postHogConfig.key, {
|
||||
host: postHogConfig.host,
|
||||
fetch: async (...args) => fetch(...args),
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { getAllFlags, getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||
|
||||
/**
|
||||
* Evaluate whether a flag is enabled for the current user in a server component.
|
||||
*
|
||||
* @param flag The flag to evaluate.
|
||||
* @returns Whether the flag is enabled, or the variant value of the flag.
|
||||
*/
|
||||
export const getServerComponentFlag = async (flag: string) => {
|
||||
return await getFlag(flag, {
|
||||
requestHeaders: Object.fromEntries(headers().entries()),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all feature flags for the current user from a server component.
|
||||
*
|
||||
* @returns A record of flags and their values for the user derived from the headers.
|
||||
*/
|
||||
export const getServerComponentAllFlags = async () => {
|
||||
return await getAllFlags({
|
||||
requestHeaders: Object.fromEntries(headers().entries()),
|
||||
});
|
||||
};
|
||||
129
packages/lib/server-only/feature-flags/get.ts
Normal file
129
packages/lib/server-only/feature-flags/get.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { JWT, getToken } from 'next-auth/jwt';
|
||||
|
||||
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||
|
||||
/**
|
||||
* Evaluate a single feature flag based on the current user if possible.
|
||||
*
|
||||
* @param req The request with a query parameter `flag`. Example request URL: /api/feature-flag/get?flag=flag-name
|
||||
* @returns A Response with the feature flag value.
|
||||
*/
|
||||
export default async function handleFeatureFlagGet(req: Request) {
|
||||
const { searchParams } = new URL(req.url ?? '');
|
||||
const flag = searchParams.get('flag');
|
||||
|
||||
const requestHeaders = Object.fromEntries(req.headers.entries());
|
||||
|
||||
const nextReq = new NextRequest(req, {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
|
||||
const token = await getToken({ req: nextReq });
|
||||
|
||||
if (!flag) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Missing flag query parameter.',
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const postHog = PostHogServerClient();
|
||||
|
||||
// Return the local feature flags if PostHog is not enabled, true by default.
|
||||
// The front end should not call this API if PostHog is disabled to reduce network requests.
|
||||
if (!postHog) {
|
||||
return NextResponse.json(LOCAL_FEATURE_FLAGS[flag] ?? true);
|
||||
}
|
||||
|
||||
const distinctId = extractDistinctUserId(token, nextReq);
|
||||
|
||||
const featureFlag = await postHog.getFeatureFlag(flag, distinctId, mapJwtToFlagProperties(token));
|
||||
|
||||
const res = NextResponse.json(featureFlag);
|
||||
|
||||
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
|
||||
|
||||
const origin = req.headers.get('Origin');
|
||||
|
||||
if (origin) {
|
||||
if (origin.startsWith(process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000')) {
|
||||
res.headers.set('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
|
||||
if (origin.startsWith(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001')) {
|
||||
res.headers.set('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a JWT to properties which are consumed by PostHog to evaluate feature flags.
|
||||
*
|
||||
* @param jwt The JWT of the current user.
|
||||
* @returns A map of properties which are consumed by PostHog.
|
||||
*/
|
||||
export const mapJwtToFlagProperties = (
|
||||
jwt?: JWT | null,
|
||||
): {
|
||||
groups?: Record<string, string>;
|
||||
personProperties?: Record<string, string>;
|
||||
groupProperties?: Record<string, Record<string, string>>;
|
||||
} => {
|
||||
return {
|
||||
personProperties: {
|
||||
email: jwt?.email ?? '',
|
||||
},
|
||||
groupProperties: {
|
||||
// Add properties to group users into different groups, such as billing plan.
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract a distinct ID from a JWT and request.
|
||||
*
|
||||
* Will fallback to a random ID if no ID could be extracted from either the JWT or request.
|
||||
*
|
||||
* @param jwt The JWT of the current user.
|
||||
* @param request Request potentially containing a PostHog `distinct_id` cookie.
|
||||
* @returns A distinct user ID.
|
||||
*/
|
||||
export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): string => {
|
||||
const config = extractPostHogConfig();
|
||||
|
||||
const email = jwt?.email;
|
||||
const userId = jwt?.id.toString();
|
||||
|
||||
let fallbackDistinctId = nanoid();
|
||||
|
||||
if (config) {
|
||||
try {
|
||||
const postHogCookie = JSON.parse(
|
||||
request.cookies.get(`ph_${config.key}_posthog`)?.value ?? '',
|
||||
);
|
||||
|
||||
const postHogDistinctId = postHogCookie['distinct_id'];
|
||||
|
||||
if (typeof postHogDistinctId === 'string') {
|
||||
fallbackDistinctId = postHogDistinctId;
|
||||
}
|
||||
} catch {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
return email ?? userId ?? fallbackDistinctId;
|
||||
};
|
||||
@ -13,6 +13,9 @@ export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForD
|
||||
userId,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return fields;
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export interface SetFieldsForDocumentOptions {
|
||||
userId: number;
|
||||
documentId: number;
|
||||
fields: {
|
||||
id?: number | null;
|
||||
type: FieldType;
|
||||
signerEmail: string;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
@ -54,62 +55,56 @@ export const setFieldsForDocument = async ({
|
||||
|
||||
return {
|
||||
...field,
|
||||
...existing,
|
||||
_persisted: existing,
|
||||
};
|
||||
})
|
||||
.filter((field) => {
|
||||
return (
|
||||
field.Recipient?.sendStatus !== SendStatus.SENT &&
|
||||
field.Recipient?.signingStatus !== SigningStatus.SIGNED
|
||||
field._persisted?.Recipient?.sendStatus !== SendStatus.SENT &&
|
||||
field._persisted?.Recipient?.signingStatus !== SigningStatus.SIGNED
|
||||
);
|
||||
});
|
||||
|
||||
const persistedFields = await prisma.$transaction(
|
||||
// Disabling as wrapping promises here causes type issues
|
||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||
linkedFields.map((field) =>
|
||||
field.id
|
||||
? prisma.field.update({
|
||||
where: {
|
||||
id: field.id,
|
||||
recipientId: field.recipientId,
|
||||
documentId,
|
||||
prisma.field.upsert({
|
||||
where: {
|
||||
id: field._persisted?.id ?? -1,
|
||||
documentId,
|
||||
},
|
||||
update: {
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.pageWidth,
|
||||
height: field.pageHeight,
|
||||
},
|
||||
create: {
|
||||
type: field.type,
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.pageWidth,
|
||||
height: field.pageHeight,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
Document: {
|
||||
connect: {
|
||||
id: documentId,
|
||||
},
|
||||
data: {
|
||||
type: field.type,
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.pageWidth,
|
||||
height: field.pageHeight,
|
||||
},
|
||||
})
|
||||
: prisma.field.create({
|
||||
data: {
|
||||
// TODO: Rewrite this entire transaction because this is a mess
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
type: field.type!,
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.pageWidth,
|
||||
height: field.pageHeight,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
|
||||
Document: {
|
||||
connect: {
|
||||
id: document.id,
|
||||
},
|
||||
},
|
||||
Recipient: {
|
||||
connect: {
|
||||
documentId_email: {
|
||||
documentId: document.id,
|
||||
email: field.signerEmail,
|
||||
},
|
||||
},
|
||||
},
|
||||
Recipient: {
|
||||
connect: {
|
||||
documentId_email: {
|
||||
documentId,
|
||||
email: field.signerEmail.toLowerCase(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@ -1,25 +1,31 @@
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { readFileSync } from 'fs';
|
||||
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
||||
|
||||
import {
|
||||
CAVEAT_FONT_PATH,
|
||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||
DEFAULT_STANDARD_FONT_SIZE,
|
||||
MIN_HANDWRITING_FONT_SIZE,
|
||||
MIN_STANDARD_FONT_SIZE,
|
||||
} from '@documenso/lib/constants/pdf';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
|
||||
const DEFAULT_STANDARD_FONT_SIZE = 15;
|
||||
const DEFAULT_HANDWRITING_FONT_SIZE = 50;
|
||||
|
||||
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||
// Fetch the font file from the public URL.
|
||||
const fontResponse = await fetch(CAVEAT_FONT_PATH);
|
||||
const fontCaveat = await fontResponse.arrayBuffer();
|
||||
|
||||
const isSignatureField = isSignatureFieldType(field.type);
|
||||
|
||||
pdf.registerFontkit(fontkit);
|
||||
|
||||
const fontCaveat = readFileSync('./public/fonts/caveat.ttf');
|
||||
|
||||
const pages = pdf.getPages();
|
||||
|
||||
const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
|
||||
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
|
||||
let fontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
|
||||
let fontSize = maxFontSize;
|
||||
|
||||
const page = pages.at(field.page - 1);
|
||||
|
||||
@ -50,11 +56,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
let imageWidth = image.width;
|
||||
let imageHeight = image.height;
|
||||
|
||||
const initialDimensions = {
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
};
|
||||
|
||||
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
||||
|
||||
imageWidth = imageWidth * scalingFactor;
|
||||
@ -76,14 +77,9 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
|
||||
const textHeight = font.heightAtSize(fontSize);
|
||||
|
||||
const initialDimensions = {
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
};
|
||||
|
||||
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
||||
|
||||
fontSize = Math.max(fontSize * scalingFactor, maxFontSize);
|
||||
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
||||
textWidth = font.widthOfTextAtSize(field.customText, fontSize);
|
||||
|
||||
const textX = fieldX + (fieldWidth - textWidth) / 2;
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import * as fs from 'fs';
|
||||
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
||||
|
||||
import { CAVEAT_FONT_PATH } from '../../constants/pdf';
|
||||
|
||||
export async function insertTextInPDF(
|
||||
pdfAsBase64: string,
|
||||
text: string,
|
||||
@ -10,13 +11,15 @@ export async function insertTextInPDF(
|
||||
page = 0,
|
||||
useHandwritingFont = true,
|
||||
): Promise<string> {
|
||||
const fontBytes = fs.readFileSync('./public/fonts/caveat.ttf');
|
||||
// Fetch the font file from the public URL.
|
||||
const fontResponse = await fetch(CAVEAT_FONT_PATH);
|
||||
const fontCaveat = await fontResponse.arrayBuffer();
|
||||
|
||||
const pdfDoc = await PDFDocument.load(pdfAsBase64);
|
||||
|
||||
pdfDoc.registerFontkit(fontkit);
|
||||
|
||||
const font = await pdfDoc.embedFont(useHandwritingFont ? fontBytes : StandardFonts.Helvetica);
|
||||
const font = await pdfDoc.embedFont(useHandwritingFont ? fontCaveat : StandardFonts.Helvetica);
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
const pdfPage = pages[page];
|
||||
|
||||
@ -16,6 +16,9 @@ export const getRecipientsForDocument = async ({
|
||||
userId,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return recipients;
|
||||
|
||||
@ -29,6 +29,11 @@ export const setRecipientsForDocument = async ({
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
email: recipient.email.toLowerCase(),
|
||||
}));
|
||||
|
||||
const existingRecipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId,
|
||||
@ -37,13 +42,13 @@ export const setRecipientsForDocument = async ({
|
||||
|
||||
const removedRecipients = existingRecipients.filter(
|
||||
(existingRecipient) =>
|
||||
!recipients.find(
|
||||
!normalizedRecipients.find(
|
||||
(recipient) =>
|
||||
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
||||
),
|
||||
);
|
||||
|
||||
const linkedRecipients = recipients
|
||||
const linkedRecipients = normalizedRecipients
|
||||
.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
@ -52,37 +57,37 @@ export const setRecipientsForDocument = async ({
|
||||
|
||||
return {
|
||||
...recipient,
|
||||
...existing,
|
||||
_persisted: existing,
|
||||
};
|
||||
})
|
||||
.filter((recipient) => {
|
||||
return (
|
||||
recipient.sendStatus !== SendStatus.SENT && recipient.signingStatus !== SigningStatus.SIGNED
|
||||
recipient._persisted?.sendStatus !== SendStatus.SENT &&
|
||||
recipient._persisted?.signingStatus !== SigningStatus.SIGNED
|
||||
);
|
||||
});
|
||||
|
||||
const persistedRecipients = await prisma.$transaction(
|
||||
// Disabling as wrapping promises here causes type issues
|
||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||
linkedRecipients.map((recipient) =>
|
||||
recipient.id
|
||||
? prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
documentId,
|
||||
},
|
||||
data: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
documentId,
|
||||
},
|
||||
})
|
||||
: prisma.recipient.create({
|
||||
data: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
token: nanoid(),
|
||||
documentId,
|
||||
},
|
||||
}),
|
||||
prisma.recipient.upsert({
|
||||
where: {
|
||||
id: recipient._persisted?.id ?? -1,
|
||||
documentId,
|
||||
},
|
||||
update: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
documentId,
|
||||
},
|
||||
create: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
token: nanoid(),
|
||||
documentId,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
58
packages/lib/server-only/share/create-or-get-share-link.ts
Normal file
58
packages/lib/server-only/share/create-or-get-share-link.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { alphaid } from '../../universal/id';
|
||||
|
||||
export type CreateSharingIdOptions =
|
||||
| {
|
||||
documentId: number;
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const createOrGetShareLink = async ({ documentId, ...options }: CreateSharingIdOptions) => {
|
||||
const email = await match(options)
|
||||
.with({ token: P.string }, async ({ token }) => {
|
||||
return await prisma.recipient
|
||||
.findFirst({
|
||||
where: {
|
||||
documentId,
|
||||
token,
|
||||
},
|
||||
})
|
||||
.then((recipient) => recipient?.email);
|
||||
})
|
||||
.with({ userId: P.number }, async ({ userId }) => {
|
||||
return await prisma.user
|
||||
.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
})
|
||||
.then((user) => user?.email);
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
if (!email) {
|
||||
throw new Error('Unable to create share link for document with the given email');
|
||||
}
|
||||
|
||||
return await prisma.documentShareLink.upsert({
|
||||
where: {
|
||||
documentId_email: {
|
||||
email,
|
||||
documentId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
email,
|
||||
documentId,
|
||||
slug: alphaid(14),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetRecipientOrSenderByShareLinkSlugOptions = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export const getRecipientOrSenderByShareLinkSlug = async ({
|
||||
slug,
|
||||
}: GetRecipientOrSenderByShareLinkSlugOptions) => {
|
||||
const { documentId, email } = await prisma.documentShareLink.findFirstOrThrow({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
documentId,
|
||||
email,
|
||||
},
|
||||
include: {
|
||||
Signature: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (recipient) {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
const sender = await prisma.user.findFirst({
|
||||
where: {
|
||||
Document: { some: { id: documentId } },
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (sender) {
|
||||
return sender;
|
||||
}
|
||||
|
||||
throw new Error('Recipient or sender not found');
|
||||
};
|
||||
13
packages/lib/server-only/share/get-share-link-by-slug.ts
Normal file
13
packages/lib/server-only/share/get-share-link-by-slug.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetShareLinkBySlugOptions = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export const getShareLinkBySlug = async ({ slug }: GetShareLinkBySlugOptions) => {
|
||||
return await prisma.documentShareLink.findFirstOrThrow({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -6,7 +6,7 @@ export type GetSubscriptionByUserIdOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const getSubscriptionByUserId = ({ userId }: GetSubscriptionByUserIdOptions) => {
|
||||
export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => {
|
||||
return prisma.subscription.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
|
||||
@ -7,9 +7,14 @@ import { SALT_ROUNDS } from '../../constants/auth';
|
||||
export type UpdatePasswordOptions = {
|
||||
userId: number;
|
||||
password: string;
|
||||
currentPassword: string;
|
||||
};
|
||||
|
||||
export const updatePassword = async ({ userId, password }: UpdatePasswordOptions) => {
|
||||
export const updatePassword = async ({
|
||||
userId,
|
||||
password,
|
||||
currentPassword,
|
||||
}: UpdatePasswordOptions) => {
|
||||
// Existence check
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
@ -17,23 +22,29 @@ export const updatePassword = async ({ userId, password }: UpdatePasswordOptions
|
||||
},
|
||||
});
|
||||
|
||||
const hashedPassword = await hash(password, SALT_ROUNDS);
|
||||
|
||||
if (user.password) {
|
||||
// Compare the new password with the old password
|
||||
const isSamePassword = await compare(password, user.password);
|
||||
|
||||
if (isSamePassword) {
|
||||
throw new Error('Your new password cannot be the same as your old password.');
|
||||
}
|
||||
if (!user.password) {
|
||||
throw new Error('User has no password');
|
||||
}
|
||||
|
||||
const isCurrentPasswordValid = await compare(currentPassword, user.password);
|
||||
if (!isCurrentPasswordValid) {
|
||||
throw new Error('Current password is incorrect.');
|
||||
}
|
||||
|
||||
// Compare the new password with the old password
|
||||
const isSamePassword = await compare(password, user.password);
|
||||
if (isSamePassword) {
|
||||
throw new Error('Your new password cannot be the same as your old password.');
|
||||
}
|
||||
|
||||
const hashedNewPassword = await hash(password, SALT_ROUNDS);
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
password: hashedNewPassword,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
1
packages/lib/types/font.d.ts
vendored
Normal file
1
packages/lib/types/font.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module '*.ttf';
|
||||
1
packages/lib/universal/base64.ts
Normal file
1
packages/lib/universal/base64.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from '@scure/base';
|
||||
5
packages/lib/universal/generate-twitter-intent.ts
Normal file
5
packages/lib/universal/generate-twitter-intent.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const generateTwitterIntent = (text: string, shareUrl: string) => {
|
||||
return `https://twitter.com/intent/tweet?text=${encodeURIComponent(
|
||||
text,
|
||||
)}%0A%0A${encodeURIComponent(shareUrl)}`;
|
||||
};
|
||||
104
packages/lib/universal/get-feature-flag.ts
Normal file
104
packages/lib/universal/get-feature-flag.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
TFeatureFlagValue,
|
||||
ZFeatureFlagValueSchema,
|
||||
} from '@documenso/lib/client-only/providers/feature-flag.types';
|
||||
import { APP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
/**
|
||||
* Evaluate whether a flag is enabled for the current user.
|
||||
*
|
||||
* @param flag The flag to evaluate.
|
||||
* @param options See `GetFlagOptions`.
|
||||
* @returns Whether the flag is enabled, or the variant value of the flag.
|
||||
*/
|
||||
export const getFlag = async (
|
||||
flag: string,
|
||||
options?: GetFlagOptions,
|
||||
): Promise<TFeatureFlagValue> => {
|
||||
const requestHeaders = options?.requestHeaders ?? {};
|
||||
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return LOCAL_FEATURE_FLAGS[flag] ?? true;
|
||||
}
|
||||
|
||||
const url = new URL(`${APP_BASE_URL}/api/feature-flag/get`);
|
||||
url.searchParams.set('flag', flag);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
},
|
||||
next: {
|
||||
revalidate: 60,
|
||||
},
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZFeatureFlagValueSchema.parse(res))
|
||||
.catch(() => false);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all feature flags for the current user if possible.
|
||||
*
|
||||
* @param options See `GetFlagOptions`.
|
||||
* @returns A record of flags and their values for the user derived from the headers.
|
||||
*/
|
||||
export const getAllFlags = async (
|
||||
options?: GetFlagOptions,
|
||||
): Promise<Record<string, TFeatureFlagValue>> => {
|
||||
const requestHeaders = options?.requestHeaders ?? {};
|
||||
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return LOCAL_FEATURE_FLAGS;
|
||||
}
|
||||
|
||||
const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`);
|
||||
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
},
|
||||
next: {
|
||||
revalidate: 60,
|
||||
},
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
|
||||
.catch(() => LOCAL_FEATURE_FLAGS);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all feature flags for anonymous users.
|
||||
*
|
||||
* @returns A record of flags and their values.
|
||||
*/
|
||||
export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFlagValue>> => {
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return LOCAL_FEATURE_FLAGS;
|
||||
}
|
||||
|
||||
const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`);
|
||||
|
||||
return fetch(url, {
|
||||
next: {
|
||||
revalidate: 60,
|
||||
},
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
|
||||
.catch(() => LOCAL_FEATURE_FLAGS);
|
||||
};
|
||||
|
||||
interface GetFlagOptions {
|
||||
/**
|
||||
* The headers to attach to the request to evaluate flags.
|
||||
*
|
||||
* The authenticated user will be derived from the headers if possible.
|
||||
*/
|
||||
requestHeaders: Record<string, string>;
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10);
|
||||
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 21);
|
||||
|
||||
export { nanoid } from 'nanoid';
|
||||
|
||||
@ -17,7 +17,7 @@ import { alphaid } from '../id';
|
||||
export const getPresignPostUrl = async (fileName: string, contentType: string) => {
|
||||
const client = getS3Client();
|
||||
|
||||
const user = await getServerComponentSession();
|
||||
const { user } = await getServerComponentSession();
|
||||
|
||||
// Get the basename and extension for the file
|
||||
const { name, ext } = path.parse(fileName);
|
||||
|
||||
41
packages/lib/utils/fields.ts
Normal file
41
packages/lib/utils/fields.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { Field } from '@documenso/prisma/client';
|
||||
|
||||
/**
|
||||
* Sort the fields by the Y position on the document.
|
||||
*/
|
||||
export const sortFieldsByPosition = (fields: Field[]): Field[] => {
|
||||
const clonedFields: Field[] = JSON.parse(JSON.stringify(fields));
|
||||
|
||||
// Sort by page first, then position on page second.
|
||||
return clonedFields.sort((a, b) => a.page - b.page || Number(a.positionY) - Number(b.positionY));
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate whether all the provided fields are inserted.
|
||||
*
|
||||
* If there are any non-inserted fields it will be highlighted and scrolled into view.
|
||||
*
|
||||
* @returns `true` if all fields are inserted, `false` otherwise.
|
||||
*/
|
||||
export const validateFieldsInserted = (fields: Field[]): boolean => {
|
||||
const fieldCardElements = document.getElementsByClassName('field-card-container');
|
||||
|
||||
// Attach validate attribute on all fields.
|
||||
Array.from(fieldCardElements).forEach((element) => {
|
||||
element.setAttribute('data-validate', 'true');
|
||||
});
|
||||
|
||||
const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||
|
||||
const firstUninsertedField = uninsertedFields[0];
|
||||
|
||||
const firstUninsertedFieldElement =
|
||||
firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`);
|
||||
|
||||
if (firstUninsertedFieldElement) {
|
||||
firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return false;
|
||||
}
|
||||
|
||||
return uninsertedFields.length === 0;
|
||||
};
|
||||
12
packages/lib/utils/render-custom-email-template.ts
Normal file
12
packages/lib/utils/render-custom-email-template.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export const renderCustomEmailTemplate = <T extends Record<string, string>>(
|
||||
template: string,
|
||||
variables: T,
|
||||
): string => {
|
||||
return template.replace(/\{(\S+)\}/g, (_, key) => {
|
||||
if (key in variables) {
|
||||
return variables[key];
|
||||
}
|
||||
|
||||
return key;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user