chore: merge main

This commit is contained in:
Catalin Pit
2024-02-16 12:12:54 +02:00
163 changed files with 3250 additions and 57373 deletions

View File

@ -0,0 +1,205 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { manualLogin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEMPLATES]: view templates', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 1',
userId: owner.id,
teamId: team.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 2',
userId: teamMemberUser.id,
teamId: team.id,
});
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
});
// Owner should see both team templates.
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
await expect(page.getByRole('main')).toContainText('Showing 2 results');
// Only should only see their personal template.
await page.goto(`${WEBAPP_BASE_URL}/templates`);
await expect(page.getByRole('main')).toContainText('Showing 1 result');
await unseedTeam(team.url);
});
test('[TEMPLATES]: delete template', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 1',
userId: owner.id,
teamId: team.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 2',
userId: teamMemberUser.id,
teamId: team.id,
});
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
});
// Owner should be able to delete their personal template.
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Template deleted').first()).toBeVisible();
// Team member should be able to delete all templates.
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
for (const template of ['Team template 1', 'Team template 2']) {
await page
.getByRole('row', { name: template })
.getByRole('cell', { name: 'Use Template' })
.getByRole('button')
.nth(1)
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Template deleted').first()).toBeVisible();
}
await unseedTeam(team.url);
});
test('[TEMPLATES]: duplicate template', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 1',
userId: teamMemberUser.id,
teamId: team.id,
});
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
});
// Duplicate personal template.
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByRole('button', { name: 'Duplicate' }).click();
await expect(page.getByText('Template duplicated').first()).toBeVisible();
await expect(page.getByRole('main')).toContainText('Showing 2 results');
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
// Duplicate team template.
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByRole('button', { name: 'Duplicate' }).click();
await expect(page.getByText('Template duplicated').first()).toBeVisible();
await expect(page.getByRole('main')).toContainText('Showing 2 results');
await unseedTeam(team.url);
});
test('[TEMPLATES]: use template', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 1',
userId: teamMemberUser.id,
teamId: team.id,
});
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
});
// Use personal template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.waitForURL(/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL('/documents');
await expect(page.getByRole('main')).toContainText('Showing 1 result');
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
// Use team template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL(`/t/${team.url}/documents`);
await expect(page.getByRole('main')).toContainText('Showing 1 result');
await unseedTeam(team.url);
});

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

@ -16,7 +16,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,

View File

@ -35,14 +35,14 @@
"@react-email/section": "0.0.10",
"@react-email/tailwind": "0.0.9",
"@react-email/text": "0.0.6",
"nodemailer": "^6.9.3",
"nodemailer": "^6.9.9",
"react-email": "^1.9.5",
"resend": "^2.0.0"
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
"@types/nodemailer": "^6.4.8",
"@types/nodemailer": "^6.4.14",
"tsup": "^7.1.0"
}
}

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

@ -1,16 +1,20 @@
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_PROJECT = () => env('NEXT_PUBLIC_PROJECT');
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 = () => NEXT_PUBLIC_PROJECT() === 'marketing';
export const IS_APP_WEB = () => 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

@ -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,7 +21,7 @@ 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',
marketing_header_single_player_mode: false,
} as const;
@ -24,8 +29,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

@ -24,3 +24,9 @@ export const RECIPIENT_ROLES_DESCRIPTION: {
roleName: 'Viewer',
},
};
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',
[RecipientRole.VIEWER]: 'VIEW_REQUEST',
[RecipientRole.APPROVER]: 'APPROVE_REQUEST',
} as const;

View File

@ -1,6 +1,7 @@
import { TeamMemberRole } from '@documenso/prisma/client';
export const TEAM_URL_ROOT_REGEX = new RegExp('^/t/[^/]+$');
export const TEAM_URL_REGEX = new RegExp('^/t/[^/]+');
export const TEAM_MEMBER_ROLE_MAP: Record<keyof typeof TeamMemberRole, string> = {
ADMIN: 'Admin',

View File

@ -0,0 +1,2 @@
export const URL_REGEX =
/^(https?):\/\/(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z0-9()]{2,}(?:\/[a-zA-Z0-9-._?&=/]*)?$/i;

View File

@ -7,13 +7,16 @@ 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';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
import { getUserByEmail } from '../server-only/user/get-user-by-email';
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
import { ErrorCode } from './error-codes';
@ -90,6 +93,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
}
}
if (!user.emailVerified) {
const mostRecentToken = await getMostRecentVerificationTokenByUserId({
userId: user.id,
});
if (
!mostRecentToken ||
mostRecentToken.expires.valueOf() <= Date.now() ||
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
await sendConfirmationToken({ email });
}
throw new Error(ErrorCode.UNVERIFIED_EMAIL);
}
return {
id: Number(user.id),
email: user.email,
@ -203,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

@ -19,4 +19,5 @@ export const ErrorCode = {
INCORRECT_PASSWORD: 'INCORRECT_PASSWORD',
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
UNVERIFIED_EMAIL: 'UNVERIFIED_EMAIL',
} as const;

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

@ -1,7 +1,7 @@
'use server';
import { prisma } from '@documenso/prisma';
import { DocumentDataType } from '@documenso/prisma/client';
import type { DocumentDataType } from '@documenso/prisma/client';
export type CreateDocumentDataOptions = {
type: DocumentDataType;

View File

@ -1,5 +1,11 @@
'use server';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
export type CreateDocumentMetaOptions = {
@ -9,7 +15,9 @@ export type CreateDocumentMetaOptions = {
timezone?: string;
password?: string;
dateFormat?: string;
redirectUrl?: string;
userId: number;
requestMetadata: RequestMetadata;
};
export const upsertDocumentMeta = async ({
@ -18,47 +26,81 @@ export const upsertDocumentMeta = async ({
timezone,
dateFormat,
documentId,
userId,
password,
userId,
redirectUrl,
requestMetadata,
}: CreateDocumentMetaOptions) => {
await prisma.document.findFirstOrThrow({
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});
const { documentMeta: originalDocumentMeta } = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
{
userId,
userId: user.id,
},
{
team: {
members: {
some: {
userId,
userId: user.id,
},
},
},
},
],
},
include: {
documentMeta: true,
},
});
return await prisma.documentMeta.upsert({
where: {
documentId,
},
create: {
subject,
message,
dateFormat,
timezone,
password,
documentId,
},
update: {
subject,
message,
dateFormat,
password,
timezone,
},
return await prisma.$transaction(async (tx) => {
const upsertedDocumentMeta = await tx.documentMeta.upsert({
where: {
documentId,
},
create: {
subject,
message,
password,
dateFormat,
timezone,
documentId,
redirectUrl,
},
update: {
subject,
message,
password,
dateFormat,
timezone,
redirectUrl,
},
});
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

@ -1,5 +1,8 @@
'use server';
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';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
@ -11,6 +14,7 @@ import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
documentId: number;
requestMetadata?: RequestMetadata;
};
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
@ -36,6 +40,7 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
export const completeDocumentWithToken = async ({
token,
documentId,
requestMetadata,
}: CompleteDocumentWithTokenOptions) => {
'use server';
@ -76,6 +81,24 @@ export const completeDocumentWithToken = async ({
},
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
documentId: document.id,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
},
}),
});
const pendingRecipients = await prisma.recipient.count({
where: {
documentId: document.id,
@ -105,7 +128,7 @@ export const completeDocumentWithToken = async ({
});
if (documents.count > 0) {
await sealDocument({ documentId: document.id });
await sealDocument({ documentId: document.id, requestMetadata });
}
const updatedDocument = await getDocument({ token, documentId });

View File

@ -1,5 +1,9 @@
'use server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
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';
import { prisma } from '@documenso/prisma';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
@ -10,6 +14,7 @@ export type CreateDocumentOptions = {
userId: number;
teamId?: number;
documentDataId: string;
requestMetadata?: RequestMetadata;
};
export const createDocument = async ({
@ -17,22 +22,30 @@ export const createDocument = async ({
title,
documentDataId,
teamId,
requestMetadata,
}: CreateDocumentOptions) => {
return await prisma.$transaction(async (tx) => {
if (teamId !== undefined) {
await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
teamMembers: {
select: {
teamId: true,
},
});
}
},
},
});
const createdDocument = await tx.document.create({
if (
teamId !== undefined &&
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
}
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
title,
documentDataId,
@ -41,11 +54,23 @@ export const createDocument = async ({
},
});
await triggerWebhook({
eventTrigger: WebhookTriggerEvents.DOCUMENT_CREATED,
documentData: createdDocument,
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
user,
requestMetadata,
data: {
title,
},
}),
});
return createdDocument;
await triggerWebhook({
eventTrigger: WebhookTriggerEvents.DOCUMENT_CREATED,
documentData: document,
});
return document;
});
};

View File

@ -8,6 +8,7 @@ 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';
export type DeleteDocumentOptions = {
@ -49,7 +50,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,

View File

@ -39,6 +39,7 @@ export const duplicateDocumentById = async ({
dateFormat: true,
password: true,
timezone: true,
redirectUrl: true,
},
},
},

View File

@ -27,6 +27,7 @@ export const getDocumentAndSenderByToken = async ({
include: {
User: true,
documentData: true,
documentMeta: true,
},
});

View File

@ -4,12 +4,19 @@ 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';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { getDocumentWhereInput } from './get-document-by-id';
export type ResendDocumentOptions = {
@ -17,6 +24,7 @@ export type ResendDocumentOptions = {
userId: number;
recipients: number[];
teamId?: number;
requestMetadata: RequestMetadata;
};
export const resendDocument = async ({
@ -24,6 +32,7 @@ export const resendDocument = async ({
userId,
recipients,
teamId,
requestMetadata,
}: ResendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@ -76,6 +85,8 @@ export const resendDocument = async ({
return;
}
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const customEmailTemplate = {
@ -84,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,
@ -99,20 +110,39 @@ export const resendDocument = async ({
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
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 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,
},
}),
});
});
}),
);

