Merge branch 'main' into feat/account-deletion

This commit is contained in:
Lucas Smith
2024-02-24 23:18:14 +11:00
committed by GitHub
180 changed files with 4418 additions and 57739 deletions

View File

@ -15,7 +15,7 @@ test('[PR-713]: should see sent documents', async ({ page }) => {
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').fill('sent');
await page.getByPlaceholder('Type a command or search...').first().fill('sent');
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
});
@ -32,7 +32,7 @@ test('[PR-713]: should see received documents', async ({ page }) => {
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').fill('received');
await page.getByPlaceholder('Type a command or search...').first().fill('received');
await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
});
@ -49,6 +49,6 @@ test('[PR-713]: should be able to search by recipient', async ({ page }) => {
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').fill(recipient.email);
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
});

View File

@ -1,20 +1,19 @@
import { type Page, expect, test } from '@playwright/test';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import {
extractUserVerificationToken,
seedUser,
unseedUser,
unseedUserByEmail,
} from '@documenso/prisma/seed/users';
test.use({ storageState: { cookies: [], origins: [] } });
/*
Using them sequentially so the 2nd test
uses the details from the 1st (registration) test
*/
test.describe.configure({ mode: 'serial' });
const username = 'Test User';
const email = 'test-user@auth-flow.documenso.com';
const password = 'Password123#';
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
const username = 'Test User';
const email = `test-user-${Date.now()}@auth-flow.documenso.com`;
const password = 'Password123#';
await page.goto('/signup');
await page.getByLabel('Name').fill(username);
await page.getByLabel('Email').fill(email);
@ -31,25 +30,33 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
}
await page.getByRole('button', { name: 'Sign Up', exact: true }).click();
await page.waitForURL('/unverified-account');
const { token } = await extractUserVerificationToken(email);
await page.goto(`/verify-email/${token}`);
await expect(page.getByRole('heading')).toContainText('Email Confirmed!');
await page.getByRole('link', { name: 'Go back home' }).click();
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
await unseedUserByEmail(email);
});
test('user can login with user and password', async ({ page }: { page: Page }) => {
const user = await seedUser();
await page.goto('/signin');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password', { exact: true }).fill('password');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
});
test.afterAll('Teardown', async () => {
try {
await deleteUser({ email });
} catch (e) {
throw new Error(`Error deleting user: ${e}`);
}
await unseedUser(user.id);
});

View File

