feat: support embedded authoring for creation (#1741)

Adds support for creating documents and templates
using our embed components.

Support is super primitive at the moment and is being polished.
This commit is contained in:
Lucas Smith
2025-04-11 00:20:39 +10:00
committed by GitHub
parent 95aae52fa4
commit e613e0e347
42 changed files with 3849 additions and 137 deletions

View File

@ -0,0 +1,203 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { seedUser } from '@documenso/prisma/seed/users';
test.describe('Embedding Presign API', () => {
test('createEmbeddingPresignToken: should create a token with default expiration', async ({
request,
}) => {
const user = await seedUser();
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
},
},
);
const responseData = await response.json();
console.log(responseData);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.token).toBeDefined();
expect(responseData.expiresAt).toBeDefined();
expect(responseData.expiresIn).toBe(3600); // Default 1 hour in seconds
});
test('createEmbeddingPresignToken: should create a token with custom expiration', async ({
request,
}) => {
const user = await seedUser();
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
expiresIn: 120, // 2 hours
},
},
);
const responseData = await response.json();
console.log(responseData);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.token).toBeDefined();
expect(responseData.expiresAt).toBeDefined();
expect(responseData.expiresIn).toBe(7200); // 2 hours in seconds
});
test('createEmbeddingPresignToken: should create a token with immediate expiration in dev mode', async ({
request,
}) => {
const user = await seedUser();
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
expiresIn: 0, // Immediate expiration
},
},
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
const responseData = await response.json();
console.log(responseData);
expect(responseData.token).toBeDefined();
expect(responseData.expiresAt).toBeDefined();
expect(responseData.expiresIn).toBe(0); // 0 seconds
});
test('verifyEmbeddingPresignToken: should verify a valid token', async ({ request }) => {
const user = await seedUser();
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test',
expiresIn: null,
});
// First create a token
const createResponse = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
},
},
);
expect(createResponse.ok()).toBeTruthy();
const createResponseData = await createResponse.json();
console.log('Create response:', createResponseData);
const presignToken = createResponseData.token;
// Then verify it
const verifyResponse = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
token: presignToken,
},
},
);
expect(verifyResponse.ok()).toBeTruthy();
expect(verifyResponse.status()).toBe(200);
const verifyResponseData = await verifyResponse.json();
console.log('Verify response:', verifyResponseData);
expect(verifyResponseData.success).toBe(true);
});
test('verifyEmbeddingPresignToken: should reject an invalid token', async ({ request }) => {
const user = await seedUser();
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
token: 'invalid-token',
},
},
);
const responseData = await response.json();
console.log('Invalid token response:', responseData);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.success).toBe(false);
});
});

View File

@ -7,7 +7,7 @@
"scripts": {
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
"test:e2e": "NODE_OPTIONS=--experimental-require-module start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
"test:e2e": "NODE_OPTIONS=--experimental-require-module NODE_ENV=test start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
},
"keywords": [],
"author": "",

View File