View File

@ -5,10 +5,13 @@ import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
@ -17,9 +20,14 @@ import { sendCompletedEmail } from './send-completed-email';
export type SealDocumentOptions = {
documentId: number;
sendEmail?: boolean;
requestMetadata?: RequestMetadata;
};
export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumentOptions) => {
export const sealDocument = async ({
documentId,
sendEmail = true,
requestMetadata,
}: SealDocumentOptions) => {
'use server';
const document = await prisma.document.findFirstOrThrow({
@ -100,16 +108,30 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
});
}
await prisma.documentData.update({
where: {
id: documentData.id,
},
data: {
data: newData,
},
await prisma.$transaction(async (tx) => {
await tx.documentData.update({
where: {
id: documentData.id,
},
data: {
data: newData,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
documentId: document.id,
requestMetadata,
user: null,
data: {
transactionId: nanoid(),
},
}),
});
});
if (sendEmail) {
await sendCompletedEmail({ documentId });
await sendCompletedEmail({ documentId, requestMetadata });
}
};

View File

@ -5,13 +5,18 @@ 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';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export interface SendDocumentOptions {
documentId: number;
requestMetadata?: RequestMetadata;
}
export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) => {
export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDocumentOptions) => {
const document = await prisma.document.findUnique({
where: {
id: documentId,
@ -36,32 +41,51 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) =>
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 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,
},
}),
});
});
}),
);

View File

@ -4,22 +4,39 @@ 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 { 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';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
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;
requestMetadata?: RequestMetadata;
};
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
export const sendDocument = async ({
documentId,
userId,
requestMetadata,
}: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
const document = await prisma.document.findUnique({
@ -66,6 +83,8 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
return;
}
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const customEmailTemplate = {
@ -74,8 +93,8 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
'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,
@ -89,29 +108,48 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
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 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 prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
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,
},
}),
});
});
}),
);

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

@ -1,34 +1,76 @@
'use server';
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';
import { prisma } from '@documenso/prisma';
export type UpdateTitleOptions = {
userId: number;
documentId: number;
title: string;
requestMetadata?: RequestMetadata;
};
export const updateTitle = async ({ userId, documentId, title }: UpdateTitleOptions) => {
return await prisma.document.update({
export const updateTitle = async ({
userId,
documentId,
title,
requestMetadata,
}: UpdateTitleOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
id: userId,
},
});
return await prisma.$transaction(async (tx) => {
const document = await tx.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
if (document.title === title) {
return document;
}
const updatedDocument = await tx.document.update({
where: {
id: documentId,
},
data: {
title,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.title,
to: updatedDocument.title,
},
],
},
data: {
title,
},
}),
});
return updatedDocument;
});
};

View File

@ -1,11 +1,15 @@
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';
import { prisma } from '@documenso/prisma';
import { ReadStatus } from '@documenso/prisma/client';
export type ViewedDocumentOptions = {
token: string;
requestMetadata?: RequestMetadata;
};
export const viewedDocument = async ({ token }: ViewedDocumentOptions) => {
export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
token,
@ -13,16 +17,38 @@ export const viewedDocument = async ({ token }: ViewedDocumentOptions) => {
},
});
if (!recipient) {
if (!recipient || !recipient.documentId) {
return;
}
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
readStatus: ReadStatus.OPENED,
},
const { documentId } = recipient;
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
readStatus: ReadStatus.OPENED,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
documentId,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
},
}),
});
});
};

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

