feat: add create document beta endpoint (#1584)

This commit is contained in:
David Nguyen
2025-01-16 13:36:00 +11:00
committed by GitHub
parent 5750f2b477
commit 9e03747e43
47 changed files with 2624 additions and 1934 deletions

View File

@ -8,7 +8,7 @@ jobs:
e2e_tests:
name: 'E2E Tests'
timeout-minutes: 60
runs-on: ubuntu-22.04
runs-on: warp-ubuntu-2204-x64-16x
steps:
- uses: actions/checkout@v4

View File

@ -12,7 +12,6 @@ import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -50,17 +49,13 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
const onDownloadClick = async () => {
try {
let document: DocumentWithData | null = null;
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
documentId: row.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
}
const document = !recipient
? await trpcClient.document.getDocumentById.query({
documentId: row.id,
})
: await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
const documentData = document?.documentData;

View File

@ -25,7 +25,6 @@ import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import {
@ -81,17 +80,13 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
const onDownloadClick = async () => {
try {
let document: DocumentWithData | null = null;
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
documentId: row.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
}
const document = !recipient
? await trpcClient.document.getDocumentById.query({
documentId: row.id,
})
: await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
const documentData = document?.documentData;

View File

@ -5,7 +5,6 @@ import { useEffect, useMemo, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import type {
Team,
TeamProfile,
@ -15,6 +14,7 @@ import type {
} from '@documenso/prisma/client';
import { TemplateType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import type { FindTemplateRow } from '@documenso/trpc/server/template-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Switch } from '@documenso/ui/primitives/switch';

View File

@ -7,11 +7,11 @@ import { useLingui } from '@lingui/react';
import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import type { TemplateDirectLink } from '@documenso/prisma/client';
import { TemplateType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import type { FindTemplateRow } from '@documenso/trpc/server/template-router/schema';
import {
DropdownMenu,
DropdownMenuContent,

View File

@ -12,7 +12,7 @@ import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import type { TTemplate } from '@documenso/lib/types/template';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -32,7 +32,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
export type EditTemplateFormProps = {
className?: string;
initialTemplate: TemplateWithDetails;
initialTemplate: TTemplate;
isEnterprise: boolean;
templateRootPath: string;
};

View File

@ -10,7 +10,7 @@ import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import type { FindTemplateRow } from '@documenso/trpc/server/template-router/schema';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';

View File

@ -7,9 +7,9 @@ import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { TTemplate } from '@documenso/lib/types/template';
import type { Recipient } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
@ -41,7 +41,7 @@ export type TConfigureDirectTemplateFormSchema = z.infer<typeof ZConfigureDirect
export type ConfigureDirectTemplateFormProps = {
flowStep: DocumentFlowStep;
isDocumentPdfLoaded: boolean;
template: Omit<TemplateWithDetails, 'user'>;
template: Omit<TTemplate, 'user'>;
directTemplateRecipient: Recipient & { fields: Field[] };
initialEmail?: string;
onSubmit: (_data: TConfigureDirectTemplateFormSchema) => void;

View File

@ -8,9 +8,9 @@ import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TTemplate } from '@documenso/lib/types/template';
import type { Field } from '@documenso/prisma/client';
import { type Recipient } from '@documenso/prisma/client';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
@ -28,7 +28,7 @@ import type { DirectTemplateLocalField } from './sign-direct-template';
import { SignDirectTemplateForm } from './sign-direct-template';
export type TemplatesDirectPageViewProps = {
template: Omit<TemplateWithDetails, 'user'>;
template: Omit<TTemplate, 'user'>;
directTemplateToken: string;
directTemplateRecipient: Recipient & { fields: Field[] };
};

View File

@ -14,10 +14,10 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { TTemplate } from '@documenso/lib/types/template';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Field, Recipient, Signature } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
@ -55,7 +55,7 @@ export type SignDirectTemplateFormProps = {
flowStep: DocumentFlowStep;
directRecipient: Recipient;
directRecipientFields: Field[];
template: Omit<TemplateWithDetails, 'user'>;
template: Omit<TTemplate, 'user'>;
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
};

View File

@ -151,6 +151,7 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();

View File

@ -41,7 +41,10 @@ export default defineConfig({
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1080 },
},
},
// {

View File

@ -4,6 +4,19 @@ import { DEFAULT_DOCUMENT_TIME_ZONE } from './time-zones';
export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a';
export const VALID_DATE_FORMAT_VALUES = [
DEFAULT_DOCUMENT_DATE_FORMAT,
'yyyy-MM-dd',
'dd/MM/yyyy hh:mm a',
'MM/dd/yyyy hh:mm a',
'yyyy-MM-dd HH:mm',
'yy-MM-dd hh:mm a',
'yyyy-MM-dd HH:mm:ss',
'MMMM dd, yyyy hh:mm a',
'EEEE, MMMM dd, yyyy hh:mm a',
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
] as const;
export const DATE_FORMATS = [
{
key: 'yyyy-MM-dd_hh:mm_a',
@ -55,7 +68,11 @@ export const DATE_FORMATS = [
label: 'ISO 8601',
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
},
];
] satisfies {
key: string;
label: string;
value: (typeof VALID_DATE_FORMAT_VALUES)[number];
}[];
export const convertToLocalSystemFormat = (
customText: string,

View File

@ -0,0 +1,248 @@
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { DocumentVisibility, TemplateMeta } from '@documenso/prisma/client';
import {
DocumentSource,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import type { TCreateDocumentV2Request } from '@documenso/trpc/server/document-router/schema';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
userId: number;
teamId?: number;
documentDataId: string;
normalizePdf?: boolean;
data: {
title: string;
externalId?: string;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes;
globalActionAuth?: TDocumentActionAuthTypes;
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata: ApiRequestMetadata;
};
export const createDocumentV2 = async ({
userId,
teamId,
documentDataId,
normalizePdf,
data,
meta,
requestMetadata,
}: CreateDocumentOptions) => {
const { title, formValues } = data;
const team = teamId
? await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
teamGlobalSettings: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
})
: null;
if (teamId !== undefined && !team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
id: documentDataId,
},
});
if (documentData) {
const buffer = await getFile(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const newDocumentData = await putPdfFile({
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),
});
// eslint-disable-next-line require-atomic-updates
documentDataId = newDocumentData.id;
}
}
const authOptions = createDocumentAuthOptions({
globalAccessAuth: data?.globalAccessAuth || null,
globalActionAuth: data?.globalActionAuth || null,
});
const recipientsHaveActionAuth = data.recipients?.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (authOptions.globalActionAuth || recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const visibility = determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
team?.members[0].role ?? TeamMemberRole.MEMBER,
);
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
title,
externalId: data.externalId,
documentDataId,
userId,
teamId,
authOptions,
visibility,
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: {
...meta,
signingOrder: meta?.signingOrder || undefined,
emailSettings: meta?.emailSettings || undefined,
language: meta?.language || team?.teamGlobalSettings?.documentLanguage,
typedSignatureEnabled:
meta?.typedSignatureEnabled ?? team?.teamGlobalSettings?.typedSignatureEnabled,
},
},
},
});
await Promise.all(
(data.recipients || []).map(async (recipient) => {
const recipientAuthOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null,
actionAuth: recipient.actionAuth || null,
});
await tx.recipient.create({
data: {
documentId: document.id,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions: recipientAuthOptions,
fields: {
createMany: {
data: (recipient.fields || []).map((field) => ({
documentId: document.id,
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
})),
},
},
},
});
}),
);
// Todo: Is it necessary to create a full audit log with all fields and recipients audit logs?
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
metadata: requestMetadata,
data: {
title,
source: {
type: DocumentSource.DOCUMENT,
},
},
}),
});
const createdDocument = await tx.document.findFirst({
where: {
id: document.id,
},
include: {
documentData: true,
documentMeta: true,
recipients: true,
fields: true,
},
});
if (!createdDocument) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
userId,
teamId,
});
return createdDocument;
});
};