@ -6,7 +6,7 @@ import { prisma } from '@documenso/prisma';
import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices';
export type IsDocumentPlatformOptions = Pick<Document, 'id' | 'userId' | 'teamId'>;
export type IsDocumentPlatformOptions = Pick<Document, 'userId' | 'teamId'>;
/**
* Whether the user is platform, or has permission to use platform features on

View File

@ -28,6 +28,7 @@
"@lingui/core": "^5.2.0",
"@lingui/macro": "^5.2.0",
"@lingui/react": "^5.2.0",
"jose": "^6.0.0",
"@noble/ciphers": "0.4.0",
"@noble/hashes": "1.3.2",
"@node-rs/bcrypt": "^1.10.0",
@ -58,4 +59,4 @@
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4"
}
}
}

View File

@ -0,0 +1,63 @@
import { SignJWT } from 'jose';
import { DateTime } from 'luxon';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { env } from '../../utils/env';
import { getApiTokenByToken } from '../public-api/get-api-token-by-token';
export type CreateEmbeddingPresignTokenOptions = {
apiToken: string;
/**
* Number of hours until the token expires
* In development mode, can be set to 0 to create a token that expires immediately (for testing)
*/
expiresIn?: number;
};
export const createEmbeddingPresignToken = async ({
apiToken,
expiresIn,
}: CreateEmbeddingPresignTokenOptions) => {
try {
// Validate the API token
const validatedToken = await getApiTokenByToken({ token: apiToken });
const now = DateTime.now();
// In development mode, allow setting expiresIn to 0 for testing
// In production, enforce a minimum expiration time
const isDevelopment = env('NODE_ENV') !== 'production';
console.log('isDevelopment', isDevelopment);
const minExpirationMinutes = isDevelopment ? 0 : 5;
// Ensure expiresIn is at least the minimum allowed value
const effectiveExpiresIn =
expiresIn !== undefined && expiresIn >= minExpirationMinutes ? expiresIn : 60; // Default to 1 hour if not specified or below minimum
const expiresAt = now.plus({ minutes: effectiveExpiresIn });
const secret = new TextEncoder().encode(validatedToken.token);
const token = await new SignJWT({
aud: String(validatedToken.teamId ?? validatedToken.userId),
sub: String(validatedToken.id),
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt(now.toJSDate())
.setExpirationTime(expiresAt.toJSDate())
.sign(secret);
return {
token,
expiresAt: expiresAt.toJSDate(),
expiresIn: Math.floor(expiresAt.diff(now).toMillis() / 1000),
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to create presign token',
});
}
};

View File

@ -0,0 +1,115 @@
import type { JWTPayload } from 'jose';
import { decodeJwt, jwtVerify } from 'jose';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type VerifyEmbeddingPresignTokenOptions = {
token: string;
};
export const verifyEmbeddingPresignToken = async ({
token,
}: VerifyEmbeddingPresignTokenOptions) => {
// First decode the JWT to get the claims without verification
let decodedToken: JWTPayload;
try {
decodedToken = decodeJwt<JWTPayload>(token);
} catch (error) {
console.error('Error decoding JWT token:', error);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token format',
});
}
// Validate the required claims
if (!decodedToken.sub || typeof decodedToken.sub !== 'string') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token format: missing or invalid subject claim',
});
}
if (!decodedToken.aud || typeof decodedToken.aud !== 'string') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token format: missing or invalid audience claim',
});
}
// Convert string IDs to numbers
const tokenId = Number(decodedToken.sub);
const audienceId = Number(decodedToken.aud);
if (Number.isNaN(tokenId) || !Number.isInteger(tokenId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid token ID format in subject claim',
});
}
if (Number.isNaN(audienceId) || !Number.isInteger(audienceId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid user ID format in audience claim',
});
}
// Get the API token to use as the verification secret
const apiToken = await prisma.apiToken.findFirst({
where: {
id: tokenId,
},
});
if (!apiToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token not found',
});
}
// This should never happen but we need to narrow types
if (!apiToken.userId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token does not have a user attached',
});
}
const userId = apiToken.userId;
if (audienceId !== apiToken.teamId && audienceId !== apiToken.userId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token does not match audience',
});
}
// Now verify the token with the actual secret
const secret = new TextEncoder().encode(apiToken.token);
try {
await jwtVerify(token, secret);
} catch (error) {
// Check if the token has expired
if (error instanceof Error && error.name === 'JWTExpired') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Presign token has expired',
});
}
// Handle invalid signature
if (error instanceof Error && error.name === 'JWSInvalid') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token signature',
});
}
// Log and rethrow other errors
console.error('Error verifying JWT token:', error);
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Failed to verify presign token',
});
}
return {
...apiToken,
userId,
};
};

View File

@ -0,0 +1,14 @@
import { router } from '../trpc';
import { createEmbeddingDocumentRoute } from './create-embedding-document';
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
import { createEmbeddingTemplateRoute } from './create-embedding-template';
import { getEmbeddingDocumentRoute } from './get-embedding-document';
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
export const embeddingPresignRouter = router({
createEmbeddingPresignToken: createEmbeddingPresignTokenRoute,
verifyEmbeddingPresignToken: verifyEmbeddingPresignTokenRoute,
createEmbeddingDocument: createEmbeddingDocumentRoute,
createEmbeddingTemplate: createEmbeddingTemplateRoute,
getEmbeddingDocument: getEmbeddingDocumentRoute,
});

View File