@ -10,7 +10,20 @@ export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForT
where: {
templateId,
Template: {
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
orderBy: {

View File

@ -1,16 +1,21 @@
'use server';
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';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
export type RemovedSignedFieldWithTokenOptions = {
token: string;
fieldId: number;
requestMetadata?: RequestMetadata;
};
export const removeSignedFieldWithToken = async ({
token,
fieldId,
requestMetadata,
}: RemovedSignedFieldWithTokenOptions) => {
const field = await prisma.field.findFirstOrThrow({
where: {
@ -44,8 +49,8 @@ export const removeSignedFieldWithToken = async ({
throw new Error(`Field ${fieldId} has no recipientId`);
}
await Promise.all([
prisma.field.update({
await prisma.$transaction(async (tx) => {
await tx.field.update({
where: {
id: field.id,
},
@ -53,11 +58,28 @@ export const removeSignedFieldWithToken = async ({
customText: '',
inserted: false,
},
}),
prisma.signature.deleteMany({
});
await tx.signature.deleteMany({
where: {
fieldId: field.id,
},
}),
]);
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
documentId: document.id,
user: {
name: recipient?.name,
email: recipient?.email,
},
requestMetadata,
data: {
field: field.type,
fieldId: field.secondaryId,
},
}),
});
});
};

View File

@ -1,3 +1,9 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffFieldChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
@ -15,12 +21,14 @@ export interface SetFieldsForDocumentOptions {
pageWidth: number;
pageHeight: number;
}[];
requestMetadata?: RequestMetadata;
}
export const setFieldsForDocument = async ({
userId,
documentId,
fields,
requestMetadata,
}: SetFieldsForDocumentOptions) => {
const document = await prisma.document.findFirst({
where: {
@ -42,10 +50,25 @@ export const setFieldsForDocument = async ({
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.completedAt) {
throw new Error('Document already complete');
}
const existingFields = await prisma.field.findMany({
where: {
documentId,
@ -56,11 +79,7 @@ export const setFieldsForDocument = async ({
});
const removedFields = existingFields.filter(
(existingField) =>
!fields.find(
(field) =>
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
),
(existingField) => !fields.find((field) => field.id === existingField.id),
);
const linkedFields = fields
@ -79,56 +98,123 @@ export const setFieldsForDocument = async ({
);
});
const persistedFields = await prisma.$transaction(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedFields.map((field) =>
prisma.field.upsert({
where: {
id: field._persisted?.id ?? -1,
documentId,
},
update: {
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
},
create: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
customText: '',
inserted: false,
Document: {
connect: {
id: documentId,
},
const persistedFields = await prisma.$transaction(async (tx) => {
await Promise.all(
linkedFields.map(async (field) => {
const fieldSignerEmail = field.signerEmail.toLowerCase();
const upsertedField = await tx.field.upsert({
where: {
id: field._persisted?.id ?? -1,
documentId,
},
Recipient: {
connect: {
documentId_email: {
documentId,
email: field.signerEmail.toLowerCase(),
update: {
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
},
create: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
customText: '',
inserted: false,
Document: {
connect: {
id: documentId,
},
},
Recipient: {
connect: {
documentId_email: {
documentId,
email: fieldSignerEmail,
},
},
},
},
},
});
if (upsertedField.recipientId === null) {
throw new Error('Not possible');
}
const baseAuditLog = {
fieldId: upsertedField.secondaryId,
fieldRecipientEmail: fieldSignerEmail,
fieldRecipientId: upsertedField.recipientId,
fieldType: upsertedField.type,
};
const changes = field._persisted ? diffFieldChanges(field._persisted, upsertedField) : [];
// Handle field updated audit log.
if (field._persisted && changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
documentId: documentId,
user,
requestMetadata,
data: {
changes,
...baseAuditLog,
},
}),
});
}
// Handle field created audit log.
if (!field._persisted) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
documentId: documentId,
user,
requestMetadata,
data: {
...baseAuditLog,
},
}),
});
}
return upsertedField;
}),
),
);
);
});
if (removedFields.length > 0) {
await prisma.field.deleteMany({
where: {
id: {
in: removedFields.map((field) => field.id),
await prisma.$transaction(async (tx) => {
await tx.field.deleteMany({
where: {
id: {
in: removedFields.map((field) => field.id),
},
},
},
});
await tx.documentAuditLog.createMany({
data: removedFields.map((field) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED,
documentId: documentId,
user,
requestMetadata,
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',
fieldRecipientId: field.recipientId ?? -1,
fieldType: field.type,
},
}),
),
});
});
}

View File

@ -27,7 +27,20 @@ export const setFieldsForTemplate = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});

View File

@ -1,18 +1,23 @@
'use server';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
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 SignFieldWithTokenOptions = {
token: string;
fieldId: number;
value: string;
isBase64?: boolean;
requestMetadata?: RequestMetadata;
};
export const signFieldWithToken = async ({
@ -20,6 +25,7 @@ export const signFieldWithToken = async ({
fieldId,
value,
isBase64,
requestMetadata,
}: SignFieldWithTokenOptions) => {
const field = await prisma.field.findFirstOrThrow({
where: {
@ -40,6 +46,10 @@ export const signFieldWithToken = async ({
throw new Error(`Document not found for field ${field.id}`);
}
if (!recipient) {
throw new Error(`Recipient not found for field ${field.id}`);
}
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
}
@ -123,6 +133,38 @@ export const signFieldWithToken = async ({
});
}
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
documentId: document.id,
user: {
email: recipient.email,
name: recipient.name,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
fieldId: updatedField.secondaryId,
field: match(updatedField.type)
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, (type) => ({
type,
data: signatureImageAsBase64 || typedSignature || '',
}))
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({
type,
data: updatedField.customText,
}))
.exhaustive(),
fieldSecurity: {
type: 'NONE',
},
},
}),
});
return updatedField;
});
};

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

@ -13,7 +13,20 @@ export const getRecipientsForTemplate = async ({
where: {
templateId,
Template: {
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
orderBy: {

View File

@ -1,9 +1,14 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid } from '@documenso/lib/universal/id';
import {
createDocumentAuditLogData,
diffRecipientChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { nanoid } from '../../universal/id';
export interface SetRecipientsForDocumentOptions {
userId: number;
documentId: number;
@ -13,12 +18,14 @@ export interface SetRecipientsForDocumentOptions {
name: string;
role: RecipientRole;
}[];
requestMetadata?: RequestMetadata;
}
export const setRecipientsForDocument = async ({
userId,
documentId,
recipients,
requestMetadata,
}: SetRecipientsForDocumentOptions) => {
const document = await prisma.document.findFirst({
where: {
@ -40,10 +47,25 @@ export const setRecipientsForDocument = async ({
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.completedAt) {
throw new Error('Document already complete');
}
const normalizedRecipients = recipients.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
@ -77,49 +99,127 @@ export const setRecipientsForDocument = async ({
})
.filter((recipient) => {
return (
recipient._persisted?.sendStatus !== SendStatus.SENT &&
recipient._persisted?.signingStatus !== SigningStatus.SIGNED
recipient._persisted?.role === RecipientRole.CC ||
(recipient._persisted?.sendStatus !== SendStatus.SENT &&
recipient._persisted?.signingStatus !== SigningStatus.SIGNED)
);
});
const persistedRecipients = await prisma.$transaction(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedRecipients.map((recipient) =>
prisma.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
documentId,
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
documentId,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
documentId,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
const persistedRecipients = await prisma.$transaction(async (tx) => {
await Promise.all(
linkedRecipients.map(async (recipient) => {
const upsertedRecipient = await tx.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
documentId,
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
documentId,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
documentId,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
});
const recipientId = upsertedRecipient.id;
// Clear all fields if the recipient role is changed to a type that cannot have fields.
if (
recipient._persisted &&
recipient._persisted.role !== recipient.role &&
(recipient.role === RecipientRole.CC || recipient.role === RecipientRole.VIEWER)
) {
await tx.field.deleteMany({
where: {
recipientId,
},
});
}
const baseAuditLog = {
recipientEmail: upsertedRecipient.email,
recipientName: upsertedRecipient.name,
recipientId,
recipientRole: upsertedRecipient.role,
};
const changes = recipient._persisted
? diffRecipientChanges(recipient._persisted, upsertedRecipient)
: [];
// Handle recipient updated audit log.
if (recipient._persisted && changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: documentId,
user,
requestMetadata,
data: {
changes,
...baseAuditLog,
},
}),
});
}
// Handle recipient created audit log.
if (!recipient._persisted) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
documentId: documentId,
user,
requestMetadata,
data: baseAuditLog,
}),
});
}
return upsertedRecipient;
}),
),
);
);
});
if (removedRecipients.length > 0) {
await prisma.recipient.deleteMany({
where: {
id: {
in: removedRecipients.map((recipient) => recipient.id),
await prisma.$transaction(async (tx) => {
await tx.recipient.deleteMany({
where: {
id: {
in: removedRecipients.map((recipient) => recipient.id),
},
},
},
});
await tx.documentAuditLog.createMany({
data: removedRecipients.map((recipient) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
documentId: documentId,
user,
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
},
}),
),
});
});
}