View File

@ -6,7 +6,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentSource, DocumentVisibility, WebhookTriggerEvents } from '@documenso/prisma/client';
import { DocumentSource, WebhookTriggerEvents } from '@documenso/prisma/client';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
@ -16,6 +16,7 @@ import {
} from '../../types/webhook-payload';
import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
@ -88,25 +89,6 @@ export const createDocument = async ({
userTeamRole = teamWithUserRole.members[0]?.role;
}
const determineVisibility = (
globalVisibility: DocumentVisibility | null | undefined,
userRole: TeamMemberRole,
): DocumentVisibility => {
if (globalVisibility) {
return globalVisibility;
}
if (userRole === TeamMemberRole.ADMIN) {
return DocumentVisibility.ADMIN;
}
if (userRole === TeamMemberRole.MANAGER) {
return DocumentVisibility.MANAGER_AND_ABOVE;
}
return DocumentVisibility.EVERYONE;
};
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
@ -138,7 +120,7 @@ export const createDocument = async ({
documentDataId,
userId,
teamId,
visibility: determineVisibility(
visibility: determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),

View File

@ -1,9 +1,8 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
@ -12,16 +11,14 @@ export interface CreateDocumentFieldsOptions {
userId: number;
teamId?: number;
documentId: number;
fields: {
fields: (TFieldAndMeta & {
recipientId: number;
type: FieldType;
pageNumber: number;
pageX: number;
pageY: number;
width: number;
height: number;
fieldMeta?: TFieldMetaSchema;
}[];
})[];
requestMetadata: ApiRequestMetadata;
}

View File

@ -1,7 +1,6 @@
'use server';
import { nanoid } from 'nanoid';
import type { z } from 'zod';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
@ -9,7 +8,6 @@ import {
} from '@documenso/lib/constants/direct-templates';
import { prisma } from '@documenso/prisma';
import type { Recipient } from '@documenso/prisma/client';
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
@ -20,18 +18,12 @@ export type CreateTemplateDirectLinkOptions = {
directRecipientId?: number;
};
export const ZCreateTemplateDirectLinkResponseSchema = TemplateDirectLinkSchema;
export type TCreateTemplateDirectLinkResponse = z.infer<
typeof ZCreateTemplateDirectLinkResponseSchema
>;
export const createTemplateDirectLink = async ({
templateId,
userId,
teamId,
directRecipientId,
}: CreateTemplateDirectLinkOptions): Promise<TCreateTemplateDirectLinkResponse> => {
}: CreateTemplateDirectLinkOptions) => {
const template = await prisma.template.findFirst({
where: {
id: templateId,

View File

@ -1,10 +1,8 @@
import { omit } from 'remeda';
import type { z } from 'zod';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import { TemplateSchema } from '@documenso/prisma/generated/zod';
import type { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & {
@ -12,15 +10,11 @@ export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & {
teamId?: number;
};
export const ZDuplicateTemplateResponseSchema = TemplateSchema;
export type TDuplicateTemplateResponse = z.infer<typeof ZDuplicateTemplateResponseSchema>;
export const duplicateTemplate = async ({
templateId,
userId,
teamId,
}: DuplicateTemplateOptions): Promise<TDuplicateTemplateResponse> => {
}: DuplicateTemplateOptions) => {
const template = await prisma.template.findUnique({
where: {
id: templateId,

View File

@ -1,5 +1,4 @@
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import {
@ -8,18 +7,9 @@ import {
TeamMemberRole,
type Template,
} from '@documenso/prisma/client';
import {
DocumentDataSchema,
FieldSchema,
RecipientSchema,
TeamSchema,
TemplateDirectLinkSchema,
TemplateMetaSchema,
TemplateSchema,
} from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type FindResultResponse, ZFindResultResponse } from '../../types/search-params';
import { type FindResultResponse } from '../../types/search-params';
export type FindTemplatesOptions = {
userId: number;
@ -29,36 +19,13 @@ export type FindTemplatesOptions = {
perPage?: number;
};
export const ZFindTemplatesResponseSchema = ZFindResultResponse.extend({
data: TemplateSchema.extend({
templateDocumentData: DocumentDataSchema,
team: TeamSchema.pick({
id: true,
url: true,
}).nullable(),
fields: FieldSchema.array(),
recipients: RecipientSchema.array(),
templateMeta: TemplateMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
}).nullable(),
directLink: TemplateDirectLinkSchema.pick({
token: true,
enabled: true,
}).nullable(),
}).array(), // Todo: openapi.
});
export type TFindTemplatesResponse = z.infer<typeof ZFindTemplatesResponseSchema>;
export type FindTemplateRow = TFindTemplatesResponse['data'][number];
export const findTemplates = async ({
userId,
teamId,
type,
page = 1,
perPage = 10,
}: FindTemplatesOptions): Promise<TFindTemplatesResponse> => {
}: FindTemplatesOptions) => {
const whereFilter: Prisma.TemplateWhereInput[] = [];
if (teamId === undefined) {
@ -112,7 +79,6 @@ export const findTemplates = async ({
AND: whereFilter,
},
include: {
templateDocumentData: true,
team: {
select: {
id: true,

View File

@ -1,15 +1,4 @@
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import {
DocumentDataSchema,
FieldSchema,
RecipientSchema,
TemplateDirectLinkSchema,
TemplateMetaSchema,
TemplateSchema,
UserSchema,
} from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
@ -19,26 +8,7 @@ export type GetTemplateByIdOptions = {
teamId?: number;
};
export const ZGetTemplateByIdResponseSchema = TemplateSchema.extend({
directLink: TemplateDirectLinkSchema.nullable(),
templateDocumentData: DocumentDataSchema,
templateMeta: TemplateMetaSchema.nullable(),
recipients: RecipientSchema.array(),
fields: FieldSchema.array(),
user: UserSchema.pick({
id: true,
name: true,
email: true,
}),
});
export type TGetTemplateByIdResponse = z.infer<typeof ZGetTemplateByIdResponseSchema>;
export const getTemplateById = async ({
id,
userId,
teamId,
}: GetTemplateByIdOptions): Promise<TGetTemplateByIdResponse> => {
export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOptions) => {
const template = await prisma.template.findFirst({
where: {
id,

View File

@ -1,7 +1,4 @@
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TemplateSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
@ -11,15 +8,11 @@ export type MoveTemplateToTeamOptions = {
userId: number;
};
export const ZMoveTemplateToTeamResponseSchema = TemplateSchema;
export type TMoveTemplateToTeamResponse = z.infer<typeof ZMoveTemplateToTeamResponseSchema>;
export const moveTemplateToTeam = async ({
templateId,
teamId,
userId,
}: MoveTemplateToTeamOptions): Promise<TMoveTemplateToTeamResponse> => {
}: MoveTemplateToTeamOptions) => {
return await prisma.$transaction(async (tx) => {
const template = await tx.template.findFirst({
where: {

View File

@ -1,9 +1,6 @@
'use server';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
@ -14,18 +11,12 @@ export type ToggleTemplateDirectLinkOptions = {
enabled: boolean;
};
export const ZToggleTemplateDirectLinkResponseSchema = TemplateDirectLinkSchema;
export type TToggleTemplateDirectLinkResponse = z.infer<
typeof ZToggleTemplateDirectLinkResponseSchema
>;
export const toggleTemplateDirectLink = async ({
templateId,
userId,
teamId,
enabled,
}: ToggleTemplateDirectLinkOptions): Promise<TToggleTemplateDirectLinkResponse> => {
}: ToggleTemplateDirectLinkOptions) => {
const template = await prisma.template.findFirst({
where: {
id: templateId,

View File

@ -1,11 +1,8 @@
'use server';
import type { z } from 'zod';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { prisma } from '@documenso/prisma';
import type { DocumentVisibility, Template, TemplateMeta } from '@documenso/prisma/client';
import { TemplateSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
@ -28,17 +25,13 @@ export type UpdateTemplateOptions = {
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
};
export const ZUpdateTemplateResponseSchema = TemplateSchema;
export type TUpdateTemplateResponse = z.infer<typeof ZUpdateTemplateResponseSchema>;
export const updateTemplate = async ({
userId,
teamId,
templateId,
meta = {},
data = {},
}: UpdateTemplateOptions): Promise<TUpdateTemplateResponse> => {
}: UpdateTemplateOptions) => {
const template = await prisma.template.findFirstOrThrow({
where: {
id: templateId,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
export const ZBaseFieldMeta = z.object({
label: z.string().optional(),
placeholder: z.string().optional(),
@ -114,3 +116,53 @@ export type TFieldMetaNotOptionalSchema = z.infer<typeof ZFieldMetaNotOptionalSc
export const ZFieldMetaSchema = ZFieldMetaNotOptionalSchema.optional();
export type TFieldMetaSchema = z.infer<typeof ZFieldMetaSchema>;
export const ZFieldAndMetaSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal(FieldType.SIGNATURE),
// Do not use z.undefined(), or void since this will create an invalid schema for Speakeasy SDK generation.
fieldMeta: z.literal(undefined),
}),
z.object({
type: z.literal(FieldType.FREE_SIGNATURE),
fieldMeta: z.literal(undefined), // Same as above.
}),
z.object({
type: z.literal(FieldType.INITIALS),
fieldMeta: ZInitialsFieldMeta.optional(),
}),
z.object({
type: z.literal(FieldType.NAME),
fieldMeta: ZNameFieldMeta.optional(),
}),
z.object({
type: z.literal(FieldType.EMAIL),
fieldMeta: ZEmailFieldMeta.optional(),
}),
z.object({
type: z.literal(FieldType.DATE),
fieldMeta: ZDateFieldMeta.optional(),
}),
z.object({
type: z.literal(FieldType.TEXT),
fieldMeta: ZTextFieldMeta.optional(),
}),
z.object({
type: z.literal(FieldType.NUMBER),
fieldMeta: ZNumberFieldMeta.optional(),
}),
z.object({
type: z.literal(FieldType.RADIO),
fieldMeta: ZRadioFieldMeta.optional(),
}),
z.object({
type: z.literal(FieldType.CHECKBOX),
fieldMeta: ZCheckboxFieldMeta.optional(),
}),
z.object({
type: z.literal(FieldType.DROPDOWN),
fieldMeta: ZDropdownFieldMeta.optional(),
}),
]);
export type TFieldAndMeta = z.infer<typeof ZFieldAndMetaSchema>;

View File

@ -1,3 +1,5 @@
import { z } from 'zod';
import { FieldSchema } from '@documenso/prisma/generated/zod';
/**
@ -28,3 +30,22 @@ export const ZFieldSchema = FieldSchema.pick({
inserted: true,
fieldMeta: true,
});
export const ZFieldPageNumberSchema = z
.number()
.min(1)
.describe('The page number the field will be on.');
export const ZFieldPageXSchema = z
.number()
.min(0)
.describe('The X coordinate of where the field will be placed.');
export const ZFieldPageYSchema = z
.number()
.min(0)
.describe('The Y coordinate of where the field will be placed.');
export const ZFieldWidthSchema = z.number().min(1).describe('The width of the field.');
export const ZFieldHeightSchema = z.number().min(1).describe('The height of the field.');

View File

@ -0,0 +1,119 @@
import type { z } from 'zod';
import {
DocumentDataSchema,
TeamSchema,
TemplateDirectLinkSchema,
TemplateMetaSchema,
TemplateSchema,
UserSchema,
} from '@documenso/prisma/generated/zod';
import { ZFieldSchema } from './field';
import { ZRecipientLiteSchema } from './recipient';
/**
* The full template response schema.
*
* Mainly used for returning a single template from the API.
*/
export const ZTemplateSchema = TemplateSchema.pick({
type: true,
visibility: true,
id: true,
externalId: true,
title: true,
userId: true,
teamId: true,
authOptions: true,
templateDocumentDataId: true,
createdAt: true,
updatedAt: true,
publicTitle: true,
publicDescription: true,
}).extend({
// Todo: Maybe we want to alter this a bit since this returns a lot of data.
templateDocumentData: DocumentDataSchema.pick({
type: true,
id: true,
data: true,
initialData: true,
}),
templateMeta: TemplateMetaSchema.pick({
id: true,
subject: true,
message: true,
timezone: true,
dateFormat: true,
signingOrder: true,
typedSignatureEnabled: true,
distributionMethod: true,
templateId: true,
redirectUrl: true,
language: true,
emailSettings: true,
}).nullable(),
directLink: TemplateDirectLinkSchema.nullable(),
user: UserSchema.pick({
id: true,
name: true,
email: true,
}),
recipients: ZRecipientLiteSchema.array(),
fields: ZFieldSchema.array(),
});
export type TTemplate = z.infer<typeof ZTemplateSchema>;
/**
* A lite version of the template response schema without relations.
*/
export const ZTemplateLiteSchema = TemplateSchema.pick({
type: true,
visibility: true,
id: true,
externalId: true,
title: true,
userId: true,
teamId: true,
authOptions: true,
templateDocumentDataId: true,
createdAt: true,
updatedAt: true,
publicTitle: true,
publicDescription: true,
});
/**
* A version of the template response schema when returning multiple template at once from a single API endpoint.
*/
export const ZTemplateManySchema = TemplateSchema.pick({
type: true,
visibility: true,
id: true,
externalId: true,
title: true,
userId: true,
teamId: true,
authOptions: true,
templateDocumentDataId: true,
createdAt: true,
updatedAt: true,
publicTitle: true,
publicDescription: true,
}).extend({
team: TeamSchema.pick({
id: true,
url: true,
}).nullable(),
fields: ZFieldSchema.array(),
recipients: ZRecipientLiteSchema.array(),
templateMeta: TemplateMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
}).nullable(),
directLink: TemplateDirectLinkSchema.pick({
token: true,
enabled: true,
}).nullable(),
});

View File

@ -0,0 +1,20 @@
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
export const determineDocumentVisibility = (
globalVisibility: DocumentVisibility | null | undefined,
userRole: TeamMemberRole,
): DocumentVisibility => {
if (globalVisibility) {
return globalVisibility;
}
if (userRole === TeamMemberRole.ADMIN) {
return DocumentVisibility.ADMIN;
}
if (userRole === TeamMemberRole.MANAGER) {
return DocumentVisibility.MANAGER_AND_ABOVE;
}
return DocumentVisibility.EVERYONE;
};

View File

@ -1,6 +0,0 @@
import type { Document, DocumentData, DocumentMeta } from '@documenso/prisma/client';
export type DocumentWithData = Document & {
documentData?: DocumentData | null;
documentMeta?: DocumentMeta | null;
};

View File

@ -1,9 +0,0 @@
import type { TGetTemplateByIdResponse } from '@documenso/lib/server-only/template/get-template-by-id';
import type { DocumentData, Template, TemplateMeta } from '@documenso/prisma/client';
export type TemplateWithData = Template & {
templateDocumentData?: DocumentData | null;
templateMeta?: TemplateMeta | null;
};
export type TemplateWithDetails = TGetTemplateByIdResponse;

View File

@ -7,8 +7,10 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { AppError } from '@documenso/lib/errors/app-error';
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { createDocumentV2 } from '@documenso/lib/server-only/document/create-document-v2';
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { duplicateDocument } from '@documenso/lib/server-only/document/duplicate-document-by-id';
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
@ -22,11 +24,14 @@ import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { DocumentStatus } from '@documenso/prisma/client';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { DocumentDataType, DocumentStatus } from '@documenso/prisma/client';
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
ZCreateDocumentRequestSchema,
ZCreateDocumentV2RequestSchema,
ZCreateDocumentV2ResponseSchema,
ZDeleteDocumentMutationSchema,
ZDistributeDocumentRequestSchema,
ZDistributeDocumentResponseSchema,
@ -146,6 +151,79 @@ export const documentRouter = router({
});
}),
/**
* Temporariy endpoint for V2 Beta until we allow passthrough documents on create.
*
* @public
* @deprecated
*/
createDocumentTemporary: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/create/beta',
summary: 'Create document',
description:
'You will need to upload the PDF to the provided URL returned. Note: Once V2 API is released, this will be removed since we will allow direct uploads, instead of using an upload URL.',
tags: ['Document'],
},
})
.input(ZCreateDocumentV2RequestSchema)
.output(ZCreateDocumentV2ResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const {
title,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
recipients,
meta,
} = input;
const { remaining } = await getServerLimits({ email: ctx.user.email, teamId });
if (remaining.documents <= 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'You have reached your document limit for this month. Please upgrade your plan.',
});
}
const fileName = title.endsWith('.pdf') ? title : `${title}.pdf`;
const { url, key } = await getPresignPostUrl(fileName, 'application/pdf');
const documentData = await createDocumentData({
data: key,
type: DocumentDataType.S3_PATH,
});
const createdDocument = await createDocumentV2({
userId: ctx.user.id,
teamId,
documentDataId: documentData.id,
normalizePdf: false, // Not normalizing because of presigned URL.
data: {
title,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
recipients,
},
meta,
requestMetadata: ctx.metadata,
});
return {
document: createdDocument,
uploadUrl: url,
};
}),
/**
* Wait until RR7 so we can passthrough documents.
*
@ -220,6 +298,7 @@ export const documentRouter = router({
typedSignatureEnabled: meta.typedSignatureEnabled,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
signingOrder: meta.signingOrder,
emailSettings: meta.emailSettings,
requestMetadata: ctx.metadata,
});
@ -240,8 +319,8 @@ export const documentRouter = router({
deleteDocument: authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/document/{documentId}',
method: 'POST',
path: '/document/delete',
summary: 'Delete document',
tags: ['Document'],
},
@ -320,6 +399,8 @@ export const documentRouter = router({
/**
* @private
*
* Todo: Remove and use `updateDocument` endpoint instead.
*/
setSigningOrderForDocument: authenticatedProcedure
.input(ZSetSigningOrderForDocumentMutationSchema)

View File

@ -1,5 +1,6 @@
import { z } from 'zod';
import { VALID_DATE_FORMAT_VALUES } from '@documenso/lib/constants/date-formats';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import {
ZDocumentLiteSchema,
@ -11,6 +12,15 @@ import {
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
import {
ZFieldHeightSchema,
ZFieldPageNumberSchema,
ZFieldPageXSchema,
ZFieldPageYSchema,
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import {
@ -22,14 +32,42 @@ import {
FieldType,
} from '@documenso/prisma/client';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
export const ZDocumentTitleSchema = z
.string()
.trim()
.min(1)
.max(255)
.describe('The title of the document.');
export const ZDocumentExternalIdSchema = z
.string()
.trim()
.describe('The external ID of the document.');
export const ZDocumentVisibilitySchema = z
.nativeEnum(DocumentVisibility)
.describe('The visibility of the document.');
export const ZDocumentMetaTimezoneSchema = z
.string()
.describe('The timezone to use for date fields and signing the document.');
.describe(
'The timezone to use for date fields and signing the document. Example Etc/UTC, Australia/Melbourne',
);
// Cooked.
// .refine((value) => TIME_ZONES.includes(value), {
// message: 'Invalid timezone. Please provide a valid timezone',
// });
export type TDocumentMetaTimezone = z.infer<typeof ZDocumentMetaTimezoneSchema>;
export const ZDocumentMetaDateFormatSchema = z
.string()
.enum(VALID_DATE_FORMAT_VALUES)
.describe('The date format to use for date fields and signing the document.');
export type TDocumentMetaDateFormat = z.infer<typeof ZDocumentMetaDateFormatSchema>;
export const ZDocumentMetaRedirectUrlSchema = z
.string()
.describe('The URL to which the recipient should be redirected after signing the document.')
@ -55,7 +93,7 @@ export const ZDocumentMetaDistributionMethodSchema = z
export const ZDocumentMetaTypedSignatureEnabledSchema = z
.boolean()
.describe('Whether to allow typed signatures.');
.describe('Whether to allow recipients to sign using a typed signature.');
export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({
templateId: z
@ -113,23 +151,79 @@ export const ZGetDocumentWithDetailsByIdRequestSchema = z.object({
export const ZGetDocumentWithDetailsByIdResponseSchema = ZDocumentSchema;
export const ZCreateDocumentRequestSchema = z.object({
title: z.string().min(1),
title: ZDocumentTitleSchema,
documentDataId: z.string().min(1),
timezone: z.string().optional(),
timezone: ZDocumentMetaTimezoneSchema.optional(),
});
export const ZCreateDocumentV2RequestSchema = z.object({
title: ZDocumentTitleSchema,
externalId: ZDocumentExternalIdSchema.optional(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(),
formValues: ZDocumentFormValuesSchema.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(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
});
export type TCreateDocumentV2Request = z.infer<typeof ZCreateDocumentV2RequestSchema>;
export const ZCreateDocumentV2ResponseSchema = z.object({
document: ZDocumentSchema,
uploadUrl: z
.string()
.describe(
'The URL to upload the document PDF to. Use a PUT request with the file via form-data',
),
});
export const ZUpdateDocumentRequestSchema = z.object({
documentId: z.number(),
data: z
.object({
title: z.string().describe('The title of the document.').min(1).optional(),
externalId: z.string().nullish().describe('The external ID of the document.'),
visibility: z
.nativeEnum(DocumentVisibility)
.describe('The visibility of the document.')
.optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullish(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullish(),
})
.optional(),
meta: z
@ -139,6 +233,7 @@ export const ZUpdateDocumentRequestSchema = z.object({
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),

View File

@ -193,8 +193,8 @@ export const fieldRouter = router({
deleteDocumentField: authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/document/field/{fieldId}',
method: 'POST',
path: '/document/field/delete',
summary: 'Delete document field',
tags: ['Document Fields'],
},
@ -370,8 +370,8 @@ export const fieldRouter = router({
deleteTemplateField: authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/template/field/{fieldId}',
method: 'POST',
path: '/template/field/delete',
summary: 'Delete template field',
tags: ['Template Fields'],
},

View File

@ -1,31 +1,38 @@
import { z } from 'zod';
import { ZRecipientActionAuthSchema } from '@documenso/lib/types/document-auth';
import { ZFieldSchema } from '@documenso/lib/types/field';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import {
ZFieldHeightSchema,
ZFieldPageNumberSchema,
ZFieldPageXSchema,
ZFieldPageYSchema,
ZFieldSchema,
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
const ZCreateFieldSchema = z.object({
recipientId: z.number().describe('The ID of the recipient to create the field for.'),
type: ZFieldSchema.shape.type.describe('The type of the field to create.'),
pageNumber: z.number().describe('The page number the field will be on.'),
pageX: z.number().describe('The X coordinate of where the field will be placed.'),
pageY: z.number().describe('The Y coordinate of where the field will be placed.'),
width: z.number().describe('The width of the field.'),
height: z.number().describe('The height of the field.'),
fieldMeta: ZFieldMetaSchema.optional(),
});
const ZCreateFieldSchema = ZFieldAndMetaSchema.and(
z.object({
recipientId: z.number().describe('The ID of the recipient to create the field for.'),
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
}),
);
const ZUpdateFieldSchema = z.object({
id: z.number().describe('The ID of the field to update.'),
type: ZFieldSchema.shape.type.optional().describe('The type of the field to update.'),
pageNumber: z.number().optional().describe('The page number the field will be on.'),
pageX: z.number().optional().describe('The X coordinate of where the field will be placed.'),
pageY: z.number().optional().describe('The Y coordinate of where the field will be placed.'),
width: z.number().optional().describe('The width of the field.'),
height: z.number().optional().describe('The height of the field.'),
fieldMeta: ZFieldMetaSchema.optional(),
});
const ZUpdateFieldSchema = ZFieldAndMetaSchema.and(
z.object({
id: z.number().describe('The ID of the field to update.'),
pageNumber: ZFieldPageNumberSchema.optional(),
pageX: ZFieldPageXSchema.optional(),
pageY: ZFieldPageYSchema.optional(),
width: ZFieldWidthSchema.optional(),
height: ZFieldHeightSchema.optional(),
}),
);
export const ZCreateDocumentFieldRequestSchema = z.object({
documentId: z.number(),

View File

@ -4,9 +4,27 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { appRouter } from './router';
export const openApiDocument = generateOpenApiDocument(appRouter, {
title: 'Documenso v2 beta API',
description: 'Subject to breaking changes until v2 is fully released.',
version: '0.0.0',
baseUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta`,
});
export const openApiDocument = {
...generateOpenApiDocument(appRouter, {
title: 'Documenso v2 beta API',
description: 'Subject to breaking changes until v2 is fully released.',
version: '0.0.0',
baseUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta`,
securitySchemes: {
apiKey: {
type: 'apiKey',
in: 'header',
name: 'Authorization',
},
},
}),
/**
* Dirty way to pass through the security field.
*/
security: [
{
apiKey: [],
},
],
};

View File

@ -193,8 +193,8 @@ export const recipientRouter = router({
deleteDocumentRecipient: authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/document/recipient/{recipientId}',
method: 'POST',
path: '/document/recipient/delete',
summary: 'Delete document recipient',
tags: ['Document Recipients'],
},
@ -367,8 +367,8 @@ export const recipientRouter = router({
deleteTemplateRecipient: authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/template/recipient/{recipientId}',
method: 'POST',
path: '/template/recipient/delete',
summary: 'Delete template recipient',
tags: ['Template Recipients'],
},

View File

@ -14,7 +14,14 @@ export const ZGetRecipientRequestSchema = z.object({
export const ZGetRecipientResponseSchema = ZRecipientSchema;
const ZCreateRecipientSchema = z.object({
/**
* When changing this, ensure everything that uses this schema is updated correctly
* since this will change the Openapi schema.
*
* Example `createDocument` uses this, so you will need to update that function to
* pass along required details.
*/
export const ZCreateRecipientSchema = z.object({
email: z.string().toLowerCase().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
@ -42,11 +49,16 @@ export const ZCreateDocumentRecipientResponseSchema = ZRecipientLiteSchema;
export const ZCreateDocumentRecipientsRequestSchema = z.object({
documentId: z.number(),
recipients: z.array(ZCreateRecipientSchema).refine((recipients) => {
const emails = recipients.map((recipient) => recipient.email.toLowerCase());
recipients: z.array(ZCreateRecipientSchema).refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email.toLowerCase());
return new Set(emails).size === emails.length;
}),
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
});
export const ZCreateDocumentRecipientsResponseSchema = z.object({
@ -62,13 +74,18 @@ export const ZUpdateDocumentRecipientResponseSchema = ZRecipientSchema;
export const ZUpdateDocumentRecipientsRequestSchema = z.object({
documentId: z.number(),
recipients: z.array(ZUpdateRecipientSchema).refine((recipients) => {
const emails = recipients
.filter((recipient) => recipient.email !== undefined)
.map((recipient) => recipient.email?.toLowerCase());
recipients: z.array(ZUpdateRecipientSchema).refine(
(recipients) => {
const emails = recipients
.filter((recipient) => recipient.email !== undefined)
.map((recipient) => recipient.email?.toLowerCase());
return new Set(emails).size === emails.length;
}),
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
});
export const ZUpdateDocumentRecipientsResponseSchema = z.object({
@ -100,7 +117,7 @@ export const ZSetDocumentRecipientsRequestSchema = z
return new Set(emails).size === emails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Signers must have unique emails', path: ['signers__root'] },
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
);
export const ZSetDocumentRecipientsResponseSchema = z.object({
@ -116,11 +133,16 @@ export const ZCreateTemplateRecipientResponseSchema = ZRecipientLiteSchema;
export const ZCreateTemplateRecipientsRequestSchema = z.object({
templateId: z.number(),
recipients: z.array(ZCreateRecipientSchema).refine((recipients) => {
const emails = recipients.map((recipient) => recipient.email);
recipients: z.array(ZCreateRecipientSchema).refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
}),
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
});
export const ZCreateTemplateRecipientsResponseSchema = z.object({
@ -136,13 +158,18 @@ export const ZUpdateTemplateRecipientResponseSchema = ZRecipientSchema;
export const ZUpdateTemplateRecipientsRequestSchema = z.object({
templateId: z.number(),
recipients: z.array(ZUpdateRecipientSchema).refine((recipients) => {
const emails = recipients
.filter((recipient) => recipient.email !== undefined)
.map((recipient) => recipient.email);
recipients: z.array(ZUpdateRecipientSchema).refine(
(recipients) => {
const emails = recipients
.filter((recipient) => recipient.email !== undefined)
.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
}),
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
});
export const ZUpdateTemplateRecipientsResponseSchema = z.object({

View File

@ -13,36 +13,15 @@ import {
ZCreateTemplateResponseSchema,
createTemplate,
} from '@documenso/lib/server-only/template/create-template';
import {
ZCreateTemplateDirectLinkResponseSchema,
createTemplateDirectLink,
} from '@documenso/lib/server-only/template/create-template-direct-link';
import { createTemplateDirectLink } from '@documenso/lib/server-only/template/create-template-direct-link';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { deleteTemplateDirectLink } from '@documenso/lib/server-only/template/delete-template-direct-link';
import {
ZDuplicateTemplateResponseSchema,
duplicateTemplate,
} from '@documenso/lib/server-only/template/duplicate-template';
import {
ZFindTemplatesResponseSchema,
findTemplates,
} from '@documenso/lib/server-only/template/find-templates';
import {
ZGetTemplateByIdResponseSchema,
getTemplateById,
} from '@documenso/lib/server-only/template/get-template-by-id';
import {
ZMoveTemplateToTeamResponseSchema,
moveTemplateToTeam,
} from '@documenso/lib/server-only/template/move-template-to-team';
import {
ZToggleTemplateDirectLinkResponseSchema,
toggleTemplateDirectLink,
} from '@documenso/lib/server-only/template/toggle-template-direct-link';
import {
ZUpdateTemplateResponseSchema,
updateTemplate,
} from '@documenso/lib/server-only/template/update-template';
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { moveTemplateToTeam } from '@documenso/lib/server-only/template/move-template-to-team';
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
import { updateTemplate } from '@documenso/lib/server-only/template/update-template';
import type { Document } from '@documenso/prisma/client';
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
@ -50,16 +29,23 @@ import {
ZCreateDocumentFromDirectTemplateRequestSchema,
ZCreateDocumentFromTemplateRequestSchema,
ZCreateDocumentFromTemplateResponseSchema,
ZCreateTemplateDirectLinkMutationSchema,
ZCreateTemplateDirectLinkRequestSchema,
ZCreateTemplateDirectLinkResponseSchema,
ZCreateTemplateMutationSchema,
ZDeleteTemplateDirectLinkMutationSchema,
ZDeleteTemplateDirectLinkRequestSchema,
ZDeleteTemplateMutationSchema,
ZDuplicateTemplateMutationSchema,
ZDuplicateTemplateResponseSchema,
ZFindTemplatesRequestSchema,
ZFindTemplatesResponseSchema,
ZGetTemplateByIdRequestSchema,
ZMoveTemplatesToTeamRequestSchema,
ZToggleTemplateDirectLinkMutationSchema,
ZGetTemplateByIdResponseSchema,
ZMoveTemplateToTeamRequestSchema,
ZMoveTemplateToTeamResponseSchema,
ZToggleTemplateDirectLinkRequestSchema,
ZToggleTemplateDirectLinkResponseSchema,
ZUpdateTemplateRequestSchema,
ZUpdateTemplateResponseSchema,
} from './schema';
export const templateRouter = router({
@ -202,8 +188,8 @@ export const templateRouter = router({
deleteTemplate: authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/template/{templateId}',
method: 'POST',
path: '/template/delete',
summary: 'Delete template',
tags: ['Template'],
},
@ -331,7 +317,7 @@ export const templateRouter = router({
tags: ['Template'],
},
})
.input(ZCreateTemplateDirectLinkMutationSchema)
.input(ZCreateTemplateDirectLinkRequestSchema)
.output(ZCreateTemplateDirectLinkResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
@ -358,14 +344,14 @@ export const templateRouter = router({
deleteTemplateDirectLink: authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/template/direct/{templateId}',
method: 'POST',
path: '/template/direct/delete',
summary: 'Delete direct link',
description: 'Delete a direct link for a template',
tags: ['Template'],
},
})
.input(ZDeleteTemplateDirectLinkMutationSchema)
.input(ZDeleteTemplateDirectLinkRequestSchema)
.output(z.void())
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
@ -389,7 +375,7 @@ export const templateRouter = router({
tags: ['Template'],
},
})
.input(ZToggleTemplateDirectLinkMutationSchema)
.input(ZToggleTemplateDirectLinkRequestSchema)
.output(ZToggleTemplateDirectLinkResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
@ -413,7 +399,7 @@ export const templateRouter = router({
tags: ['Template'],
},
})
.input(ZMoveTemplatesToTeamRequestSchema)
.input(ZMoveTemplateToTeamRequestSchema)
.output(ZMoveTemplateToTeamResponseSchema)
.mutation(async ({ input, ctx }) => {
const { templateId, teamId } = input;

View File

@ -6,8 +6,14 @@ import {
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import {
ZTemplateLiteSchema,
ZTemplateManySchema,
ZTemplateSchema,
} from '@documenso/lib/types/template';
import { DocumentSigningOrder, DocumentVisibility, TemplateType } from '@documenso/prisma/client';
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod';
import {
ZDocumentMetaDateFormatSchema,
@ -69,7 +75,9 @@ export const ZDuplicateTemplateMutationSchema = z.object({
templateId: z.number(),
});
export const ZCreateTemplateDirectLinkMutationSchema = z.object({
export const ZDuplicateTemplateResponseSchema = ZTemplateLiteSchema;
export const ZCreateTemplateDirectLinkRequestSchema = z.object({
templateId: z.number(),
directRecipientId: z
.number()
@ -79,15 +87,28 @@ export const ZCreateTemplateDirectLinkMutationSchema = z.object({
.optional(),
});
export const ZDeleteTemplateDirectLinkMutationSchema = z.object({
const GenericDirectLinkResponseSchema = TemplateDirectLinkSchema.pick({
id: true,
templateId: true,
token: true,
createdAt: true,
enabled: true,
directTemplateRecipientId: true,
});
export const ZCreateTemplateDirectLinkResponseSchema = GenericDirectLinkResponseSchema;
export const ZDeleteTemplateDirectLinkRequestSchema = z.object({
templateId: z.number(),
});
export const ZToggleTemplateDirectLinkMutationSchema = z.object({
export const ZToggleTemplateDirectLinkRequestSchema = z.object({
templateId: z.number(),
enabled: z.boolean(),
});
export const ZToggleTemplateDirectLinkResponseSchema = GenericDirectLinkResponseSchema;
export const ZDeleteTemplateMutationSchema = z.object({
templateId: z.number(),
});
@ -141,19 +162,32 @@ export const ZUpdateTemplateRequestSchema = z.object({
.optional(),
});
export const ZUpdateTemplateResponseSchema = ZTemplateLiteSchema;
export const ZFindTemplatesRequestSchema = ZFindSearchParamsSchema.extend({
type: z.nativeEnum(TemplateType).describe('Filter templates by type.').optional(),
});
export const ZFindTemplatesResponseSchema = ZFindResultResponse.extend({
data: ZTemplateManySchema.array(),
});
export type TFindTemplatesResponse = z.infer<typeof ZFindTemplatesResponseSchema>;
export type FindTemplateRow = TFindTemplatesResponse['data'][number];
export const ZGetTemplateByIdRequestSchema = z.object({
templateId: z.number(),
});
export const ZMoveTemplatesToTeamRequestSchema = z.object({
export const ZGetTemplateByIdResponseSchema = ZTemplateSchema;
export const ZMoveTemplateToTeamRequestSchema = z.object({
templateId: z.number().describe('The ID of the template to move to.'),
teamId: z.number().describe('The ID of the team to move the template to.'),
});
export const ZMoveTemplateToTeamResponseSchema = ZTemplateLiteSchema;
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;

View File

@ -11,10 +11,10 @@ import { match } from 'ts-pattern';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDocument } from '@documenso/lib/types/document';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import {
DocumentGlobalAuthAccessSelect,
DocumentGlobalAuthAccessTooltip,
@ -65,7 +65,7 @@ export type AddSettingsFormProps = {
fields: Field[];
isDocumentEnterprise: boolean;
isDocumentPdfLoaded: boolean;
document: DocumentWithData;
document: TDocument;
currentTeamMemberRole?: TeamMemberRole;
onSubmit: (_data: TAddSettingsFormSchema) => void;
};

View File

@ -9,6 +9,10 @@ import {
} from '@documenso/lib/types/document-auth';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { DocumentVisibility } from '@documenso/prisma/client';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema';
export const ZMapNegativeOneToUndefinedSchema = z
.string()
@ -32,8 +36,8 @@ export const ZAddSettingsFormSchema = z.object({
ZDocumentActionAuthTypesSchema.optional(),
),
meta: z.object({
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
timezone: ZDocumentMetaTimezoneSchema.optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: ZDocumentMetaDateFormatSchema.optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
redirectUrl: z
.string()
.optional()

View File

@ -7,6 +7,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import type { Field, Recipient } from '@documenso/prisma/client';
@ -15,7 +16,6 @@ import {
DocumentStatus,
RecipientRole,
} from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
@ -43,7 +43,7 @@ export type AddSubjectFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
document: DocumentWithData;
document: TDocument;
onSubmit: (_data: TAddSubjectFormSchema) => void;
isDocumentPdfLoaded: boolean;
};

View File

@ -14,10 +14,11 @@ import { DOCUMENT_DISTRIBUTION_METHODS } from '@documenso/lib/constants/document
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import type { TTemplate } from '@documenso/lib/types/template';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { DocumentDistributionMethod, type Field, type Recipient } from '@documenso/prisma/client';
import type { TemplateWithData } from '@documenso/prisma/types/template';
import type { TDocumentMetaDateFormat } from '@documenso/trpc/server/document-router/schema';
import {
DocumentGlobalAuthAccessSelect,
DocumentGlobalAuthAccessTooltip,
@ -71,7 +72,7 @@ export type AddTemplateSettingsFormProps = {
fields: Field[];
isEnterprise: boolean;
isDocumentPdfLoaded: boolean;
template: TemplateWithData;
template: TTemplate;
currentTeamMemberRole?: TeamMemberRole;
onSubmit: (_data: TAddTemplateSettingsFormSchema) => void;
};
@ -104,7 +105,9 @@ export const AddTemplateSettingsFormPartial = ({
subject: template.templateMeta?.subject ?? '',
message: template.templateMeta?.message ?? '',
timezone: template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
dateFormat: (template.templateMeta?.dateFormat ??
DEFAULT_DOCUMENT_DATE_FORMAT) as TDocumentMetaDateFormat,
distributionMethod:
template.templateMeta?.distributionMethod || DocumentDistributionMethod.EMAIL,
redirectUrl: template.templateMeta?.redirectUrl ?? '',

View File

@ -10,6 +10,10 @@ import {
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { DocumentVisibility } from '@documenso/prisma/client';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
import { DocumentDistributionMethod } from '.prisma/client';
@ -27,8 +31,8 @@ export const ZAddTemplateSettingsFormSchema = z.object({
meta: z.object({
subject: z.string(),
message: z.string(),
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
timezone: ZDocumentMetaTimezoneSchema.default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: ZDocumentMetaDateFormatSchema.default(DEFAULT_DOCUMENT_DATE_FORMAT),
distributionMethod: z
.nativeEnum(DocumentDistributionMethod)
.optional()