@ -0,0 +1,63 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createDocumentV2 } from '@documenso/lib/server-only/document/create-document-v2';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { procedure } from '../trpc';
import {
ZCreateEmbeddingDocumentRequestSchema,
ZCreateEmbeddingDocumentResponseSchema,
} from './create-embedding-document.types';
export const createEmbeddingDocumentRoute = procedure
.input(ZCreateEmbeddingDocumentRequestSchema)
.output(ZCreateEmbeddingDocumentResponseSchema)
.mutation(async ({ input, ctx: { req, metadata } }) => {
try {
const authorizationHeader = req.headers.get('authorization');
const [presignToken] = (authorizationHeader || '')
.split('Bearer ')
.filter((s) => s.length > 0);
if (!presignToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'No presign token provided',
});
}
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
const { title, documentDataId, externalId, recipients, meta } = input;
const document = await createDocumentV2({
data: {
title,
externalId,
recipients,
},
meta,
documentDataId,
userId: apiToken.userId,
teamId: apiToken.teamId ?? undefined,
requestMetadata: metadata,
});
if (!document.id) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to create document: missing document ID',
});
}
return {
documentId: document.id,
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to create document',
});
}
});

View File

@ -0,0 +1,83 @@
import { z } from 'zod';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import {
ZFieldHeightSchema,
ZFieldPageNumberSchema,
ZFieldPageXSchema,
ZFieldPageYSchema,
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { DocumentSigningOrder } from '@documenso/prisma/generated/types';
import {
ZDocumentExternalIdSchema,
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
ZDocumentMetaTypedSignatureEnabledSchema,
ZDocumentMetaUploadSignatureEnabledSchema,
ZDocumentTitleSchema,
} from '../document-router/schema';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
export const ZCreateEmbeddingDocumentRequestSchema = z.object({
title: ZDocumentTitleSchema,
documentDataId: z.string(),
externalId: ZDocumentExternalIdSchema.optional(),
recipients: z
.array(
ZCreateRecipientSchema.extend({
fields: ZFieldAndMetaSchema.and(
z.object({
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
}),
)
.array()
.optional(),
}),
)
.refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{ message: 'Recipients must have unique emails' },
)
.optional(),
meta: z
.object({
subject: ZDocumentMetaSubjectSchema.optional(),
message: ZDocumentMetaMessageSchema.optional(),
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
});
export const ZCreateEmbeddingDocumentResponseSchema = z.object({
documentId: z.number(),
});
export type TCreateEmbeddingDocumentRequestSchema = z.infer<
typeof ZCreateEmbeddingDocumentRequestSchema
>;

View File

@ -0,0 +1,73 @@
import { isCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import { procedure } from '../trpc';
import {
ZCreateEmbeddingPresignTokenRequestSchema,
ZCreateEmbeddingPresignTokenResponseSchema,
createEmbeddingPresignTokenMeta,
} from './create-embedding-presign-token.types';
/**
* Route to create embedding presign tokens.
*/
export const createEmbeddingPresignTokenRoute = procedure
.meta(createEmbeddingPresignTokenMeta)
.input(ZCreateEmbeddingPresignTokenRequestSchema)
.output(ZCreateEmbeddingPresignTokenResponseSchema)
.mutation(async ({ input, ctx: { req } }) => {
try {
const authorizationHeader = req.headers.get('authorization');
const [apiToken] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
if (!apiToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'No API token provided',
});
}
const { expiresIn } = input;
if (IS_BILLING_ENABLED()) {
const token = await getApiTokenByToken({ token: apiToken });
if (!token.userId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid API token',
});
}
const [hasCommunityPlan, hasPlatformPlan, hasEnterprisePlan] = await Promise.all([
isCommunityPlan({ userId: token.userId, teamId: token.teamId ?? undefined }),
isDocumentPlatform({ userId: token.userId, teamId: token.teamId }),
isUserEnterprise({ userId: token.userId, teamId: token.teamId ?? undefined }),
]);
if (!hasCommunityPlan && !hasPlatformPlan && !hasEnterprisePlan) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to create embedding presign tokens',
});
}
}
const presignToken = await createEmbeddingPresignToken({
apiToken,
expiresIn,
});
return { ...presignToken };
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to create embedding presign token',
});
}
});

View File