View File

@ -20,7 +20,20 @@ export const setRecipientsForTemplate = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});

View File

@ -46,7 +46,7 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
},
});
if (IS_BILLING_ENABLED && team.subscription) {
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId: teamMemberInvite.teamId,

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

@ -57,10 +57,10 @@ export const createTeam = async ({
},
});
let isPaymentRequired = IS_BILLING_ENABLED;
let isPaymentRequired = IS_BILLING_ENABLED();
let customerId: string | null = null;
if (IS_BILLING_ENABLED) {
if (IS_BILLING_ENABLED()) {
const communityPlanPriceIds = await getCommunityPlanPriceIds();
isPaymentRequired = !subscriptionsContainsActiveCommunityPlan(

View File

@ -85,7 +85,7 @@ export const deleteTeamMembers = async ({
},
});
if (IS_BILLING_ENABLED && team.subscription) {
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,

View File

@ -72,8 +72,20 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
where: whereFilter,
include: {
teamEmail: true,
emailVerification: true,
transferVerification: true,
emailVerification: {
select: {
expiresAt: true,
name: true,
email: true,
},
},
transferVerification: {
select: {
expiresAt: true,
name: true,
email: true,
},
},
subscription: true,
members: {
where: {

View File

@ -42,7 +42,7 @@ export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => {
},
});
if (IS_BILLING_ENABLED && team.subscription) {
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,

View File

@ -49,7 +49,7 @@ export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOpti
let teamSubscription: Stripe.Subscription | null = null;
if (IS_BILLING_ENABLED) {
if (IS_BILLING_ENABLED()) {
teamSubscription = await transferTeamSubscription({
user: newOwnerUser,
team,

View File

@ -11,7 +11,23 @@ export const createDocumentFromTemplate = async ({
userId,
}: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({
where: { id: templateId, userId },
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
include: {
Recipient: true,
Field: true,
@ -34,6 +50,7 @@ export const createDocumentFromTemplate = async ({
const document = await prisma.document.create({
data: {
userId,
teamId: template.teamId,
title: template.title,
documentDataId: documentData.id,
Recipient: {

View File

@ -1,20 +1,36 @@
import { prisma } from '@documenso/prisma';
import { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
import type { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
export type CreateTemplateOptions = TCreateTemplateMutationSchema & {
userId: number;
teamId?: number;
};
export const createTemplate = async ({
title,
userId,
teamId,
templateDocumentDataId,
}: CreateTemplateOptions) => {
if (teamId) {
await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
return await prisma.template.create({
data: {
title,
userId,
templateDocumentDataId,
teamId,
},
});
};

View File

@ -8,5 +8,23 @@ export type DeleteTemplateOptions = {
};
export const deleteTemplate = async ({ id, userId }: DeleteTemplateOptions) => {
return await prisma.template.delete({ where: { id, userId } });
return await prisma.template.delete({
where: {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
};

View File

@ -1,14 +1,39 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import type { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & {
userId: number;
};
export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplateOptions) => {
export const duplicateTemplate = async ({
templateId,
userId,
teamId,
}: DuplicateTemplateOptions) => {
let templateWhereFilter: Prisma.TemplateWhereUniqueInput = {
id: templateId,
userId,
teamId: null,
};
if (teamId !== undefined) {
templateWhereFilter = {
id: templateId,
teamId,
team: {
members: {
some: {
userId,
},
},
},
};
}
const template = await prisma.template.findUnique({
where: { id: templateId, userId },
where: templateWhereFilter,
include: {
Recipient: true,
Field: true,
@ -31,6 +56,7 @@ export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplat
const duplicatedTemplate = await prisma.template.create({
data: {
userId,
teamId,
title: template.title + ' (copy)',
templateDocumentDataId: documentData.id,
Recipient: {

View File

@ -0,0 +1,56 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
export type FindTemplatesOptions = {
userId: number;
teamId?: number;
page: number;
perPage: number;
};
export const findTemplates = async ({
userId,
teamId,
page = 1,
perPage = 10,
}: FindTemplatesOptions) => {
let whereFilter: Prisma.TemplateWhereInput = {
userId,
teamId: null,
};
if (teamId !== undefined) {
whereFilter = {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
};
}
const [templates, count] = await Promise.all([
prisma.template.findMany({
where: whereFilter,
include: {
templateDocumentData: true,
Field: true,
},
skip: Math.max(page - 1, 0) * perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.template.count({
where: whereFilter,
}),
]);
return {
templates,
totalPages: Math.ceil(count / perPage),
};
};

View File

@ -1,4 +1,5 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
export interface GetTemplateByIdOptions {
id: number;
@ -6,11 +7,26 @@ export interface GetTemplateByIdOptions {
}
export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) => {
const whereFilter: Prisma.TemplateWhereInput = {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
};
return await prisma.template.findFirstOrThrow({
where: {
id,
userId,
},
where: whereFilter,
include: {
templateDocumentData: true,
},

View File

@ -1,35 +0,0 @@
import { prisma } from '@documenso/prisma';
export type GetTemplatesOptions = {
userId: number;
page: number;
perPage: number;
};
export const getTemplates = async ({ userId, page = 1, perPage = 10 }: GetTemplatesOptions) => {
const [templates, count] = await Promise.all([
prisma.template.findMany({
where: {
userId,
},
include: {
templateDocumentData: true,
Field: true,
},
skip: Math.max(page - 1, 0) * perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.template.count({
where: {
userId,
},
}),
]);
return {
templates,
totalPages: Math.ceil(count / perPage),
};
};

View File

@ -68,7 +68,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
},
});
if (!IS_BILLING_ENABLED) {
if (!IS_BILLING_ENABLED()) {
return;
}
@ -108,7 +108,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

@ -0,0 +1,18 @@
import { prisma } from '@documenso/prisma';
export type GetMostRecentVerificationTokenByUserIdOptions = {
userId: number;
};
export const getMostRecentVerificationTokenByUserId = async ({
userId,
}: GetMostRecentVerificationTokenByUserIdOptions) => {
return await prisma.verificationToken.findFirst({
where: {
userId,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@ -1,13 +1,20 @@
import crypto from 'crypto';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
import { getMostRecentVerificationTokenByUserId } from './get-most-recent-verification-token-by-user-id';
const IDENTIFIER = 'confirmation-email';
export const sendConfirmationToken = async ({ email }: { email: string }) => {
type SendConfirmationTokenOptions = { email: string; force?: boolean };
export const sendConfirmationToken = async ({
email,
force = false,
}: SendConfirmationTokenOptions) => {
const token = crypto.randomBytes(20).toString('hex');
const user = await prisma.user.findFirst({
@ -20,6 +27,21 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => {
throw new Error('User not found');
}
if (user.emailVerified) {
throw new Error('Email verified');
}
const mostRecentToken = await getMostRecentVerificationTokenByUserId({ userId: user.id });
// If we've sent a token in the last 5 minutes, don't send another one
if (
!force &&
mostRecentToken?.createdAt &&
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
return;
}
const createdToken = await prisma.verificationToken.create({
data: {
identifier: IDENTIFIER,
@ -37,5 +59,11 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => {
throw new Error(`Failed to create the verification token`);
}
return sendConfirmationEmail({ userId: user.id });
try {
await sendConfirmationEmail({ userId: user.id });
return { success: true };
} catch (err) {
throw new Error(`Failed to send the confirmation email`);
}
};

View File

@ -0,0 +1,350 @@
/////////////////////////////////////////////////////////////////////////////////////////////
//
// Be aware that any changes to this file may require migrations since we are storing JSON
// data in Prisma.
//
/////////////////////////////////////////////////////////////////////////////////////////////
import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
export const ZDocumentAuditLogTypeSchema = z.enum([
// Document actions.
'EMAIL_SENT',
// Document modification events.
'FIELD_CREATED',
'FIELD_DELETED',
'FIELD_UPDATED',
'RECIPIENT_CREATED',
'RECIPIENT_DELETED',
'RECIPIENT_UPDATED',
// Document events.
'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([
'DATE_FORMAT',
'MESSAGE',
'PASSWORD',
'REDIRECT_URL',
'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_META_DIFF_TYPE = ZDocumentMetaDiffTypeSchema.Enum;
export const FIELD_DIFF_TYPE = ZFieldDiffTypeSchema.Enum;
export const RECIPIENT_DIFF_TYPE = ZRecipientDiffTypeSchema.Enum;
export const ZFieldDiffDimensionSchema = z.object({
type: z.literal(FIELD_DIFF_TYPE.DIMENSION),
from: z.object({
width: z.number(),
height: z.number(),
}),
to: z.object({
width: z.number(),
height: z.number(),
}),
});
export const ZFieldDiffPositionSchema = z.object({
type: z.literal(FIELD_DIFF_TYPE.POSITION),
from: z.object({
page: z.number(),
positionX: z.number(),
positionY: z.number(),
}),
to: z.object({
page: z.number(),
positionX: z.number(),
positionY: z.number(),
}),
});
export const ZDocumentAuditLogDocumentMetaSchema = z.union([
z.object({
type: z.union([
z.literal(DOCUMENT_META_DIFF_TYPE.DATE_FORMAT),
z.literal(DOCUMENT_META_DIFF_TYPE.MESSAGE),
z.literal(DOCUMENT_META_DIFF_TYPE.REDIRECT_URL),
z.literal(DOCUMENT_META_DIFF_TYPE.SUBJECT),
z.literal(DOCUMENT_META_DIFF_TYPE.TIMEZONE),
]),
from: z.string().nullable(),
to: z.string().nullable(),
}),
z.object({
type: z.literal(DOCUMENT_META_DIFF_TYPE.PASSWORD),
}),
]);
export const ZDocumentAuditLogFieldDiffSchema = z.union([
ZFieldDiffDimensionSchema,
ZFieldDiffPositionSchema,
]);
export const ZRecipientDiffNameSchema = z.object({
type: z.literal(RECIPIENT_DIFF_TYPE.NAME),
from: z.string(),
to: z.string(),
});
export const ZRecipientDiffRoleSchema = z.object({
type: z.literal(RECIPIENT_DIFF_TYPE.ROLE),
from: z.string(),
to: z.string(),
});
export const ZRecipientDiffEmailSchema = z.object({
type: z.literal(RECIPIENT_DIFF_TYPE.EMAIL),
from: z.string(),
to: z.string(),
});
export const ZDocumentAuditLogRecipientDiffSchema = z.union([
ZRecipientDiffNameSchema,
ZRecipientDiffRoleSchema,
ZRecipientDiffEmailSchema,
]);
const ZBaseFieldEventDataSchema = z.object({
fieldId: z.string(), // Note: This is the secondary field ID, which will get migrated in the future.
fieldRecipientEmail: z.string(),
fieldRecipientId: z.number(),
fieldType: z.string(), // We specifically don't want to use enums to allow for more flexibility.
});
const ZBaseRecipientDataSchema = z.object({
recipientEmail: z.string(),
recipientName: z.string(),
recipientId: z.number(),
recipientRole: z.string(),
});
/**
* Event: Email sent.
*/
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',
]),
isResending: z.boolean(),
}),
});
/**
* Event: Document completed.
*/
export const ZDocumentAuditLogEventDocumentCompletedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED),
data: z.object({
transactionId: z.string(),
}),
});
/**
* Event: Document created.
*/
export const ZDocumentAuditLogEventDocumentCreatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED),
data: z.object({
title: z.string(),
}),
});
/**
* Event: Document field inserted.
*/
export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED),
data: ZBaseRecipientDataSchema.extend({
fieldId: z.string(),
// Organised into union to allow us to extend each field if required.
field: z.union([
z.object({
type: z.literal(FieldType.EMAIL),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.DATE),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.NAME),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.TEXT),
data: z.string(),
}),
z.object({
type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
data: z.string(),
}),
]),
// Todo: Replace with union once we have more field security types.
fieldSecurity: z.object({
type: z.literal('NONE'),
}),
}),
});
/**
* Event: Document field uninserted.
*/
export const ZDocumentAuditLogEventDocumentFieldUninsertedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED),
data: z.object({
field: z.nativeEnum(FieldType),
fieldId: z.string(),
}),
});
/**
* Event: Document meta updated.
*/
export const ZDocumentAuditLogEventDocumentMetaUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED),
data: z.object({
changes: z.array(ZDocumentAuditLogDocumentMetaSchema),
}),
});
/**
* Event: Document opened.
*/
export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED),
data: ZBaseRecipientDataSchema,
});
/**
* Event: Document recipient completed the document (the recipient has fully actioned and completed their required steps for the document).
*/
export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED),
data: ZBaseRecipientDataSchema,
});
/**
* Event: Document title updated.
*/
export const ZDocumentAuditLogEventDocumentTitleUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED),
data: z.object({
from: z.string(),
to: z.string(),
}),
});
/**
* Event: Field created.
*/
export const ZDocumentAuditLogEventFieldCreatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED),
data: ZBaseFieldEventDataSchema,
});
/**
* Event: Field deleted.
*/
export const ZDocumentAuditLogEventFieldRemovedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED),
data: ZBaseFieldEventDataSchema,
});
/**
* Event: Field updated.
*/
export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED),
data: ZBaseFieldEventDataSchema.extend({
changes: z.array(ZDocumentAuditLogFieldDiffSchema),
}),
});
/**
* Event: Recipient added.
*/
export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED),
data: ZBaseRecipientDataSchema,
});
/**
* Event: Recipient updated.
*/
export const ZDocumentAuditLogEventRecipientUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED),
data: ZBaseRecipientDataSchema.extend({
changes: z.array(ZDocumentAuditLogRecipientDiffSchema),
}),
});
/**
* Event: Recipient deleted.
*/
export const ZDocumentAuditLogEventRecipientRemovedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED),
data: ZBaseRecipientDataSchema,
});
export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
documentId: z.number(),
});
export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
z.union([
ZDocumentAuditLogEventEmailSentSchema,
ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
ZDocumentAuditLogEventDocumentMetaUpdatedSchema,
ZDocumentAuditLogEventDocumentOpenedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
ZDocumentAuditLogEventFieldCreatedSchema,
ZDocumentAuditLogEventFieldRemovedSchema,
ZDocumentAuditLogEventFieldUpdatedSchema,
ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
]),
);
export type TDocumentAuditLog = z.infer<typeof ZDocumentAuditLogSchema>;
export type TDocumentAuditLogType = z.infer<typeof ZDocumentAuditLogTypeSchema>;
export type TDocumentAuditLogFieldDiffSchema = z.infer<typeof ZDocumentAuditLogFieldDiffSchema>;
export type TDocumentAuditLogDocumentMetaDiffSchema = z.infer<
typeof ZDocumentAuditLogDocumentMetaSchema
>;
export type TDocumentAuditLogRecipientDiffSchema = z.infer<
typeof ZDocumentAuditLogRecipientDiffSchema
>;