@ -12,7 +12,7 @@ export type GetLimitsOptions = {
export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => {
const requestHeaders = headers ?? {};
const url = new URL(`${APP_BASE_URL}/api/limits`);
const url = new URL('/api/limits', APP_BASE_URL() ?? 'http://localhost:3000');
if (teamId) {
requestHeaders['team-id'] = teamId.toString();

View File

@ -1,11 +1,10 @@
import { DateTime } from 'luxon';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { getPricesByPlan } from '../stripe/get-prices-by-plan';
import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import { ZLimitsSchema } from './schema';
@ -16,7 +15,7 @@ export type GetServerLimitsOptions = {
};
export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => {
if (!IS_BILLING_ENABLED) {
if (!IS_BILLING_ENABLED()) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
@ -56,10 +55,11 @@ const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
);
if (activeSubscriptions.length > 0) {
const communityPlanPrices = await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
const documentPlanPrices = await getDocumentRelatedPrices();
for (const subscription of activeSubscriptions) {
const price = communityPlanPrices.find((price) => price.id === subscription.priceId);
const price = documentPlanPrices.find((price) => price.id === subscription.priceId);
if (!price || typeof price.product === 'string' || price.product.deleted) {
continue;
}

View File

@ -0,0 +1,10 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the Stripe prices of items that affect the amount of documents a user can create.
*/
export const getDocumentRelatedPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
};

View File

@ -0,0 +1,13 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
export const getEnterprisePlanPrices = async () => {
return await getPricesByPlan(STRIPE_PLAN_TYPE.ENTERPRISE);
};
export const getEnterprisePlanPriceIds = async () => {
const prices = await getEnterprisePlanPrices();
return prices.map((price) => price.id);
};

View File

@ -1,14 +1,18 @@
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
export const getPricesByPlan = async (
plan: (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE],
) => {
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
const planTypes = typeof plan === 'string' ? [plan] : plan;
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR ');
const { data: prices } = await stripe.prices.search({
query: `metadata['plan']:'${plan}' type:'recurring'`,
query,
expand: ['data.product'],
limit: 100,
});
return prices;
return prices.filter((price) => price.type === 'recurring');
};

View File

@ -0,0 +1,10 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the prices of items that count as the account's primary plan.
*/
export const getPrimaryAccountPlanPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
};

View File

@ -0,0 +1,17 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the Stripe prices of items that affect the amount of teams a user can create.
*/
export const getTeamRelatedPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
};
/**
* Returns the Stripe price IDs of items that affect the amount of teams a user can create.
*/
export const getTeamRelatedPriceIds = async () => {
return await getTeamRelatedPrices().then((prices) => prices.map((price) => price.id));
};

View File

@ -2,13 +2,13 @@ import type Stripe from 'stripe';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { type Subscription, type Team, type User } from '@documenso/prisma/client';
import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
import { getCommunityPlanPriceIds } from './get-community-plan-prices';
import { getTeamPrices } from './get-team-prices';
import { getTeamRelatedPriceIds } from './get-team-related-prices';
type TransferStripeSubscriptionOptions = {
/**
@ -46,14 +46,14 @@ export const transferTeamSubscription = async ({
throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.');
}
const [communityPlanIds, teamSeatPrices] = await Promise.all([
getCommunityPlanPriceIds(),
const [teamRelatedPlanPriceIds, teamSeatPrices] = await Promise.all([
getTeamRelatedPriceIds(),
getTeamPrices(),
]);
const teamSubscriptionRequired = !subscriptionsContainsActiveCommunityPlan(
const teamSubscriptionRequired = !subscriptionsContainsActivePlan(
user.Subscription,
communityPlanIds,
teamRelatedPlanPriceIds,
);
let teamSubscription: Stripe.Subscription | null = null;

View File

@ -1,3 +1,5 @@
import { env } from 'next-runtime-env';
import { Button, Column, Img, Link, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
@ -10,7 +12,9 @@ export const TemplateDocumentSelfSigned = ({
documentName,
assetBaseUrl,
}: TemplateDocumentSelfSignedProps) => {
const signUpUrl = `${process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`;
const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
const signUpUrl = `${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();

View File

@ -1,3 +1,5 @@
import { env } from 'next-runtime-env';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
@ -8,6 +10,8 @@ export interface TemplateResetPasswordProps {
}
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
@ -24,7 +28,7 @@ export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordPro
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
href={`${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
>
Sign In
</Button>

View File

@ -0,0 +1,19 @@
export type DownloadFileOptions = {
filename: string;
data: Blob;
};
export const downloadFile = ({ filename, data }: DownloadFileOptions) => {
if (typeof window === 'undefined') {
throw new Error('downloadFile can only be called in browser environments');
}
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(data);
link.download = filename;
link.click();
window.URL.revokeObjectURL(link.href);
};

View File

@ -1,6 +1,7 @@
import type { DocumentData } from '@documenso/prisma/client';
import { getFile } from '../universal/upload/get-file';
import { downloadFile } from './download-file';
type DownloadPDFProps = {
documentData: DocumentData;
@ -14,16 +15,12 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps)
type: 'application/pdf',
});
const link = window.document.createElement('a');
const [baseTitle] = fileName?.includes('.pdf')
? fileName.split('.pdf')
: [fileName ?? 'document'];
link.href = window.URL.createObjectURL(blob);
link.download = `${baseTitle}_signed.pdf`;
link.click();
window.URL.revokeObjectURL(link.href);
downloadFile({
filename: baseTitle,
data: blob,
});
};

View File

@ -0,0 +1,13 @@
import type { EffectCallback } from 'react';
import { useEffect } from 'react';
/**
* Dangerously runs an effect "once" by ignoring the depedencies of a given effect.
*
* DANGER: The effect will run twice in concurrent react and development environments.
*/
export const unsafe_useEffectOnce = (callback: EffectCallback) => {
// Intentionally avoiding exhaustive deps and rule of hooks here
// eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/rules-of-hooks
return useEffect(callback, []);
};

View File

@ -1,16 +1,19 @@
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
export const IS_BILLING_ENABLED = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true';
import { env } from 'next-runtime-env';
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50;
export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web';
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL');
export const NEXT_PUBLIC_MARKETING_URL = () => env('NEXT_PUBLIC_MARKETING_URL');
export const APP_BASE_URL = IS_APP_WEB
? process.env.NEXT_PUBLIC_WEBAPP_URL
: process.env.NEXT_PUBLIC_MARKETING_URL;
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
export const WEBAPP_BASE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000';
export const APP_FOLDER = () => (IS_APP_MARKETING ? 'marketing' : 'web');
export const MARKETING_BASE_URL = process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001';
export const APP_BASE_URL = () =>
IS_APP_WEB ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PUBLIC_MARKETING_URL();
export const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000';
export const MARKETING_BASE_URL = NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001';

View File

@ -6,6 +6,5 @@ export enum STRIPE_CUSTOMER_TYPE {
export enum STRIPE_PLAN_TYPE {
TEAM = 'team',
COMMUNITY = 'community',
ENTERPRISE = 'enterprise',
}
export const TEAM_BILLING_DOMAIN = 'billing.team.documenso.com';

View File

@ -0,0 +1,19 @@
import { DOCUMENT_EMAIL_TYPE } from '../types/document-audit-logs';
export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
[DOCUMENT_EMAIL_TYPE.SIGNING_REQUEST]: {
description: 'Signing request',
},
[DOCUMENT_EMAIL_TYPE.VIEW_REQUEST]: {
description: 'Viewing request',
},
[DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: {
description: 'Approval request',
},
[DOCUMENT_EMAIL_TYPE.CC]: {
description: 'CC',
},
[DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: {
description: 'Document completed',
},
} satisfies Record<keyof typeof DOCUMENT_EMAIL_TYPE, unknown>;

View File

@ -1,5 +1,10 @@
import { env } from 'next-runtime-env';
import { APP_BASE_URL } from './app';
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
/**
* The flag name for global session recording feature flag.
*/
@ -16,8 +21,9 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
* Does not take any person or group properties into account.
*/
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_teams: true,
app_document_page_view_history_sheet: false,
marketing_header_single_player_mode: false,
} as const;
@ -25,8 +31,8 @@ export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
* Extract the PostHog configuration from the environment.
*/
export function extractPostHogConfig(): { key: string; host: string } | null {
const postHogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const postHogHost = `${APP_BASE_URL}/ingest`;
const postHogKey = NEXT_PUBLIC_POSTHOG_KEY();
const postHogHost = `${APP_BASE_URL()}/ingest`;
if (!postHogKey || !postHogHost) {
return null;

View File

@ -6,4 +6,4 @@ 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`;
export const CAVEAT_FONT_PATH = () => `${APP_BASE_URL()}/fonts/caveat.ttf`;

View File

@ -1,29 +1,31 @@
import { RecipientRole } from '@documenso/prisma/client';
export const RECIPIENT_ROLES_DESCRIPTION: {
[key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string };
} = {
export const RECIPIENT_ROLES_DESCRIPTION = {
[RecipientRole.APPROVER]: {
actionVerb: 'Approve',
actioned: 'Approved',
progressiveVerb: 'Approving',
roleName: 'Approver',
},
[RecipientRole.CC]: {
actionVerb: 'CC',
actioned: `CC'd`,
progressiveVerb: 'CC',
roleName: 'CC',
roleName: 'Cc',
},
[RecipientRole.SIGNER]: {
actionVerb: 'Sign',
actioned: 'Signed',
progressiveVerb: 'Signing',
roleName: 'Signer',
},
[RecipientRole.VIEWER]: {
actionVerb: 'View',
actioned: 'Viewed',
progressiveVerb: 'Viewing',
roleName: 'Viewer',
},
};
} satisfies Record<keyof typeof RecipientRole, unknown>;
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',

View File

@ -7,6 +7,7 @@ import type { JWT } from 'next-auth/jwt';
import CredentialsProvider from 'next-auth/providers/credentials';
import type { GoogleProfile } from 'next-auth/providers/google';
import GoogleProvider from 'next-auth/providers/google';
import { env } from 'next-runtime-env';
import { prisma } from '@documenso/prisma';
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
@ -221,7 +222,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
async signIn({ user }) {
// We do this to stop OAuth providers from creating an account
// when signups are disabled
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
const userData = await getUserByEmail({ email: user.email! });
return !!userData;

View File

@ -43,7 +43,7 @@ export const setupTwoFactorAuthentication = async ({
const secret = crypto.randomBytes(10);
const backupCodes = new Array(10)
const backupCodes = Array.from({ length: 10 })
.fill(null)
.map(() => crypto.randomBytes(5).toString('hex'))
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());

View File

@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma';
import { Role } from '@documenso/prisma/client';
import type { Role } from '@documenso/prisma/client';
export type UpdateUserOptions = {
id: number;

View File

@ -5,11 +5,16 @@ import { render } from '@documenso/email/render';
import { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendConfirmationEmailProps {
userId: number;
}
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
const NEXT_PRIVATE_SMTP_FROM_NAME = process.env.NEXT_PRIVATE_SMTP_FROM_NAME;
const NEXT_PRIVATE_SMTP_FROM_ADDRESS = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS;
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@ -30,10 +35,10 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
throw new Error('Verification token not found for the user');
}
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
const senderName = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
const senderAdress = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
const senderName = NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
const senderAdress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
assetBaseUrl,

View File

@ -5,6 +5,8 @@ import { render } from '@documenso/email/render';
import { ForgotPasswordTemplate } from '@documenso/email/templates/forgot-password';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendForgotPasswordOptions {
userId: number;
}
@ -29,8 +31,8 @@ export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions)
}
const token = user.PasswordResetToken[0].token;
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const resetPasswordLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/reset-password/${token}`;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const resetPasswordLink = `${NEXT_PUBLIC_WEBAPP_URL()}/reset-password/${token}`;
const template = createElement(ForgotPasswordTemplate, {
assetBaseUrl,

View File

@ -5,6 +5,8 @@ import { render } from '@documenso/email/render';
import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendResetPasswordOptions {
userId: number;
}
@ -16,7 +18,7 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
},
});
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(ResetPasswordTemplate, {
assetBaseUrl,

View File

@ -89,17 +89,21 @@ export const upsertDocumentMeta = async ({
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
documentId,
user,
requestMetadata,
data: {
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
},
}),
});
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
documentId,
user,
requestMetadata,
data: {
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
},
}),
});
}
return upsertedDocumentMeta;
});

View File

@ -8,28 +8,74 @@ import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type DeleteDocumentOptions = {
id: number;
userId: number;
status: DocumentStatus;
requestMetadata?: RequestMetadata;
};
export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => {
export const deleteDocument = async ({
id,
userId,
status,
requestMetadata,
}: DeleteDocumentOptions) => {
await prisma.document.findFirstOrThrow({
where: {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
// if the document is a draft, hard-delete
if (status === DocumentStatus.DRAFT) {
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
return await prisma.$transaction(async (tx) => {
// Currently redundant since deleting a document will delete the audit logs.
// However may be useful if we disassociate audit lgos and documents if required.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'HARD',
},
}),
});
return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
});
}
// if the document is pending, send cancellation emails to all recipients
if (status === DocumentStatus.PENDING) {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const document = await prisma.document.findUnique({
where: {
id,
@ -49,7 +95,7 @@ export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptio
if (document.Recipient.length > 0) {
await Promise.all(
document.Recipient.map(async (recipient) => {
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
@ -77,12 +123,26 @@ export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptio
}
// If the document is not a draft, only soft-delete.
return await prisma.document.update({
where: {
id,
},
data: {
deletedAt: new Date().toISOString(),
},
return await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'SOFT',
},
}),
});
return await tx.document.update({
where: {
id,
},
data: {
deletedAt: new Date().toISOString(),
},
});
});
};

View File

@ -0,0 +1,115 @@
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { prisma } from '@documenso/prisma';
import type { DocumentAuditLog } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
export interface FindDocumentAuditLogsOptions {
userId: number;
documentId: number;
page?: number;
perPage?: number;
orderBy?: {
column: keyof DocumentAuditLog;
direction: 'asc' | 'desc';
};
cursor?: string;
filterForRecentActivity?: boolean;
}
export const findDocumentAuditLogs = async ({
userId,
documentId,
page = 1,
perPage = 30,
orderBy,
cursor,
filterForRecentActivity,
}: FindDocumentAuditLogsOptions) => {
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
await prisma.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
const whereClause: Prisma.DocumentAuditLogWhereInput = {
documentId,
};
// Filter events down to what we consider recent activity.
if (filterForRecentActivity) {
whereClause.OR = [
{
type: {
in: [
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
],
},
},
{
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
data: {
path: ['isResending'],
equals: true,
},
},
];
}
const [data, count] = await Promise.all([
prisma.documentAuditLog.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage + 1,
orderBy: {
[orderByColumn]: orderByDirection,
},
cursor: cursor ? { id: cursor } : undefined,
}),
prisma.documentAuditLog.count({
where: whereClause,
}),
]);
let nextCursor: string | undefined = undefined;
const parsedData = data.map((auditLog) => parseDocumentAuditLogData(auditLog));
if (parsedData.length > perPage) {
const nextItem = parsedData.pop();
nextCursor = nextItem!.id;
}
return {
data: parsedData,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
nextCursor,
} satisfies FindResultSet<typeof parsedData> & { nextCursor?: string };
};

View File

@ -21,6 +21,19 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
include: {
documentData: true,
documentMeta: true,
User: {
select: {
id: true,
name: true,
email: true,
},
},
team: {
select: {
id: true,
url: true,
},
},
},
});
};

View File

@ -16,6 +16,7 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { getDocumentWhereInput } from './get-document-by-id';
export type ResendDocumentOptions = {
@ -94,8 +95,8 @@ export const resendDocument = async ({
'document.name': document.title,
};
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
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,
@ -109,40 +110,43 @@ export const resendDocument = async ({
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
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)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
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: true,
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)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
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: true,
},
}),
});
},
{ timeout: 30_000 },
);
}),
);
};

View File

@ -5,6 +5,7 @@ import { render } from '@documenso/email/render';
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
@ -40,52 +41,55 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
document.Recipient.map(async (recipient) => {
const { email, name, token } = recipient;
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const assetBaseUrl = 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`,
downloadLink: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}/complete`,
});
await prisma.$transaction(async (tx) => {
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 }),
attachments: [
{
filename: document.title,
content: Buffer.from(buffer),
await prisma.$transaction(
async (tx) => {
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 }),
attachments: [
{
filename: document.title,
content: Buffer.from(buffer),
},
],
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user: null,
requestMetadata,
data: {
emailType: 'DOCUMENT_COMPLETED',
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
isResending: false,
},
}),
});
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user: null,
requestMetadata,
data: {
emailType: 'DOCUMENT_COMPLETED',
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
isResending: false,
},
}),
});
},
{ timeout: 30_000 },
);
}),
);
};

View File

@ -4,10 +4,6 @@ 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 {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '@documenso/lib/constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@ -15,6 +11,12 @@ import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-em
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus } 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';
export type SendDocumentOptions = {
documentId: number;
userId: number;
@ -91,8 +93,8 @@ export const sendDocument = async ({
'document.name': document.title,
};
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
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,
@ -106,59 +108,76 @@ export const sendDocument = async ({
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
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)
: `Please ${actionVerb.toLowerCase()} this document`,
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 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)
: `Please ${actionVerb.toLowerCase()} this document`,
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,
},
}),
});
},
{ timeout: 30_000 },
);
}),
);
const updatedDocument = await prisma.document.update({
where: {
id: documentId,
},
data: {
status: DocumentStatus.PENDING,
},
const updatedDocument = await prisma.$transaction(async (tx) => {
if (document.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
documentId: document.id,
requestMetadata,
user,
data: {},
}),
});
}
return await tx.document.update({
where: {
id: documentId,
},
data: {
status: DocumentStatus.PENDING,
},
});
});
return updatedDocument;

View File

@ -5,6 +5,8 @@ import { render } from '@documenso/email/render';
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendPendingEmailOptions {
documentId: number;
recipientId: number;
@ -41,7 +43,7 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
const { email, name } = recipient;
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentPendingEmailTemplate, {
documentName: document.title,

View File

@ -24,34 +24,38 @@ export const updateTitle = async ({
},
});
return await prisma.$transaction(async (tx) => {
const document = await tx.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
],
},
});
},
],
},
});
if (document.title === title) {
return document;
}
if (document.title === title) {
return document;
}
return await prisma.$transaction(async (tx) => {
// Instead of doing everything in a transaction we can use our knowledge
// of the current document title to ensure we aren't performing a conflicting
// update.
const updatedDocument = await tx.document.update({
where: {
id: documentId,
title: document.title,
},
data: {
title,

View File

@ -5,6 +5,7 @@ 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 { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
/**
@ -38,11 +39,11 @@ export default async function handlerFeatureFlagAll(req: Request) {
const origin = req.headers.get('origin');
if (origin) {
if (origin.startsWith(process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000')) {
if (origin.startsWith(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')) {
if (origin.startsWith(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
}

View File

@ -1,11 +1,14 @@
import { NextRequest, NextResponse } from 'next/server';
import { nanoid } from 'nanoid';
import { JWT, getToken } from 'next-auth/jwt';
import type { JWT } from 'next-auth/jwt';
import { 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';
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
/**
* Evaluate a single feature flag based on the current user if possible.
*
@ -57,11 +60,11 @@ export default async function handleFeatureFlagGet(req: Request) {
const origin = req.headers.get('Origin');
if (origin) {
if (origin.startsWith(process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000')) {
if (origin.startsWith(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')) {
if (origin.startsWith(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
}

View File

@ -1,3 +1,4 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { PDFDocument, StandardFonts } from 'pdf-lib';
@ -73,13 +74,17 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
height: imageHeight,
});
} else {
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textHeight = font.heightAtSize(fontSize);
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(field.customText, fontSize);
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textX = fieldX + (fieldWidth - textWidth) / 2;
let textY = fieldY + (fieldHeight - textHeight) / 2;

View File

@ -12,7 +12,7 @@ export async function insertTextInPDF(
useHandwritingFont = true,
): Promise<string> {
// Fetch the font file from the public URL.
const fontResponse = await fetch(CAVEAT_FONT_PATH);
const fontResponse = await fetch(CAVEAT_FONT_PATH());
const fontCaveat = await fontResponse.arrayBuffer();
const pdfDoc = await PDFDocument.load(pdfAsBase64);

View File

@ -1,4 +1,5 @@
import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client';
import { nanoid } from '../../universal/id';
@ -9,6 +10,7 @@ export type SetRecipientsForTemplateOptions = {
id?: number;
email: string;
name: string;
role: RecipientRole;
}[];
};
@ -84,11 +86,13 @@ export const setRecipientsForTemplate = async ({
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
templateId,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
templateId,
},

View File

@ -0,0 +1,9 @@
import { prisma } from '@documenso/prisma';
import { ZSiteSettingsSchema } from './schema';
export const getSiteSettings = async () => {
const settings = await prisma.siteSettings.findMany();
return ZSiteSettingsSchema.parse(settings);
};

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { ZSiteSettingsBannerSchema } from './schemas/banner';
// TODO: Use `z.union([...])` once we have more than one setting
export const ZSiteSettingSchema = ZSiteSettingsBannerSchema;
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;
export const ZSiteSettingsSchema = z.array(ZSiteSettingSchema);
export type TSiteSettingsSchema = z.infer<typeof ZSiteSettingsSchema>;

View File

@ -0,0 +1,9 @@
import { z } from 'zod';
export const ZSiteSettingsBaseSchema = z.object({
id: z.string().min(1),
enabled: z.boolean(),
data: z.never(),
});
export type TSiteSettingsBaseSchema = z.infer<typeof ZSiteSettingsBaseSchema>;

View File

@ -0,0 +1,23 @@
import { z } from 'zod';
import { ZSiteSettingsBaseSchema } from './_base';
export const SITE_SETTINGS_BANNER_ID = 'site.banner';
export const ZSiteSettingsBannerSchema = ZSiteSettingsBaseSchema.extend({
id: z.literal(SITE_SETTINGS_BANNER_ID),
data: z
.object({
content: z.string(),
bgColor: z.string(),
textColor: z.string(),
})
.optional()
.default({
content: '',
bgColor: '#000000',
textColor: '#FFFFFF',
}),
});
export type TSiteSettingsBannerSchema = z.infer<typeof ZSiteSettingsBannerSchema>;

View File

@ -0,0 +1,33 @@
import { prisma } from '@documenso/prisma';
import type { TSiteSettingSchema } from './schema';
export type UpsertSiteSettingOptions = TSiteSettingSchema & {
userId: number;
};
export const upsertSiteSetting = async ({
id,
enabled,
data,
userId,
}: UpsertSiteSettingOptions) => {
return await prisma.siteSettings.upsert({
where: {
id,
},
create: {
id,
enabled,
data,
lastModifiedByUserId: userId,
lastModifiedAt: new Date(),
},
update: {
enabled,
data,
lastModifiedByUserId: userId,
lastModifiedAt: new Date(),
},
});
};

View File

@ -9,55 +9,58 @@ export type AcceptTeamInvitationOptions = {
};
export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitationOptions) => {
await prisma.$transaction(async (tx) => {
const user = await tx.user.findFirstOrThrow({
where: {
id: userId,
},
});
await prisma.$transaction(
async (tx) => {
const user = await tx.user.findFirstOrThrow({
where: {
id: userId,
},
});
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
where: {
teamId,
email: user.email,
},
include: {
team: {
include: {
subscription: true,
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
where: {
teamId,
email: user.email,
},
include: {
team: {
include: {
subscription: true,
},
},
},
},
});
});
const { team } = teamMemberInvite;
const { team } = teamMemberInvite;
await tx.teamMember.create({
data: {
teamId: teamMemberInvite.teamId,
userId: user.id,
role: teamMemberInvite.role,
},
});
await tx.teamMemberInvite.delete({
where: {
id: teamMemberInvite.id,
},
});
if (IS_BILLING_ENABLED && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
await tx.teamMember.create({
data: {
teamId: teamMemberInvite.teamId,
userId: user.id,
role: teamMemberInvite.role,
},
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
await tx.teamMemberInvite.delete({
where: {
id: teamMemberInvite.id,
},
});
}
});
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId: teamMemberInvite.teamId,
},
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
});
}
},
{ timeout: 30_000 },
);
};

View File

@ -12,7 +12,7 @@ export const createTeamBillingPortal = async ({
userId,
teamId,
}: CreateTeamBillingPortalOptions) => {
if (!IS_BILLING_ENABLED) {
if (!IS_BILLING_ENABLED()) {
throw new Error('Billing is not enabled');
}

View File

@ -28,56 +28,59 @@ export const createTeamEmailVerification = async ({
data,
}: CreateTeamEmailVerificationOptions) => {
try {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
},
include: {
teamEmail: true,
emailVerification: true,
},
});
include: {
teamEmail: true,
emailVerification: true,
},
});
if (team.teamEmail || team.emailVerification) {
throw new AppError(
AppErrorCode.INVALID_REQUEST,
'Team already has an email or existing email verification.',
);
}
if (team.teamEmail || team.emailVerification) {
throw new AppError(
AppErrorCode.INVALID_REQUEST,
'Team already has an email or existing email verification.',
);
}
const existingTeamEmail = await tx.teamEmail.findFirst({
where: {
email: data.email,
},
});
const existingTeamEmail = await tx.teamEmail.findFirst({
where: {
email: data.email,
},
});
if (existingTeamEmail) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
}
if (existingTeamEmail) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
}
const { token, expiresAt } = createTokenVerification({ hours: 1 });
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.create({
data: {
token,
expiresAt,
email: data.email,
name: data.name,
teamId,
},
});
await tx.teamEmailVerification.create({
data: {
token,
expiresAt,
email: data.email,
name: data.name,
teamId,
},
});
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
});
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
},
{ timeout: 30_000 },
);
} catch (err) {
console.error(err);

View File

@ -2,11 +2,11 @@ import type Stripe from 'stripe';
import { z } from 'zod';
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
import { getCommunityPlanPriceIds } from '@documenso/ee/server-only/stripe/get-community-plan-prices';
import { getTeamRelatedPrices } from '@documenso/ee/server-only/stripe/get-team-related-prices';
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { Prisma, TeamMemberRole } from '@documenso/prisma/client';
@ -57,17 +57,16 @@ export const createTeam = async ({
},
});
let isPaymentRequired = IS_BILLING_ENABLED;
let isPaymentRequired = IS_BILLING_ENABLED();
let customerId: string | null = null;
if (IS_BILLING_ENABLED) {
const communityPlanPriceIds = await getCommunityPlanPriceIds();
isPaymentRequired = !subscriptionsContainsActiveCommunityPlan(
user.Subscription,
communityPlanPriceIds,
if (IS_BILLING_ENABLED()) {
const teamRelatedPriceIds = await getTeamRelatedPrices().then((prices) =>
prices.map((price) => price.id),
);
isPaymentRequired = !subscriptionsContainsActivePlan(user.Subscription, teamRelatedPriceIds);
customerId = await createTeamCustomer({
name: user.name ?? teamName,
email: user.email,

View File

@ -27,76 +27,81 @@ export const deleteTeamMembers = async ({
teamId,
teamMemberIds,
}: DeleteTeamMembersOptions) => {
await prisma.$transaction(async (tx) => {
// Find the team and validate that the user is allowed to remove members.
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
await prisma.$transaction(
async (tx) => {
// Find the team and validate that the user is allowed to remove members.
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
},
include: {
members: {
select: {
id: true,
userId: true,
role: true,
include: {
members: {
select: {
id: true,
userId: true,
role: true,
},
},
subscription: true,
},
});
const currentTeamMember = team.members.find((member) => member.userId === userId);
const teamMembersToRemove = team.members.filter((member) =>
teamMemberIds.includes(member.id),
);
if (!currentTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist');
}
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner');
}
const isMemberToRemoveHigherRole = teamMembersToRemove.some(
(member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role),
);
if (isMemberToRemoveHigherRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
}
// Remove the team members.
await tx.teamMember.deleteMany({
where: {
id: {
in: teamMemberIds,
},
teamId,
userId: {
not: team.ownerUserId,
},
},
subscription: true,
},
});
const currentTeamMember = team.members.find((member) => member.userId === userId);
const teamMembersToRemove = team.members.filter((member) => teamMemberIds.includes(member.id));
if (!currentTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist');
}
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner');
}
const isMemberToRemoveHigherRole = teamMembersToRemove.some(
(member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role),
);
if (isMemberToRemoveHigherRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
}
// Remove the team members.
await tx.teamMember.deleteMany({
where: {
id: {
in: teamMemberIds,
},
teamId,
userId: {
not: team.ownerUserId,
},
},
});
if (IS_BILLING_ENABLED && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,
},
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
});
}
});
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,
},
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
});
}
},
{ timeout: 30_000 },
);
};

View File

@ -9,34 +9,37 @@ export type DeleteTeamOptions = {
};
export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: userId,
},
include: {
subscription: true,
},
});
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: userId,
},
include: {
subscription: true,
},
});
if (team.subscription) {
await stripe.subscriptions
.cancel(team.subscription.planId, {
prorate: false,
invoice_now: true,
})
.catch((err) => {
console.error(err);
throw AppError.parseError(err);
});
}
if (team.subscription) {
await stripe.subscriptions
.cancel(team.subscription.planId, {
prorate: false,
invoice_now: true,
})
.catch((err) => {
console.error(err);
throw AppError.parseError(err);
});
}
await tx.team.delete({
where: {
id: teamId,
ownerUserId: userId,
},
});
});
await tx.team.delete({
where: {
id: teamId,
ownerUserId: userId,
},
});
},
{ timeout: 30_000 },
);
};

View File

@ -15,45 +15,48 @@ export type LeaveTeamOptions = {
};
export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: {
not: userId,
},
},
include: {
subscription: true,
},
});
await tx.teamMember.delete({
where: {
userId_teamId: {
userId,
teamId,
},
team: {
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: {
not: userId,
},
},
},
});
if (IS_BILLING_ENABLED && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,
include: {
subscription: true,
},
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
await tx.teamMember.delete({
where: {
userId_teamId: {
userId,
teamId,
},
team: {
ownerUserId: {
not: userId,
},
},
},
});
}
});
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,
},
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
});
}
},
{ timeout: 30_000 },
);
};

View File

@ -44,63 +44,66 @@ export const requestTeamOwnershipTransfer = async ({
// Todo: Clear payment methods disabled for now.
const clearPaymentMethods = false;
await prisma.$transaction(async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: userId,
members: {
some: {
userId: newOwnerUserId,
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: userId,
members: {
some: {
userId: newOwnerUserId,
},
},
},
},
});
});
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
},
});
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
},
});
const { token, expiresAt } = createTokenVerification({ minute: 10 });
const { token, expiresAt } = createTokenVerification({ minute: 10 });
const teamVerificationPayload = {
teamId,
token,
expiresAt,
userId: newOwnerUserId,
name: newOwnerUser.name ?? '',
email: newOwnerUser.email,
clearPaymentMethods,
};
await tx.teamTransferVerification.upsert({
where: {
const teamVerificationPayload = {
teamId,
},
create: teamVerificationPayload,
update: teamVerificationPayload,
});
token,
expiresAt,
userId: newOwnerUserId,
name: newOwnerUser.name ?? '',
email: newOwnerUser.email,
clearPaymentMethods,
};
const template = createElement(TeamTransferRequestTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
senderName: userName,
teamName: team.name,
teamUrl: team.url,
token,
});
await tx.teamTransferVerification.upsert({
where: {
teamId,
},
create: teamVerificationPayload,
update: teamVerificationPayload,
});
await mailer.sendMail({
to: newOwnerUser.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
});
const template = createElement(TeamTransferRequestTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
senderName: userName,
teamName: team.name,
teamUrl: team.url,
token,
});
await mailer.sendMail({
to: newOwnerUser.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
},
{ timeout: 30_000 },
);
};

View File

@ -17,49 +17,52 @@ export const resendTeamEmailVerification = async ({
userId,
teamId,
}: ResendTeamMemberInvitationOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
},
include: {
emailVerification: true,
},
});
include: {
emailVerification: true,
},
});
if (!team) {
throw new AppError('TeamNotFound', 'User is not a member of the team.');
}
if (!team) {
throw new AppError('TeamNotFound', 'User is not a member of the team.');
}
const { emailVerification } = team;
const { emailVerification } = team;
if (!emailVerification) {
throw new AppError(
'VerificationNotFound',
'No team email verification exists for this team.',
);
}
if (!emailVerification) {
throw new AppError(
'VerificationNotFound',
'No team email verification exists for this team.',
);
}
const { token, expiresAt } = createTokenVerification({ hours: 1 });
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.update({
where: {
teamId,
},
data: {
token,
expiresAt,
},
});
await tx.teamEmailVerification.update({
where: {
teamId,
},
data: {
token,
expiresAt,
},
});
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
});
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
},
{ timeout: 30_000 },
);
};

View File

@ -35,42 +35,45 @@ export const resendTeamMemberInvitation = async ({
teamId,
invitationId,
}: ResendTeamMemberInvitationOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
},
});
});
if (!team) {
throw new AppError('TeamNotFound', 'User is not a valid member of the team.');
}
if (!team) {
throw new AppError('TeamNotFound', 'User is not a valid member of the team.');
}
const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({
where: {
id: invitationId,
teamId,
},
});
const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({
where: {
id: invitationId,
teamId,
},
});
if (!teamMemberInvite) {
throw new AppError('InviteNotFound', 'No invite exists for this user.');
}
if (!teamMemberInvite) {
throw new AppError('InviteNotFound', 'No invite exists for this user.');
}
await sendTeamMemberInviteEmail({
email: teamMemberInvite.email,
token: teamMemberInvite.token,
teamName: team.name,
teamUrl: team.url,
senderName: userName,
});
});
await sendTeamMemberInviteEmail({
email: teamMemberInvite.email,
token: teamMemberInvite.token,
teamName: team.name,
teamUrl: team.url,
senderName: userName,
});
},
{ timeout: 30_000 },
);
};

View File

@ -11,78 +11,81 @@ export type TransferTeamOwnershipOptions = {
};
export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOptions) => {
await prisma.$transaction(async (tx) => {
const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({
where: {
token,
},
include: {
team: {
include: {
subscription: true,
await prisma.$transaction(
async (tx) => {
const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({
where: {
token,
},
include: {
team: {
include: {
subscription: true,
},
},
},
},
});
const { team, userId: newOwnerUserId } = teamTransferVerification;
await tx.teamTransferVerification.delete({
where: {
teamId: team.id,
},
});
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
teamMembers: {
some: {
teamId: team.id,
},
},
},
include: {
Subscription: true,
},
});
let teamSubscription: Stripe.Subscription | null = null;
if (IS_BILLING_ENABLED) {
teamSubscription = await transferTeamSubscription({
user: newOwnerUser,
team,
clearPaymentMethods: teamTransferVerification.clearPaymentMethods,
});
}
if (teamSubscription) {
await tx.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(teamSubscription, undefined, team.id),
);
}
const { team, userId: newOwnerUserId } = teamTransferVerification;
await tx.team.update({
where: {
id: team.id,
},
data: {
ownerUserId: newOwnerUserId,
members: {
update: {
where: {
userId_teamId: {
teamId: team.id,
userId: newOwnerUserId,
await tx.teamTransferVerification.delete({
where: {
teamId: team.id,
},
});
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
teamMembers: {
some: {
teamId: team.id,
},
},
},
include: {
Subscription: true,
},
});
let teamSubscription: Stripe.Subscription | null = null;
if (IS_BILLING_ENABLED()) {
teamSubscription = await transferTeamSubscription({
user: newOwnerUser,
team,
clearPaymentMethods: teamTransferVerification.clearPaymentMethods,
});
}
if (teamSubscription) {
await tx.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(teamSubscription, undefined, team.id),
);
}
await tx.team.update({
where: {
id: team.id,
},
data: {
ownerUserId: newOwnerUserId,
members: {
update: {
where: {
userId_teamId: {
teamId: team.id,
userId: newOwnerUserId,
},
},
data: {
role: TeamMemberRole.ADMIN,
},
},
data: {
role: TeamMemberRole.ADMIN,
},
},
},
},
});
});
});
},
{ timeout: 30_000 },
);
};

View File

@ -57,6 +57,7 @@ export const createDocumentFromTemplate = async ({
create: template.Recipient.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
})),
},

View File

@ -53,47 +53,50 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
await Promise.allSettled(
acceptedTeamInvites.map(async (invite) =>
prisma
.$transaction(async (tx) => {
await tx.teamMember.create({
data: {
teamId: invite.teamId,
userId: user.id,
role: invite.role,
},
});
await tx.teamMemberInvite.delete({
where: {
id: invite.id,
},
});
if (!IS_BILLING_ENABLED) {
return;
}
const team = await tx.team.findFirstOrThrow({
where: {
id: invite.teamId,
},
include: {
members: {
select: {
id: true,
},
.$transaction(
async (tx) => {
await tx.teamMember.create({
data: {
teamId: invite.teamId,
userId: user.id,
role: invite.role,
},
subscription: true,
},
});
if (team.subscription) {
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: team.members.length,
});
}
})
await tx.teamMemberInvite.delete({
where: {
id: invite.id,
},
});
if (!IS_BILLING_ENABLED()) {
return;
}
const team = await tx.team.findFirstOrThrow({
where: {
id: invite.teamId,
},
include: {
members: {
select: {
id: true,
},
},
subscription: true,
},
});
if (team.subscription) {
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: team.members.length,
});
}
},
{ timeout: 30_000 },
)
.catch(async () => {
await prisma.teamMemberInvite.update({
where: {
@ -108,7 +111,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
);
// Update the user record with a new or existing Stripe customer record.
if (IS_BILLING_ENABLED) {
if (IS_BILLING_ENABLED()) {
try {
return await getStripeCustomerByUser(user).then((session) => session.user);
} catch (err) {

View File

@ -21,15 +21,24 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'RECIPIENT_UPDATED',
// Document events.
'DOCUMENT_COMPLETED', // When the document is sealed and fully completed.
'DOCUMENT_CREATED', // When the document is created.
'DOCUMENT_DELETED', // When the document is soft deleted.
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
'DOCUMENT_META_UPDATED', // When the document meta data is updated.
'DOCUMENT_OPENED', // When the document is opened by a recipient.
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
]);
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
'SIGNING_REQUEST',
'VIEW_REQUEST',
'APPROVE_REQUEST',
'CC',
'DOCUMENT_COMPLETED',
'DOCUMENT_CREATED',
'DOCUMENT_DELETED',
'DOCUMENT_FIELD_INSERTED',
'DOCUMENT_FIELD_UNINSERTED',
'DOCUMENT_META_UPDATED',
'DOCUMENT_OPENED',
'DOCUMENT_TITLE_UPDATED',
'DOCUMENT_RECIPIENT_COMPLETED',
]);
export const ZDocumentMetaDiffTypeSchema = z.enum([
@ -40,10 +49,12 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([
'SUBJECT',
'TIMEZONE',
]);
export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']);
export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']);
export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum;
export const DOCUMENT_EMAIL_TYPE = ZDocumentAuditLogEmailTypeSchema.Enum;
export const DOCUMENT_META_DIFF_TYPE = ZDocumentMetaDiffTypeSchema.Enum;
export const FIELD_DIFF_TYPE = ZFieldDiffTypeSchema.Enum;
export const RECIPIENT_DIFF_TYPE = ZRecipientDiffTypeSchema.Enum;
@ -140,13 +151,7 @@ const ZBaseRecipientDataSchema = z.object({
export const ZDocumentAuditLogEventEmailSentSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT),
data: ZBaseRecipientDataSchema.extend({
emailType: z.enum([
'SIGNING_REQUEST',
'VIEW_REQUEST',
'APPROVE_REQUEST',
'CC',
'DOCUMENT_COMPLETED',
]),
emailType: ZDocumentAuditLogEmailTypeSchema,
isResending: z.boolean(),
}),
});
@ -171,6 +176,16 @@ export const ZDocumentAuditLogEventDocumentCreatedSchema = z.object({
}),
});
/**
* Event: Document deleted.
*/
export const ZDocumentAuditLogEventDocumentDeletedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED),
data: z.object({
type: z.enum(['SOFT', 'HARD']),
}),
});
/**
* Event: Document field inserted.
*/
@ -247,6 +262,14 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
data: ZBaseRecipientDataSchema,
});
/**
* Event: Document sent.
*/
export const ZDocumentAuditLogEventDocumentSentSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT),
data: z.object({}),
});
/**
* Event: Document title updated.
*/
@ -314,6 +337,11 @@ export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
documentId: z.number(),
name: z.string().optional().nullable(),
email: z.string().optional().nullable(),
userId: z.number().optional().nullable(),
userAgent: z.string().optional().nullable(),
ipAddress: z.string().optional().nullable(),
});
export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
@ -321,11 +349,13 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventEmailSentSchema,
ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentDeletedSchema,
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
ZDocumentAuditLogEventDocumentMetaUpdatedSchema,
ZDocumentAuditLogEventDocumentOpenedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
ZDocumentAuditLogEventDocumentSentSchema,
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
ZDocumentAuditLogEventFieldCreatedSchema,
ZDocumentAuditLogEventFieldRemovedSchema,
@ -348,3 +378,8 @@ export type TDocumentAuditLogDocumentMetaDiffSchema = z.infer<
export type TDocumentAuditLogRecipientDiffSchema = z.infer<
typeof ZDocumentAuditLogRecipientDiffSchema
>;
export type DocumentAuditLogByType<T = TDocumentAuditLog['type']> = Extract<
TDocumentAuditLog,
{ type: T }
>;

View File

@ -1,4 +1,6 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
export const getBaseUrl = () => {
if (typeof window !== 'undefined') {
return '';
@ -8,8 +10,10 @@ export const getBaseUrl = () => {
return `https://${process.env.VERCEL_URL}`;
}
if (process.env.NEXT_PUBLIC_WEBAPP_URL) {
return process.env.NEXT_PUBLIC_WEBAPP_URL;
const webAppUrl = NEXT_PUBLIC_WEBAPP_URL();
if (webAppUrl) {
return webAppUrl;
}
return `http://localhost:${process.env.PORT ?? 3000}`;

View File

@ -22,7 +22,7 @@ export const getFlag = async (
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
const url = new URL(`${APP_BASE_URL}/api/feature-flag/get`);
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`);
url.searchParams.set('flag', flag);
const response = await fetch(url, {
@ -55,7 +55,7 @@ export const getAllFlags = async (
return LOCAL_FEATURE_FLAGS;
}
const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`);
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/all`);
return fetch(url, {
headers: {
@ -80,7 +80,7 @@ export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFla
return LOCAL_FEATURE_FLAGS;
}
const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`);
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/all`);
return fetch(url, {
next: {

View File

@ -1,4 +1,5 @@
import { base64 } from '@scure/base';
import { env } from 'next-runtime-env';
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
@ -12,7 +13,9 @@ type File = {
};
export const putFile = async (file: File) => {
const { type, data } = await match(process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT)
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
const { type, data } = await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file))
.otherwise(async () => putFileInDatabase(file));

View File

@ -11,6 +11,7 @@ import {
} from '@aws-sdk/client-s3';
import slugify from '@sindresorhus/slugify';
import { type JWT, getToken } from 'next-auth/jwt';
import { env } from 'next-runtime-env';
import path from 'node:path';
import { APP_BASE_URL } from '../../constants/app';
@ -25,8 +26,10 @@ export const getPresignPostUrl = async (fileName: string, contentType: string) =
let token: JWT | null = null;
try {
const baseUrl = APP_BASE_URL() ?? 'http://localhost:3000';
token = await getToken({
req: new NextRequest(APP_BASE_URL ?? 'http://localhost:3000', {
req: new NextRequest(baseUrl, {
headers: headers(),
}),
});
@ -117,7 +120,9 @@ export const deleteS3File = async (key: string) => {
};
const getS3Client = () => {
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
if (NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
throw new Error('Invalid upload transport');
}

View File

@ -2,15 +2,14 @@ import type { Subscription } from '.prisma/client';
import { SubscriptionStatus } from '.prisma/client';
/**
* Returns true if there is a subscription that is active and is a community plan.
* Returns true if there is a subscription that is active and is one of the provided price IDs.
*/
export const subscriptionsContainsActiveCommunityPlan = (
export const subscriptionsContainsActivePlan = (
subscriptions: Subscription[],
communityPlanPriceIds: string[],
priceIds: string[],
) => {
return subscriptions.some(
(subscription) =>
subscription.status === SubscriptionStatus.ACTIVE &&
communityPlanPriceIds.includes(subscription.priceId),
subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId),
);
};

View File

@ -1,5 +1,14 @@
import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client';
import { match } from 'ts-pattern';
import type {
DocumentAuditLog,
DocumentMeta,
Field,
Recipient,
RecipientRole,
} from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../constants/recipient-roles';
import type {
TDocumentAuditLog,
TDocumentAuditLogDocumentMetaDiffSchema,
@ -7,6 +16,7 @@ import type {
TDocumentAuditLogRecipientDiffSchema,
} from '../types/document-audit-logs';
import {
DOCUMENT_AUDIT_LOG_TYPE,
DOCUMENT_META_DIFF_TYPE,
FIELD_DIFF_TYPE,
RECIPIENT_DIFF_TYPE,
@ -58,6 +68,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument
// Handle any required migrations here.
if (!data.success) {
console.error(data.error);
throw new Error('Migration required');
}
@ -203,3 +214,114 @@ export const diffDocumentMetaChanges = (
return diffs;
};
/**
* Formats the audit log into a description of the action.
*
* Provide a userId to prefix the action with the user, example 'X did Y'.
*/
export const formatDocumentAuditLogActionString = (
auditLog: TDocumentAuditLog,
userId?: number,
) => {
const { prefix, description } = formatDocumentAuditLogAction(auditLog, userId);
return prefix ? `${prefix} ${description}` : description;
};
/**
* Formats the audit log into a description of the action.
*
* Provide a userId to prefix the action with the user, example 'X did Y'.
*/
export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId?: number) => {
let prefix = userId === auditLog.userId ? 'You' : auditLog.name || auditLog.email || '';
const description = match(auditLog)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({
anonymous: 'A field was added',
identified: 'added a field',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({
anonymous: 'A field was removed',
identified: 'removed a field',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({
anonymous: 'A field was updated',
identified: 'updated a field',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({
anonymous: 'A recipient was added',
identified: 'added a recipient',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({
anonymous: 'A recipient was removed',
identified: 'removed a recipient',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({
anonymous: 'A recipient was updated',
identified: 'updated a recipient',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({
anonymous: 'Document created',
identified: 'created the document',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({
anonymous: 'Document deleted',
identified: 'deleted the document',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
anonymous: 'Field signed',
identified: 'signed a field',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({
anonymous: 'Field unsigned',
identified: 'unsigned a field',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
anonymous: 'Document updated',
identified: 'updated the document',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({
anonymous: 'Document opened',
identified: 'opened the document',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({
anonymous: 'Document title updated',
identified: 'updated the document title',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
anonymous: 'Document sent',
identified: 'sent the document',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const action = RECIPIENT_ROLES_DESCRIPTION[data.recipientRole as RecipientRole]?.actioned;
const value = action ? `${action.toLowerCase()} the document` : 'completed their task';
return {
anonymous: `Recipient ${value}`,
identified: value,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
anonymous: `Email ${data.isResending ? 'resent' : 'sent'}`,
identified: `${data.isResending ? 'resent' : 'sent'} an email`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => {
// Clear the prefix since this should be considered an 'anonymous' event.
prefix = '';
return {
anonymous: 'Document completed',
identified: 'Document completed',
};
})
.exhaustive();
return {
prefix,
description: prefix ? description.identified : description.anonymous,
};
};

View File

@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "Banner" (
"id" SERIAL NOT NULL,
"text" TEXT NOT NULL,
"customHTML" TEXT NOT NULL,
"userId" INTEGER,
CONSTRAINT "Banner_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Banner" ADD CONSTRAINT "Banner_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Banner" ADD COLUMN "show" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `customHTML` on the `Banner` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Banner" DROP COLUMN "customHTML";

View File

@ -0,0 +1,25 @@
/*
Warnings:
- You are about to drop the `Banner` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Banner" DROP CONSTRAINT "Banner_userId_fkey";
-- DropTable
DROP TABLE "Banner";
-- CreateTable
CREATE TABLE "SiteSettings" (
"id" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"data" JSONB NOT NULL,
"lastModifiedByUserId" INTEGER,
"lastModifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "SiteSettings" ADD CONSTRAINT "SiteSettings_lastModifiedByUserId_fkey" FOREIGN KEY ("lastModifiedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,13 @@
INSERT INTO "SiteSettings" ("id", "enabled", "data")
VALUES (
'site.banner',
FALSE,
jsonb_build_object(
'content',
'This is a test banner',
'bgColor',
'#000000',
'textColor',
'#ffffff'
)
);

View File

@ -47,6 +47,7 @@ model User {
VerificationToken VerificationToken[]
Template Template[]
securityAuditLogs UserSecurityAuditLog[]
siteSettings SiteSettings[]
@@index([email])
}
@ -210,15 +211,15 @@ model DocumentData {
}
model DocumentMeta {
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
}
enum ReadStatus {
@ -450,3 +451,12 @@ model Template {
@@unique([templateDocumentDataId])
}
model SiteSettings {
id String @id
enabled Boolean @default(false)
data Json
lastModifiedByUserId Int?
lastModifiedAt DateTime @default(now())
lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id])
}

View File

@ -32,3 +32,22 @@ export const unseedUser = async (userId: number) => {
},
});
};
export const unseedUserByEmail = async (email: string) => {
await prisma.user.delete({
where: {
email,
},
});
};
export const extractUserVerificationToken = async (email: string) => {
return await prisma.verificationToken.findFirstOrThrow({
where: {
identifier: 'confirmation-email',
user: {
email,
},
},
});
};

View File

@ -1,9 +1,10 @@
import { TRPCError } from '@trpc/server';
import { updateUser } from '@documenso/lib/server-only/admin/update-user';
import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting';
import { adminProcedure, router } from '../trpc';
import { ZUpdateProfileMutationByAdminSchema } from './schema';
import { ZUpdateProfileMutationByAdminSchema, ZUpdateSiteSettingMutationSchema } from './schema';
export const adminRouter = router({
updateUser: adminProcedure
@ -20,4 +21,24 @@ export const adminRouter = router({
});
}
}),
updateSiteSetting: adminProcedure
.input(ZUpdateSiteSettingMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
const { id, enabled, data } = input;
return await upsertSiteSetting({
id,
enabled,
data,
userId: ctx.user.id,
});
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to update the site setting provided.',
});
}
}),
});

View File

@ -1,6 +1,8 @@
import { Role } from '@prisma/client';
import z from 'zod';
import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema';
export const ZUpdateProfileMutationByAdminSchema = z.object({
id: z.number().min(1),
name: z.string().nullish(),
@ -11,3 +13,7 @@ export const ZUpdateProfileMutationByAdminSchema = z.object({
export type TUpdateProfileMutationByAdminSchema = z.infer<
typeof ZUpdateProfileMutationByAdminSchema
>;
export const ZUpdateSiteSettingMutationSchema = ZSiteSettingSchema;
export type TUpdateSiteSettingMutationSchema = z.infer<typeof ZUpdateSiteSettingMutationSchema>;

View File

@ -1,4 +1,5 @@
import { TRPCError } from '@trpc/server';
import { env } from 'next-runtime-env';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { compareSync } from '@documenso/lib/server-only/auth/hash';
@ -8,10 +9,12 @@ import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-conf
import { authenticatedProcedure, procedure, router } from '../trpc';
import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema';
const NEXT_PUBLIC_DISABLE_SIGNUP = () => env('NEXT_PUBLIC_DISABLE_SIGNUP');
export const authRouter = router({
signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => {
try {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
if (NEXT_PUBLIC_DISABLE_SIGNUP() === 'true') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Signups are disabled.',

View File

@ -6,6 +6,7 @@ import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/ups
import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id';
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
@ -21,6 +22,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc';
import {
ZCreateDocumentMutationSchema,
ZDeleteDraftDocumentMutationSchema,
ZFindDocumentAuditLogsQuerySchema,
ZGetDocumentByIdQuerySchema,
ZGetDocumentByTokenQuerySchema,
ZResendDocumentMutationSchema,
@ -111,7 +113,12 @@ export const documentRouter = router({
const userId = ctx.user.id;
return await deleteDocument({ id, userId, status });
return await deleteDocument({
id,
userId,
status,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@ -122,6 +129,31 @@ export const documentRouter = router({
}
}),
findDocumentAuditLogs: authenticatedProcedure
.input(ZFindDocumentAuditLogsQuerySchema)
.query(async ({ input, ctx }) => {
try {
const { page, perPage, documentId, cursor, filterForRecentActivity, orderBy } = input;
return await findDocumentAuditLogs({
page,
perPage,
documentId,
cursor,
filterForRecentActivity,
orderBy,
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find audit logs for this document. Please try again later.',
});
}
}),
setTitleForDocument: authenticatedProcedure
.input(ZSetTitleForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -1,8 +1,21 @@
import { z } from 'zod';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
export const ZFindDocumentAuditLogsQuerySchema = ZBaseTableSearchParamsSchema.extend({
documentId: z.number().min(1),
cursor: z.string().optional(),
filterForRecentActivity: z.boolean().optional(),
orderBy: z
.object({
column: z.enum(['createdAt', 'type']),
direction: z.enum(['asc', 'desc']),
})
.optional(),
});
export const ZGetDocumentByIdQuerySchema = z.object({
id: z.number().min(1),
teamId: z.number().min(1).optional(),

View File

@ -53,6 +53,7 @@ export const recipientRouter = router({
id: signer.nativeId,
email: signer.email,
name: signer.name,
role: signer.role,
})),
});
} catch (err) {

View File

@ -34,6 +34,7 @@ export const ZAddTemplateSignersMutationSchema = z
nativeId: z.number().optional(),
email: z.string().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
}),
),
})

View File

@ -22,6 +22,7 @@ export const mapField = (
.with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a'))
.with(FieldType.EMAIL, () => signer.email)
.with(FieldType.NAME, () => signer.name)
.with(FieldType.TEXT, () => signer.customText)
.otherwise(() => '');
return {

View File

@ -5,6 +5,7 @@ import { PDFDocument } from 'pdf-lib';
import { mailer } from '@documenso/email/mailer';
import { renderAsync } from '@documenso/email/render';
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { alphaid } from '@documenso/lib/universal/id';
@ -149,7 +150,7 @@ export const singleplayerRouter = router({
const template = createElement(DocumentSelfSignedEmailTemplate, {
documentName: documentName,
assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});
const [html, text] = await Promise.all([

View File

@ -12,6 +12,7 @@ export const ZCreateSinglePlayerDocumentMutationSchema = z.object({
email: z.string().email().min(1),
name: z.string(),
signature: z.string(),
customText: z.string(),
}),
fields: z.array(
z.object({

View File

@ -3,10 +3,11 @@ import { z } from 'zod';
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
import { TeamMemberRole } from '@documenso/prisma/client';
// Consider refactoring to use ZBaseTableSearchParamsSchema.
const GenericFindQuerySchema = z.object({
term: z.string().optional(),
page: z.number().optional(),
perPage: z.number().optional(),
page: z.number().min(1).optional(),
perPage: z.number().min(1).optional(),
});
/**

View File

@ -7,6 +7,7 @@ import { Copy, Sparkles } from 'lucide-react';
import { FaXTwitter } from 'react-icons/fa6';
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
TOAST_DOCUMENT_SHARE_ERROR,
TOAST_DOCUMENT_SHARE_SUCCESS,
@ -68,7 +69,7 @@ export const DocumentShareButton = ({
const onCopyClick = async () => {
if (shareLink) {
await copyShareLink(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${shareLink.slug}`);
await copyShareLink(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${shareLink.slug}`);
} else {
await createAndCopyShareLink({
token,
@ -92,7 +93,7 @@ export const DocumentShareButton = ({
}
// Ensuring we've prewarmed the opengraph image for the Twitter
await fetch(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}/opengraph`, {
await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${slug}/opengraph`, {
// We don't care about the response, so we can use no-cors
mode: 'no-cors',
});
@ -100,7 +101,7 @@ export const DocumentShareButton = ({
window.open(
generateTwitterIntent(
`I just ${token ? 'signed' : 'sent'} a document in style with @documenso. Check it out!`,
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`,
`${NEXT_PUBLIC_WEBAPP_URL()}/share/${slug}`,
),
'_blank',
);
@ -148,7 +149,7 @@ export const DocumentShareButton = ({
'animate-pulse': !shareLink?.slug,
})}
>
{process.env.NEXT_PUBLIC_WEBAPP_URL}/share/{shareLink?.slug || '...'}
{NEXT_PUBLIC_WEBAPP_URL()}/share/{shareLink?.slug || '...'}
</span>
<div
className={cn(
@ -160,7 +161,7 @@ export const DocumentShareButton = ({
>
{shareLink?.slug && (
<img
src={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${shareLink.slug}/opengraph`}
src={`${NEXT_PUBLIC_WEBAPP_URL()}/share/${shareLink.slug}/opengraph`}
alt="sharing link"
className="h-full w-full object-cover"
/>

View File

@ -64,6 +64,7 @@
"luxon": "^3.4.2",
"next": "14.0.3",
"pdfjs-dist": "3.6.172",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.7.1",
"react-hook-form": "^7.45.4",
"react-pdf": "7.3.3",

View File

@ -6,16 +6,20 @@ import { cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
const badgeVariants = cva(
'inline-flex items-center border rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'inline-flex items-center rounded-md px-2 py-1.5 text-xs font-medium ring-1 ring-inset w-fit',
{
variants: {
variant: {
default: 'bg-primary hover:bg-primary/80 border-transparent text-primary-foreground',
secondary:
'bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground',
neutral:
'bg-gray-50 text-gray-600 ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20',
destructive:
'bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground',
outline: 'text-foreground',
'bg-red-50 text-red-700 ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20',
warning:
'bg-yellow-50 text-yellow-800 ring-yellow-600/20 dark:bg-yellow-400/10 dark:text-yellow-500 dark:ring-yellow-400/20',
default:
'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20',
secondary:
'bg-blue-50 text-blue-700 ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30',
},
},
defaultVariants: {

View File

@ -0,0 +1,82 @@
import type { HTMLAttributes } from 'react';
import React, { useState } from 'react';
import { HexColorInput, HexColorPicker } from 'react-colorful';
import { cn } from '../lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
export type ColorPickerProps = {
disabled?: boolean;
value: string;
defaultValue?: string;
onChange: (color: string) => void;
} & HTMLAttributes<HTMLDivElement>;
export const ColorPicker = ({
className,
disabled = false,
value,
defaultValue = '#000000',
onChange,
...props
}: ColorPickerProps) => {
const [color, setColor] = useState(value || defaultValue);
const [inputColor, setInputColor] = useState(value || defaultValue);
const onColorChange = (newColor: string) => {
setColor(newColor);
setInputColor(newColor);
onChange(newColor);
};
const onInputChange = (newColor: string) => {
setInputColor(newColor);
};
const onInputBlur = () => {
setColor(inputColor);
onChange(inputColor);
};
return (
<Popover>
<PopoverTrigger>
<button
type="button"
disabled={disabled}
className="bg-background h-12 w-12 rounded-md border p-1 disabled:pointer-events-none disabled:opacity-50"
>
<div className="h-full w-full rounded-sm" style={{ backgroundColor: color }} />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto">
<HexColorPicker
className={cn(
className,
'w-full aria-disabled:pointer-events-none aria-disabled:opacity-50',
)}
color={color}
onChange={onColorChange}
aria-disabled={disabled}
{...props}
/>
<HexColorInput
className="mt-4 h-10 rounded-md border bg-transparent px-3 py-2 text-sm disabled:pointer-events-none disabled:opacity-50"
color={inputColor}
onChange={onInputChange}
onBlur={onInputBlur}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
onInputBlur();
}
}}
disabled={disabled}
/>
</PopoverContent>
</Popover>
);
};

View File

@ -121,7 +121,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}

View File

@ -380,7 +380,7 @@ export const AddFieldsFormPartial = ({
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<Command value={selectedSigner?.email}>
<CommandInput />
<CommandEmpty>
@ -552,6 +552,28 @@ export const AddFieldsFormPartial = ({
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.TEXT)}
onMouseDown={() => setSelectedField(FieldType.TEXT)}
data-selected={selectedField === FieldType.TEXT ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Text'}
</p>
<p className="text-muted-foreground mt-2 text-xs">Custom Text</p>
</CardContent>
</Card>
</button>
</fieldset>
</div>
</div>

View File

@ -44,6 +44,7 @@ export type AddSignatureFormProps = {
onSubmit: (_data: TAddSignatureFormSchema) => Promise<void> | void;
requireName?: boolean;
requireCustomText?: boolean;
requireSignature?: boolean;
};
@ -54,6 +55,7 @@ export const AddSignatureFormPartial = ({
onSubmit,
requireName = false,
requireCustomText = false,
requireSignature = true,
}: AddSignatureFormProps) => {
const { currentStep, totalSteps } = useStep();
@ -70,6 +72,14 @@ export const AddSignatureFormPartial = ({
});
}
if (requireCustomText && val.customText.length === 0) {
ctx.addIssue({
path: ['customText'],
code: 'custom',
message: 'Text is required',
});
}
if (requireSignature && val.signature.length === 0) {
ctx.addIssue({
path: ['signature'],
@ -85,6 +95,7 @@ export const AddSignatureFormPartial = ({
name: '',
email: '',
signature: '',
customText: '',
},
});
@ -131,6 +142,11 @@ export const AddSignatureFormPartial = ({
return !form.formState.errors.email;
}
if (fieldType === FieldType.TEXT) {
await form.trigger('customText');
return !form.formState.errors.customText;
}
return true;
};
@ -154,6 +170,11 @@ export const AddSignatureFormPartial = ({
customText: form.getValues('name'),
inserted: true,
}))
.with(FieldType.TEXT, () => ({
...field,
customText: form.getValues('customText'),
inserted: true,
}))
.with(FieldType.SIGNATURE, () => {
const value = form.getValues('signature');
@ -302,6 +323,29 @@ export const AddSignatureFormPartial = ({
)}
/>
)}
{requireCustomText && (
<FormField
control={form.control}
name="customText"
render={({ field }) => (
<FormItem>
<FormLabel required={requireCustomText}>Custom Text</FormLabel>
<FormControl>
<Input
className="bg-background"
{...field}
onChange={(value) => {
onFormValueChange(FieldType.TEXT);
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</DocumentFlowFormContainerContent>
@ -330,7 +374,7 @@ export const AddSignatureFormPartial = ({
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field) =>
match(field.type)
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, () => {
.with(FieldType.DATE, FieldType.TEXT, FieldType.EMAIL, FieldType.NAME, () => {
return (
<SinglePlayerModeCustomTextField
onClick={insertField(field)}

View File

@ -6,6 +6,7 @@ export const ZAddSignatureFormSchema = z.object({
.min(1, { message: 'Email is required' })
.email({ message: 'Invalid email address' }),
name: z.string(),
customText: z.string(),
signature: z.string(),
});

View File

@ -4,7 +4,7 @@ import React, { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { BadgeCheck, Copy, Eye, PencilLine, Plus, Trash } from 'lucide-react';
import { Plus, Trash } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
@ -17,6 +17,7 @@ import { Button } from '../button';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { Label } from '../label';
import { ROLE_ICONS } from '../recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
import { useStep } from '../stepper';
import { useToast } from '../use-toast';
@ -32,13 +33,6 @@ import {
import { ShowFieldItem } from './show-field-item';
import type { DocumentFlowStep } from './types';
const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
SIGNER: <PencilLine className="h-4 w-4" />,
APPROVER: <BadgeCheck className="h-4 w-4" />,
CC: <Copy className="h-4 w-4" />,
VIEWER: <Eye className="h-4 w-4" />,
};
export type AddSignersFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];

View File

@ -226,7 +226,7 @@ export const AddSubjectFormPartial = ({
</>
)}
<div className="flex flex-col">
<div className="mt-2 flex flex-col">
<div className="flex flex-col gap-y-4">
<div>
<Label htmlFor="redirectUrl" className="flex items-center">

View File

@ -172,6 +172,7 @@ export function SinglePlayerModeCustomTextField({
.with(FieldType.DATE, () => 'Date')
.with(FieldType.NAME, () => 'Name')
.with(FieldType.EMAIL, () => 'Email')
.with(FieldType.TEXT, () => 'Text')
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, () => 'Signature')
.otherwise(() => '')}
</button>

View File

@ -233,18 +233,20 @@ export const PDFViewer = ({
{Array(numPages)
.fill(null)
.map((_, i) => (
<div
key={i}
className="border-border my-8 overflow-hidden rounded border will-change-transform first:mt-0 last:mb-0"
>
<PDFPage
pageNumber={i + 1}
width={width}
renderAnnotationLayer={false}
renderTextLayer={false}
loading={() => ''}
onClick={(e) => onDocumentPageClick(e, i + 1)}
/>
<div key={i} className="last:-mb-2">
<div className="border-border overflow-hidden rounded border will-change-transform">
<PDFPage
pageNumber={i + 1}
width={width}
renderAnnotationLayer={false}
renderTextLayer={false}
loading={() => ''}
onClick={(e) => onDocumentPageClick(e, i + 1)}
/>
</div>
<p className="text-muted-foreground/80 my-2 text-center text-[11px]">
Page {i + 1} of {numPages}
</p>
</div>
))}
</PDFDocument>

View File

@ -0,0 +1,10 @@
import { BadgeCheck, Copy, Eye, PencilLine } from 'lucide-react';
import type { RecipientRole } from '.prisma/client';
export const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
SIGNER: <PencilLine className="h-4 w-4" />,
APPROVER: <BadgeCheck className="h-4 w-4" />,
CC: <Copy className="h-4 w-4" />,
VIEWER: <Eye className="h-4 w-4" />,
};

View File

@ -143,14 +143,17 @@ const sheetVariants = cva(
export interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
VariantProps<typeof sheetVariants> {
showOverlay?: boolean;
sheetClass?: string;
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
DialogContentProps
>(({ position, size, className, children, ...props }, ref) => (
>(({ position, size, className, sheetClass, showOverlay = true, children, ...props }, ref) => (
<SheetPortal position={position}>
<SheetOverlay />
{showOverlay && <SheetOverlay className={sheetClass} />}
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ position, size }), className)}

View File

@ -7,6 +7,8 @@ import { Undo2 } from 'lucide-react';
import type { StrokeOptions } from 'perfect-freehand';
import { getStroke } from 'perfect-freehand';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import { cn } from '../../lib/utils';
import { getSvgPathFromStroke } from './helper';
import { Point } from './point';
@ -28,6 +30,7 @@ export const SignaturePad = ({
...props
}: SignaturePadProps) => {
const $el = useRef<HTMLCanvasElement>(null);
const $imageData = useRef<ImageData | null>(null);
const [isPressed, setIsPressed] = useState(false);
const [lines, setLines] = useState<Point[][]>([]);
@ -134,7 +137,6 @@ export const SignaturePad = ({
});
onChange?.($el.current.toDataURL());
ctx.save();
}
}
@ -163,6 +165,7 @@ export const SignaturePad = ({
const ctx = $el.current.getContext('2d');
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
$imageData.current = null;
}
onChange?.(null);
@ -176,19 +179,25 @@ export const SignaturePad = ({
return;
}
const newLines = [...lines];
newLines.pop(); // Remove the last line
const newLines = lines.slice(0, -1);
setLines(newLines);
// Clear the canvas
if ($el.current) {
const ctx = $el.current.getContext('2d');
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
const { width, height } = $el.current;
ctx?.clearRect(0, 0, width, height);
if (typeof defaultValue === 'string' && $imageData.current) {
ctx?.putImageData($imageData.current, 0, 0);
}
newLines.forEach((line) => {
const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)));
ctx?.fill(pathData);
});
onChange?.($el.current.toDataURL());
}
};
@ -199,7 +208,7 @@ export const SignaturePad = ({
}
}, []);
useEffect(() => {
unsafe_useEffectOnce(() => {
if ($el.current && typeof defaultValue === 'string') {
const ctx = $el.current.getContext('2d');
@ -209,11 +218,15 @@ export const SignaturePad = ({
img.onload = () => {
ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height));
const defaultImageData = ctx?.getImageData(0, 0, width, height) || null;
$imageData.current = defaultImageData;
};
img.src = defaultValue;
}
}, [defaultValue]);
});
return (
<div

View File

@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Caveat } from 'next/font/google';
@ -10,9 +10,10 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -291,6 +292,28 @@ export const AddTemplateFieldsFormPartial = ({
setSelectedSigner(recipients[0]);
}, [recipients]);
const recipientsByRole = useMemo(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
CC: [],
VIEWER: [],
SIGNER: [],
APPROVER: [],
};
recipients.forEach((recipient) => {
recipientsByRole[recipient.role].push(recipient);
});
return recipientsByRole;
}, [recipients]);
const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter(
([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER,
);
}, [recipientsByRole]);
return (
<>
<DocumentFlowFormContainerContent>
@ -363,55 +386,49 @@ export const AddTemplateFieldsFormPartial = ({
</span>
</CommandEmpty>
<CommandGroup>
{recipients.map((recipient, index) => (
<CommandItem
key={index}
className={cn({
// 'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
})}
onSelect={() => {
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
>
{/* {recipient.sendStatus !== SendStatus.SENT ? (
<Check
aria-hidden={recipient !== selectedSigner}
className={cn('mr-2 h-4 w-4 flex-shrink-0', {
'opacity-0': recipient !== selectedSigner,
'opacity-100': recipient === selectedSigner,
})}
/>
) : (
<Tooltip>
<TooltipTrigger>
<Info className="mr-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
This document has already been sent to this recipient. You can no
longer edit this recipient.
</TooltipContent>
</Tooltip>
)} */}
{recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
</div>
{recipient.name && (
{recipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
>
No recipients with this role
</div>
)}
{recipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn('px-2 last:mb-1 [&:not(:first-child)]:mt-1')}
onSelect={() => {
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
>
<span
className="truncate"
title={`${recipient.name} (${recipient.email})`}
className={cn('text-foreground/70 truncate', {
'text-foreground/80': recipient === selectedSigner,
})}
>
{recipient.name} ({recipient.email})
</span>
)}
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && (
<span className="truncate" title={recipient.email}>
{recipient.email}
{!recipient.name && (
<span title={recipient.email}>{recipient.email}</span>
)}
</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandItem>
))}
</CommandGroup>
))}
</Command>
</PopoverContent>
</Popover>
@ -511,6 +528,28 @@ export const AddTemplateFieldsFormPartial = ({
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.TEXT)}
onMouseDown={() => setSelectedField(FieldType.TEXT)}
data-selected={selectedField === FieldType.TEXT ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Text'}
</p>
<p className="text-muted-foreground mt-2 text-xs">Custom Text</p>
</CardContent>
</Card>
</button>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More