@ -0,0 +1,38 @@
import { z } from 'zod';
import type { TrpcRouteMeta } from '../trpc';
export const createEmbeddingPresignTokenMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/embedding/create-presign-token',
summary: 'Create embedding presign token',
description:
'Creates a presign token for embedding operations with configurable expiration time',
tags: ['Embedding'],
},
};
export const ZCreateEmbeddingPresignTokenRequestSchema = z.object({
expiresIn: z
.number()
.min(0)
.max(10080)
.optional()
.default(60)
.describe('Expiration time in minutes (default: 60, max: 10,080)'),
});
export const ZCreateEmbeddingPresignTokenResponseSchema = z.object({
token: z.string(),
expiresAt: z.date(),
expiresIn: z.number().describe('Expiration time in seconds'),
});
export type TCreateEmbeddingPresignTokenRequestSchema = z.infer<
typeof ZCreateEmbeddingPresignTokenRequestSchema
>;
export type TCreateEmbeddingPresignTokenResponseSchema = z.infer<
typeof ZCreateEmbeddingPresignTokenResponseSchema
>;

View File

@ -0,0 +1,112 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
import { prisma } from '@documenso/prisma';
import { procedure } from '../trpc';
import {
ZCreateEmbeddingTemplateRequestSchema,
ZCreateEmbeddingTemplateResponseSchema,
} from './create-embedding-template.types';
export const createEmbeddingTemplateRoute = procedure
.input(ZCreateEmbeddingTemplateRequestSchema)
.output(ZCreateEmbeddingTemplateResponseSchema)
.mutation(async ({ input, ctx: { req } }) => {
try {
const authorizationHeader = req.headers.get('authorization');
const [presignToken] = (authorizationHeader || '')
.split('Bearer ')
.filter((s) => s.length > 0);
if (!presignToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'No presign token provided',
});
}
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
const { title, documentDataId, recipients, meta } = input;
// First create the template
const template = await createTemplate({
userId: apiToken.userId,
title,
templateDocumentDataId: documentDataId,
teamId: apiToken.teamId ?? undefined,
});
await Promise.all(
recipients.map(async (recipient) => {
const createdRecipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: recipient.email,
name: recipient.name || '',
role: recipient.role || 'SIGNER',
token: `template-${template.id}-${recipient.email}`,
signingOrder: recipient.signingOrder,
},
});
const fields = recipient.fields ?? [];
const createdFields = await prisma.field.createMany({
data: fields.map((field) => ({
recipientId: createdRecipient.id,
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
templateId: template.id,
})),
});
return {
...createdRecipient,
fields: createdFields,
};
}),
);
// Update the template meta if needed
if (meta) {
await prisma.templateMeta.upsert({
where: {
templateId: template.id,
},
create: {
templateId: template.id,
...meta,
},
update: {
...meta,
},
});
}
if (!template.id) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to create template: missing template ID',
});
}
return {
templateId: template.id,
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to create template',
});
}
});

View File