View File

@ -25,10 +25,16 @@ export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetad
export const extractNextAuthRequestMetadata = (
req: Pick<RequestInternal, 'body' | 'query' | 'headers' | 'method'>,
): RequestMetadata => {
const parsedIp = ZIpSchema.safeParse(req.headers?.['x-forwarded-for']);
return extractNextHeaderRequestMetadata(req.headers ?? {});
};
export const extractNextHeaderRequestMetadata = (
headers: Record<string, string>,
): RequestMetadata => {
const parsedIp = ZIpSchema.safeParse(headers?.['x-forwarded-for']);
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
const userAgent = req.headers?.['user-agent'];
const userAgent = headers?.['user-agent'];
return {
ipAddress,

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

@ -0,0 +1,205 @@
import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client';
import type {
TDocumentAuditLog,
TDocumentAuditLogDocumentMetaDiffSchema,
TDocumentAuditLogFieldDiffSchema,
TDocumentAuditLogRecipientDiffSchema,
} from '../types/document-audit-logs';
import {
DOCUMENT_META_DIFF_TYPE,
FIELD_DIFF_TYPE,
RECIPIENT_DIFF_TYPE,
ZDocumentAuditLogSchema,
} from '../types/document-audit-logs';
import type { RequestMetadata } from '../universal/extract-request-metadata';
type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
documentId: number;
type: T;
data: Extract<TDocumentAuditLog, { type: T }>['data'];
user: { email?: string; id?: number | null; name?: string | null } | null;
requestMetadata?: RequestMetadata;
};
type CreateDocumentAuditLogDataResponse = Pick<
DocumentAuditLog,
'type' | 'ipAddress' | 'userAgent' | 'email' | 'userId' | 'name' | 'documentId'
> & {
data: TDocumentAuditLog['data'];
};
export const createDocumentAuditLogData = ({
documentId,
type,
data,
user,
requestMetadata,
}: CreateDocumentAuditLogDataOptions): CreateDocumentAuditLogDataResponse => {
return {
type,
data,
documentId,
userId: user?.id ?? null,
email: user?.email ?? null,
name: user?.name ?? null,
userAgent: requestMetadata?.userAgent ?? null,
ipAddress: requestMetadata?.ipAddress ?? null,
};
};
/**
* Parse a raw document audit log from Prisma, to a typed audit log.
*
* @param auditLog raw audit log from Prisma.
*/
export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocumentAuditLog => {
const data = ZDocumentAuditLogSchema.safeParse(auditLog);
// Handle any required migrations here.
if (!data.success) {
throw new Error('Migration required');
}
return data.data;
};
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role'>;
export const diffRecipientChanges = (
oldRecipient: PartialRecipient,
newRecipient: PartialRecipient,
): TDocumentAuditLogRecipientDiffSchema[] => {
const diffs: TDocumentAuditLogRecipientDiffSchema[] = [];
if (oldRecipient.email !== newRecipient.email) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: oldRecipient.email,
to: newRecipient.email,
});
}
if (oldRecipient.role !== newRecipient.role) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.ROLE,
from: oldRecipient.role,
to: newRecipient.role,
});
}
if (oldRecipient.name !== newRecipient.name) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.NAME,
from: oldRecipient.name,
to: newRecipient.name,
});
}
return diffs;
};
export const diffFieldChanges = (
oldField: Field,
newField: Field,
): TDocumentAuditLogFieldDiffSchema[] => {
const diffs: TDocumentAuditLogFieldDiffSchema[] = [];
if (
oldField.page !== newField.page ||
!oldField.positionX.equals(newField.positionX) ||
!oldField.positionY.equals(newField.positionY)
) {
diffs.push({
type: FIELD_DIFF_TYPE.POSITION,
from: {
page: oldField.page,
positionX: oldField.positionX.toNumber(),
positionY: oldField.positionY.toNumber(),
},
to: {
page: newField.page,
positionX: newField.positionX.toNumber(),
positionY: newField.positionY.toNumber(),
},
});
}
if (!oldField.width.equals(newField.width) || !oldField.height.equals(newField.height)) {
diffs.push({
type: FIELD_DIFF_TYPE.DIMENSION,
from: {
width: oldField.width.toNumber(),
height: oldField.height.toNumber(),
},
to: {
width: newField.width.toNumber(),
height: newField.height.toNumber(),
},
});
}
return diffs;
};
export const diffDocumentMetaChanges = (
oldData: Partial<DocumentMeta> = {},
newData: DocumentMeta,
): TDocumentAuditLogDocumentMetaDiffSchema[] => {
const diffs: TDocumentAuditLogDocumentMetaDiffSchema[] = [];
const oldDateFormat = oldData?.dateFormat ?? '';
const oldMessage = oldData?.message ?? '';
const oldSubject = oldData?.subject ?? '';
const oldTimezone = oldData?.timezone ?? '';
const oldPassword = oldData?.password ?? null;
const oldRedirectUrl = oldData?.redirectUrl ?? '';
if (oldDateFormat !== newData.dateFormat) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.DATE_FORMAT,
from: oldData?.dateFormat ?? '',
to: newData.dateFormat,
});
}
if (oldMessage !== newData.message) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.MESSAGE,
from: oldMessage,
to: newData.message,
});
}
if (oldSubject !== newData.subject) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.SUBJECT,
from: oldSubject,
to: newData.subject,
});
}
if (oldTimezone !== newData.timezone) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.TIMEZONE,
from: oldTimezone,
to: newData.timezone,
});
}
if (oldRedirectUrl !== newData.redirectUrl) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.REDIRECT_URL,
from: oldRedirectUrl,
to: newData.redirectUrl,
});
}
if (oldPassword !== newData.password) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.PASSWORD,
});
}
return diffs;
};