@ -0,0 +1,74 @@
import { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import {
ZFieldHeightSchema,
ZFieldPageNumberSchema,
ZFieldPageXSchema,
ZFieldPageYSchema,
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
ZDocumentMetaTypedSignatureEnabledSchema,
ZDocumentMetaUploadSignatureEnabledSchema,
ZDocumentTitleSchema,
} from '../document-router/schema';
const ZFieldSchema = z.object({
type: z.nativeEnum(FieldType),
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
fieldMeta: ZFieldMetaSchema.optional(),
});
export const ZCreateEmbeddingTemplateRequestSchema = z.object({
title: ZDocumentTitleSchema,
documentDataId: z.string(),
recipients: z.array(
z.object({
email: z.string().email(),
name: z.string().optional(),
role: z.nativeEnum(RecipientRole).optional(),
signingOrder: z.number().optional(),
fields: z.array(ZFieldSchema).optional(),
}),
),
meta: z
.object({
subject: ZDocumentMetaSubjectSchema.optional(),
message: ZDocumentMetaMessageSchema.optional(),
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
});
export const ZCreateEmbeddingTemplateResponseSchema = z.object({
templateId: z.number(),
});
export type TCreateEmbeddingTemplateRequestSchema = z.infer<
typeof ZCreateEmbeddingTemplateRequestSchema
>;

View File

@ -0,0 +1,63 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { prisma } from '@documenso/prisma';
import { procedure } from '../trpc';
import {
ZGetEmbeddingDocumentRequestSchema,
ZGetEmbeddingDocumentResponseSchema,
} from './get-embedding-document.types';
export const getEmbeddingDocumentRoute = procedure
.input(ZGetEmbeddingDocumentRequestSchema)
.output(ZGetEmbeddingDocumentResponseSchema)
.query(async ({ input, ctx: { req } }) => {
try {
const authorizationHeader = req.headers.get('authorization');
const [presignToken] = (authorizationHeader || '')
.split('Bearer ')
.filter((s) => s.length > 0);
if (!presignToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'No presign token provided',
});
}
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
const { documentId } = input;
const document = await prisma.document.findFirst({
where: {
id: documentId,
userId: apiToken.userId,
...(apiToken.teamId ? { teamId: apiToken.teamId } : {}),
},
include: {
documentData: true,
recipients: true,
fields: true,
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
return {
document,
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to get document',
});
}
});

View File

@ -0,0 +1,34 @@
import { DocumentDataType, type Field, type Recipient } from '@prisma/client';
import { z } from 'zod';
export const ZGetEmbeddingDocumentRequestSchema = z.object({
documentId: z.number(),
});
export const ZGetEmbeddingDocumentResponseSchema = z.object({
document: z
.object({
id: z.number(),
title: z.string(),
status: z.string(),
documentDataId: z.string(),
userId: z.number(),
teamId: z.number().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
documentData: z.object({
id: z.string(),
type: z.nativeEnum(DocumentDataType),
data: z.string(),
initialData: z.string(),
}),
recipients: z.array(z.custom<Recipient>()),
fields: z.array(z.custom<Field>()),
})
.nullable(),
});
export type TGetEmbeddingDocumentRequestSchema = z.infer<typeof ZGetEmbeddingDocumentRequestSchema>;
export type TGetEmbeddingDocumentResponseSchema = z.infer<
typeof ZGetEmbeddingDocumentResponseSchema
>;

View File

@ -0,0 +1,36 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { procedure } from '../trpc';
import {
ZVerifyEmbeddingPresignTokenRequestSchema,
ZVerifyEmbeddingPresignTokenResponseSchema,
verifyEmbeddingPresignTokenMeta,
} from './verify-embedding-presign-token.types';
/**
* Public route.
*/
export const verifyEmbeddingPresignTokenRoute = procedure
.meta(verifyEmbeddingPresignTokenMeta)
.input(ZVerifyEmbeddingPresignTokenRequestSchema)
.output(ZVerifyEmbeddingPresignTokenResponseSchema)
.mutation(async ({ input }) => {
try {
const { token } = input;
const apiToken = await verifyEmbeddingPresignToken({
token,
}).catch(() => null);
return { success: !!apiToken };
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to verify embedding presign token',
});
}
});

View File

@ -0,0 +1,33 @@
import { z } from 'zod';
import type { TrpcRouteMeta } from '../trpc';
export const verifyEmbeddingPresignTokenMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/embedding/verify-presign-token',
summary: 'Verify embedding presign token',
description:
'Verifies a presign token for embedding operations and returns the associated API token',
tags: ['Embedding'],
},
};
export const ZVerifyEmbeddingPresignTokenRequestSchema = z.object({
token: z
.string()
.min(1, { message: 'Token is required' })
.describe('The presign token to verify'),
});
export const ZVerifyEmbeddingPresignTokenResponseSchema = z.object({
success: z.boolean(),
});
export type TVerifyEmbeddingPresignTokenRequestSchema = z.infer<
typeof ZVerifyEmbeddingPresignTokenRequestSchema
>;
export type TVerifyEmbeddingPresignTokenResponseSchema = z.infer<
typeof ZVerifyEmbeddingPresignTokenResponseSchema
>;

View File

@ -2,6 +2,7 @@ import { adminRouter } from './admin-router/router';
import { apiTokenRouter } from './api-token-router/router';
import { authRouter } from './auth-router/router';
import { documentRouter } from './document-router/router';
import { embeddingPresignRouter } from './embedding-router/_router';
import { fieldRouter } from './field-router/router';
import { profileRouter } from './profile-router/router';
import { recipientRouter } from './recipient-router/router';
@ -23,6 +24,7 @@ export const appRouter = router({
team: teamRouter,
template: templateRouter,
webhook: webhookRouter,
embeddingPresign: embeddingPresignRouter,
});
export type AppRouter = typeof appRouter;

View File

@ -8,14 +8,11 @@ import type { Field, Recipient } from '@prisma/client';
import { FieldType, RecipientRole, SendStatus } from '@prisma/client';
import {
CalendarDays,
Check,
CheckSquare,
ChevronDown,
ChevronsUpDown,
Contact,
Disc,
Hash,
Info,
Mail,
Type,
User,
@ -27,7 +24,6 @@ import { prop, sortBy } from 'remeda';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import {
type TFieldMetaSchema as FieldMeta,
ZFieldMetaSchema,
@ -42,16 +38,13 @@ import {
} from '@documenso/lib/utils/recipients';
import { FieldToolTip } from '../../components/field/field-tooltip';
import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors';
import { useSignerColors } from '../../lib/signer-colors';
import { cn } from '../../lib/utils';
import { Alert, AlertDescription } from '../alert';
import { Button } from '../button';
import { Card, CardContent } from '../card';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../command';
import { Form } from '../form/form';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { RecipientSelector } from '../recipient-selector';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import { useToast } from '../use-toast';
import type { TAddFieldsFormSchema } from './add-fields.types';
import {
@ -663,123 +656,12 @@ export const AddFieldsFormPartial = ({
})}
{!hideRecipients && (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground mb-12 mt-2 justify-between font-normal',
selectedSignerStyles.default.base,
)}
>
{selectedSigner?.email && (
<span className="flex-1 truncate text-left">
{selectedSigner?.name} ({selectedSigner?.email})
</span>
)}
{!selectedSigner?.email && (
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command value={selectedSigner?.email}>
<CommandInput />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
<Trans>No recipient matching this description was found.</Trans>
</span>
</CommandEmpty>
{recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{_(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
</div>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
>
<Trans>No recipients with this role</Trans>
</div>
)}
{roleRecipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn(
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
getSignerColorStyles(
Math.max(
recipients.findIndex((r) => r.id === recipient.id),
0,
),
).default.comboxBoxItem,
{
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
},
)}
onSelect={() => {
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
>
<span
className={cn('text-foreground/70 truncate', {
'text-foreground/80': recipient === selectedSigner,
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && (
<span title={recipient.email}>{recipient.email}</span>
)}
</span>
<div className="ml-auto flex items-center justify-center">
{recipient.sendStatus !== SendStatus.SENT ? (
<Check
aria-hidden={recipient !== selectedSigner}
className={cn('h-4 w-4 flex-shrink-0', {
'opacity-0': recipient !== selectedSigner,
'opacity-100': recipient === selectedSigner,
})}
/>
) : (
<Tooltip>
<TooltipTrigger>
<Info className="ml-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
This document has already been sent to this recipient. You
can no longer edit this recipient.
</Trans>
</TooltipContent>
</Tooltip>
)}
</div>
</CommandItem>
))}
</CommandGroup>
))}
</Command>
</PopoverContent>
</Popover>
<RecipientSelector
selectedRecipient={selectedSigner}
onSelectedRecipientChange={setSelectedSigner}
recipients={recipients}
className="mb-12 mt-2"
/>
)}
<Form {...form}>

View File

@ -0,0 +1,128 @@
import { FieldType } from '@prisma/client';
import {
CalendarDays,
CheckSquare,
ChevronDown,
Contact,
Disc,
Hash,
Mail,
Type,
User,
} from 'lucide-react';
import { cn } from '../lib/utils';
import { Card, CardContent } from './card';
export interface FieldSelectorProps {
className?: string;
selectedField: FieldType | null;
onSelectedFieldChange: (fieldType: FieldType) => void;
disabled?: boolean;
}
export const FieldSelector = ({
className,
selectedField,
onSelectedFieldChange,
disabled = false,
}: FieldSelectorProps) => {
const fieldTypes = [
{
type: FieldType.SIGNATURE,
label: 'Signature',
icon: null,
},
{
type: FieldType.INITIALS,
label: 'Initials',
icon: Contact,
},
{
type: FieldType.EMAIL,
label: 'Email',
icon: Mail,
},
{
type: FieldType.NAME,
label: 'Name',
icon: User,
},
{
type: FieldType.DATE,
label: 'Date',
icon: CalendarDays,
},
{
type: FieldType.TEXT,
label: 'Text',
icon: Type,
},
{
type: FieldType.NUMBER,
label: 'Number',
icon: Hash,
},
{
type: FieldType.RADIO,
label: 'Radio',
icon: Disc,
},
{
type: FieldType.CHECKBOX,
label: 'Checkbox',
icon: CheckSquare,
},
{
type: FieldType.DROPDOWN,
label: 'Dropdown',
icon: ChevronDown,
},
];
return (
<div className="grid grid-cols-2 gap-2">
{fieldTypes.map((field) => {
const Icon = field.icon;
return (
<button
key={field.type}
type="button"
className="group w-full"
onPointerDown={() => onSelectedFieldChange(field.type)}
disabled={disabled}
data-selected={selectedField === field.type ? true : undefined}
>
<Card
className={cn(
'flex w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
{
'border-primary': selectedField === field.type,
},
)}
>
<CardContent className="relative flex items-center justify-center gap-x-2 px-6 py-4">
{Icon && <Icon className="text-muted-foreground h-4 w-4" />}
<span
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-sm',
field.type === FieldType.SIGNATURE && 'invisible',
)}
>
{field.label}
</span>
{field.type === FieldType.SIGNATURE && (
<div className="text-muted-foreground font-signature absolute inset-0 flex items-center justify-center text-lg">
Signature
</div>
)}
</CardContent>
</Card>
</button>
);
})}
</div>
);
};