View File

@ -12,6 +12,10 @@ export const formatDocumentsPath = (teamUrl?: string) => {
return teamUrl ? `/t/${teamUrl}/documents` : '/documents';
};
export const formatTemplatesPath = (teamUrl?: string) => {
return teamUrl ? `/t/${teamUrl}/templates` : '/templates';
};
/**
* Determines whether a team member can execute a given action.
*

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "teamId" INTEGER;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "redirectUrl" TEXT;

View File

@ -0,0 +1,37 @@
/*
Warnings:
- A unique constraint covering the columns `[secondaryId]` on the table `Field` will be added. If there are existing duplicate values, this will fail.
- The required column `secondaryId` was added to the `Field` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- AlterTable
ALTER TABLE "Field" ADD COLUMN "secondaryId" TEXT;
-- Set all null secondaryId fields to a uuid
UPDATE "Field" SET "secondaryId" = gen_random_uuid()::text WHERE "secondaryId" IS NULL;
-- Restrict the Field to required
ALTER TABLE "Field" ALTER COLUMN "secondaryId" SET NOT NULL;
-- CreateTable
CREATE TABLE "DocumentAuditLog" (
"id" TEXT NOT NULL,
"documentId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"type" TEXT NOT NULL,
"data" JSONB NOT NULL,
"name" TEXT,
"email" TEXT,
"userId" INTEGER,
"userAgent" TEXT,
"ipAddress" TEXT,
CONSTRAINT "DocumentAuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Field_secondaryId_key" ON "Field"("secondaryId");
-- AddForeignKey
ALTER TABLE "DocumentAuditLog" ADD CONSTRAINT "DocumentAuditLog_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -188,11 +188,30 @@ model Document {
teamId Int?
team Team? @relation(fields: [teamId], references: [id])
auditLogs DocumentAuditLog[]
@@unique([documentDataId])
@@index([userId])
@@index([status])
}
model DocumentAuditLog {
id String @id @default(cuid())
documentId Int
createdAt DateTime @default(now())
type String
data Json
// Details of the person who performed the action which caused the audit log.
name String?
email String?
userId Int?
userAgent String?
ipAddress String?
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
}
enum DocumentDataType {
S3_PATH
BYTES
@ -217,6 +236,7 @@ model DocumentMeta {
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 {
@ -277,6 +297,7 @@ enum FieldType {
model Field {
id Int @id @default(autoincrement())
secondaryId String @unique @default(cuid())
documentId Int?
templateId Int?
recipientId Int?
@ -352,7 +373,8 @@ model Team {
owner User @relation(fields: [ownerUserId], references: [id])
subscription Subscription?
document Document[]
document Document[]
templates Template[]
}
model TeamPending {
@ -433,10 +455,12 @@ model Template {
type TemplateType @default(PRIVATE)
title String
userId Int
teamId Int?
templateDocumentDataId String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Recipient Recipient[]

View File

@ -0,0 +1,36 @@
import fs from 'node:fs';
import path from 'node:path';
import { prisma } from '..';
import { DocumentDataType } from '../client';
const examplePdf = fs
.readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
.toString('base64');
type SeedTemplateOptions = {
title?: string;
userId: number;
teamId?: number;
};
export const seedTemplate = async (options: SeedTemplateOptions) => {
const { title = 'Untitled', userId, teamId } = options;
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
return await prisma.template.create({
data: {
title,
templateDocumentDataId: documentData.id,
userId: userId,
teamId,
},
});
};

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

@ -15,6 +15,7 @@ import { updateTitle } from '@documenso/lib/server-only/document/update-title';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
@ -88,6 +89,7 @@ export const documentRouter = router({
teamId,
title,
documentDataId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
if (err instanceof TRPCError) {
@ -131,6 +133,7 @@ export const documentRouter = router({
title,
userId,
documentId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
}),
@ -144,6 +147,7 @@ export const documentRouter = router({
userId: ctx.user.id,
documentId,
recipients,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@ -166,6 +170,7 @@ export const documentRouter = router({
userId: ctx.user.id,
documentId,
fields,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@ -198,6 +203,7 @@ export const documentRouter = router({
documentId,
password: securePassword,
userId: ctx.user.id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@ -215,20 +221,23 @@ export const documentRouter = router({
try {
const { documentId, meta } = input;
if (meta.message || meta.subject || meta.timezone || meta.dateFormat) {
if (meta.message || meta.subject || meta.timezone || meta.dateFormat || meta.redirectUrl) {
await upsertDocumentMeta({
documentId,
subject: meta.subject,
message: meta.message,
dateFormat: meta.dateFormat,
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
userId: ctx.user.id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
}
return await sendDocument({
userId: ctx.user.id,
documentId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@ -247,6 +256,7 @@ export const documentRouter = router({
return await resendDocument({
userId: ctx.user.id,
...input,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);

View File

@ -1,5 +1,6 @@
import { z } from 'zod';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
export const ZGetDocumentByIdQuerySchema = z.object({
@ -73,6 +74,12 @@ export const ZSendDocumentMutationSchema = z.object({
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});

View File

@ -4,6 +4,7 @@ import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/rem
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
@ -33,13 +34,14 @@ export const fieldRouter = router({
pageWidth: field.pageWidth,
pageHeight: field.pageHeight,
})),
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to sign this field. Please try again later.',
message: 'We were unable to set this field. Please try again later.',
});
}
}),
@ -67,7 +69,7 @@ export const fieldRouter = router({
signFieldWithToken: procedure
.input(ZSignFieldWithTokenMutationSchema)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const { token, fieldId, value, isBase64 } = input;
@ -76,6 +78,7 @@ export const fieldRouter = router({
fieldId,
value,
isBase64,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@ -89,13 +92,14 @@ export const fieldRouter = router({
removeSignedFieldWithToken: procedure
.input(ZRemovedSignedFieldWithTokenMutationSchema)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const { token, fieldId } = input;
return await removeSignedFieldWithToken({
token,
fieldId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);

View File

@ -141,7 +141,7 @@ export const profileRouter = router({
try {
const { email } = input;
return sendConfirmationToken({ email });
return await sendConfirmationToken({ email });
} catch (err) {
let message = 'We were unable to send a confirmation email. Please try again.';

View File

@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
@ -27,13 +28,14 @@ export const recipientRouter = router({
name: signer.name,
role: signer.role,
})),
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to sign this field. Please try again later.',
message: 'We were unable to set this field. Please try again later.',
});
}
}),
@ -58,20 +60,21 @@ export const recipientRouter = router({
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to sign this field. Please try again later.',
message: 'We were unable to set this field. Please try again later.',
});
}
}),
completeDocumentWithToken: procedure
.input(ZCompleteDocumentWithTokenMutationSchema)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const { token, documentId } = input;
return await completeDocumentWithToken({
token,
documentId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);

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';
@ -62,6 +63,7 @@ export const singleplayerRouter = router({
: null,
// Dummy data.
id: -1,
secondaryId: '-1',
documentId: -1,
templateId: null,
recipientId: -1,
@ -148,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

@ -19,11 +19,12 @@ export const templateRouter = router({
.input(ZCreateTemplateMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { title, templateDocumentDataId } = input;
const { teamId, title, templateDocumentDataId } = input;
return await createTemplate({
title,
userId: ctx.user.id,
teamId,
title,
templateDocumentDataId,
});
} catch (err) {
@ -64,11 +65,12 @@ export const templateRouter = router({
.input(ZDuplicateTemplateMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { templateId } = input;
const { teamId, templateId } = input;
return await duplicateTemplate({
templateId,
userId: ctx.user.id,
teamId,
templateId,
});
} catch (err) {
console.error(err);
@ -88,7 +90,7 @@ export const templateRouter = router({
const userId = ctx.user.id;
return await deleteTemplate({ id, userId });
return await deleteTemplate({ userId, id });
} catch (err) {
console.error(err);

View File

@ -1,7 +1,8 @@
import { z } from 'zod';
export const ZCreateTemplateMutationSchema = z.object({
title: z.string().min(1),
title: z.string().min(1).trim(),
teamId: z.number().optional(),
templateDocumentDataId: z.string().min(1),
});
@ -11,6 +12,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
export const ZDuplicateTemplateMutationSchema = z.object({
templateId: z.number(),
teamId: z.number().optional(),
});
export const ZDeleteTemplateMutationSchema = z.object({

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

@ -304,6 +304,13 @@ export const AddFieldsFormPartial = ({
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 (
<>
<DocumentFlowFormContainerHeader
@ -382,13 +389,10 @@ export const AddFieldsFormPartial = ({
</span>
</CommandEmpty>
{Object.entries(recipientsByRole).map(([role, recipients], roleIndex) => (
{recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
RECIPIENT_ROLES_DESCRIPTION[role as RecipientRole].roleName
}
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
</div>
{recipients.length === 0 && (
@ -403,7 +407,7 @@ export const AddFieldsFormPartial = ({
{recipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn('px-4 last:mb-1 [&:not(:first-child)]:mt-1', {
className={cn('px-2 last:mb-1 [&:not(:first-child)]:mt-1', {
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
})}
onSelect={() => {
@ -413,7 +417,7 @@ export const AddFieldsFormPartial = ({
>
<span
className={cn('text-foreground/70 truncate', {
'text-foreground': recipient === selectedSigner,
'text-foreground/80': recipient === selectedSigner,
})}
>
{recipient.name && (

View File

@ -105,7 +105,10 @@ export const AddSignersFormPartial = ({
}
return recipients.some(
(recipient) => recipient.id === id && recipient.sendStatus === SendStatus.SENT,
(recipient) =>
recipient.id === id &&
recipient.sendStatus === SendStatus.SENT &&
recipient.role !== RecipientRole.CC,
);
};

View File

@ -2,6 +2,8 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Info } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
@ -23,6 +25,7 @@ import {
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { Combobox } from '../combobox';
import { FormErrorMessage } from '../form/form-error-message';
@ -30,7 +33,7 @@ import { Input } from '../input';
import { Label } from '../label';
import { useStep } from '../stepper';
import { Textarea } from '../textarea';
import type { TAddSubjectFormSchema } from './add-subject.types';
import { type TAddSubjectFormSchema, ZAddSubjectFormSchema } from './add-subject.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
@ -69,8 +72,10 @@ export const AddSubjectFormPartial = ({
message: document.documentMeta?.message ?? '',
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: document.documentMeta?.redirectUrl ?? '',
},
},
resolver: zodResolver(ZAddSubjectFormSchema),
});
const onFormSubmit = handleSubmit(onSubmit);
@ -163,64 +168,94 @@ export const AddSubjectFormPartial = ({
</ul>
</div>
{hasDateField && (
<Accordion type="multiple" className="mt-8 border-none">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="mb-2 border-b text-left hover:no-underline">
Advanced Options
</AccordionTrigger>
<Accordion type="multiple" className="mt-8 border-none">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="mb-2 border-b text-left hover:no-underline">
Advanced Options
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 flex max-w-prose flex-col px-1 text-sm leading-relaxed">
<div className="mt-2 flex flex-col">
<Label htmlFor="date-format">
Date Format <span className="text-muted-foreground">(Optional)</span>
</Label>
<AccordionContent className="text-muted-foreground -mx-1 flex max-w-prose flex-col px-1 pt-2 text-sm leading-relaxed">
{hasDateField && (
<>
<div className="flex flex-col">
<Label htmlFor="date-format">
Date Format <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name={`meta.dateFormat`}
disabled={documentHasBeenSent}
render={({ field: { value, onChange, disabled } }) => (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="bg-background mt-2">
<SelectValue />
</SelectTrigger>
<Controller
control={control}
name={`meta.dateFormat`}
disabled={documentHasBeenSent}
render={({ field: { value, onChange, disabled } }) => (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="bg-background mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="mt-4 flex flex-col">
<Label htmlFor="time-zone">
Time Zone <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name={`meta.timezone`}
render={({ field: { value, onChange } }) => (
<Combobox
className="bg-background"
options={TIME_ZONES}
value={value}
onChange={(value) => value && onChange(value)}
disabled={documentHasBeenSent}
/>
)}
/>
</div>
</>
)}
<div className="flex flex-col">
<div className="flex flex-col gap-y-4">
<div>
<Label htmlFor="redirectUrl" className="flex items-center">
Redirect URL{' '}
<Tooltip>
<TooltipTrigger>
<Info className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
Add a URL to redirect the user to once the document is signed
</TooltipContent>
</Tooltip>
</Label>
<Input
id="redirectUrl"
type="url"
className="bg-background my-2"
{...register('meta.redirectUrl')}
/>
<FormErrorMessage className="mt-2" error={errors.meta?.redirectUrl} />
</div>
</div>
<div className="mt-4 flex flex-col">
<Label htmlFor="time-zone">
Time Zone <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name={`meta.timezone`}
render={({ field: { value, onChange } }) => (
<Combobox
className="bg-background"
options={TIME_ZONES}
value={value}
onChange={(value) => value && onChange(value)}
disabled={documentHasBeenSent}
/>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</DocumentFlowFormContainerContent>

View File

@ -2,6 +2,7 @@ import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
export const ZAddSubjectFormSchema = z.object({
meta: z.object({
@ -9,6 +10,12 @@ export const ZAddSubjectFormSchema = z.object({
message: z.string(),
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});

View File

@ -1,5 +1,6 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import type { Field, Recipient } from '@documenso/prisma/client';
@ -10,6 +11,7 @@ import { Input } from '../input';
import { Label } from '../label';
import { useStep } from '../stepper';
import type { TAddTitleFormSchema } from './add-title.types';
import { ZAddTitleFormSchema } from './add-title.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
@ -40,6 +42,7 @@ export const AddTitleFormPartial = ({
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddTitleFormSchema>({
resolver: zodResolver(ZAddTitleFormSchema),
defaultValues: {
title: document.title,
},
@ -71,7 +74,7 @@ export const AddTitleFormPartial = ({
id="title"
className="bg-background my-2"
disabled={isSubmitting}
{...register('title', { required: "Title can't be empty" })}
{...register('title')}
/>
<FormErrorMessage className="mt-2" error={errors.title} />

View File

@ -1,7 +1,7 @@
import { z } from 'zod';
export const ZAddTitleFormSchema = z.object({
title: z.string().min(1),
title: z.string().trim().min(1, { message: "Title can't be empty" }),
});
export type TAddTitleFormSchema = z.infer<typeof ZAddTitleFormSchema>;

View File

@ -3,7 +3,8 @@
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { VariantProps, cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '../lib/utils';
@ -12,7 +13,7 @@ const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const portalVariants = cva('fixed inset-0 z-50 flex', {
const portalVariants = cva('fixed inset-0 z-[61] flex', {
variants: {
position: {
top: 'items-start',
@ -42,7 +43,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, children: _children, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'bg-background/80 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-50 backdrop-blur-sm transition-all duration-100',
'bg-background/80 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-[61] backdrop-blur-sm transition-all duration-100',
className,
)}
{...props}
@ -53,7 +54,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 scale-100 gap-4 bg-background p-6 opacity-100 shadow-lg border',
'fixed z-[61] scale-100 gap-4 bg-background p-6 opacity-100 shadow-lg border',
{
variants: {
position: {