View File

@ -0,0 +1,195 @@
import { useCallback, useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { RecipientRole, SendStatus } from '@prisma/client';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { sortBy } from 'remeda';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { getSignerColorStyles } from '../lib/signer-colors';
import { cn } from '../lib/utils';
import { Button } from './button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from './command';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
export interface RecipientSelectorProps {
className?: string;
selectedRecipient: Recipient | null;
onSelectedRecipientChange: (recipient: Recipient) => void;
recipients: Recipient[];
}
export const RecipientSelector = ({
className,
selectedRecipient,
onSelectedRecipientChange,
recipients,
}: RecipientSelectorProps) => {
const { _ } = useLingui();
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const recipientsByRole = useCallback(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
CC: [],
VIEWER: [],
SIGNER: [],
APPROVER: [],
ASSISTANT: [],
};
recipients.forEach((recipient) => {
recipientsByRole[recipient.role].push(recipient);
});
return recipientsByRole;
}, [recipients]);
const recipientsByRoleToDisplay = useCallback(() => {
return Object.entries(recipientsByRole())
.filter(
([role]) =>
role !== RecipientRole.CC &&
role !== RecipientRole.VIEWER &&
role !== RecipientRole.ASSISTANT,
)
.map(
([role, roleRecipients]) =>
[
role,
sortBy(
roleRecipients,
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[(r) => r.id, 'asc'],
),
] as [RecipientRole, Recipient[]],
);
}, [recipientsByRole]);
return (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
getSignerColorStyles(
Math.max(
recipients.findIndex((r) => r.id === selectedRecipient?.id),
0,
),
).default.base,
className,
)}
>
{selectedRecipient?.email && (
<span className="flex-1 truncate text-left">
{selectedRecipient?.name} ({selectedRecipient?.email})
</span>
)}
{!selectedRecipient?.email && (
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command value={selectedRecipient?.email}>
<CommandInput />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
<Trans>No recipient matching this description was found.</Trans>
</span>
</CommandEmpty>
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{_(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
</div>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
>
<Trans>No recipients with this role</Trans>
</div>
)}
{roleRecipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn(
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
getSignerColorStyles(
Math.max(
recipients.findIndex((r) => r.id === recipient.id),
0,
),
).default.comboxBoxItem,
{
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
},
)}
onSelect={() => {
onSelectedRecipientChange(recipient);
setShowRecipientsSelector(false);
}}
>
<span
className={cn('text-foreground/70 truncate', {
'text-foreground/80': recipient === selectedRecipient,
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
</span>
<div className="ml-auto flex items-center justify-center">
{recipient.sendStatus !== SendStatus.SENT ? (
<Check
aria-hidden={recipient !== selectedRecipient}
className={cn('h-4 w-4 flex-shrink-0', {
'opacity-0': recipient !== selectedRecipient,
'opacity-100': recipient === selectedRecipient,
})}
/>
) : (
<Tooltip>
<TooltipTrigger>
<Info className="ml-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
This document has already been sent to this recipient. You can no longer
edit this recipient.
</Trans>
</TooltipContent>
</Tooltip>
)}
</div>
</CommandItem>
))}
</CommandGroup>
))}
</Command>
</PopoverContent>
</Popover>
);
};