Compare commits

..

2 Commits

Author SHA1 Message Date
87aa628dc8 feat: add formdata endpoints for documents,envelopes,templates
Adds the missing endpoints for documents, envelopes and
templates supporting file uploads in a singular request.

Also updates frontend components that would use the prior
hidden endpoints.
2025-11-03 15:07:15 +11:00
c85c0cf610 feat: allow multipart requests for public api
Adds support for multipart/form-data requests in the public api
allowing documents to be uploaded without having to perform a secondary
request.

Need to rollout further endpoints for envelopes and templates.

Need to change how we store files to not use `putFileServerSide`
2025-11-02 23:26:43 +11:00
104 changed files with 5078 additions and 7438 deletions

View File

@ -29,10 +29,6 @@ NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
# URL used by the web app to request itself (e.g. local background jobs) # URL used by the web app to request itself (e.g. local background jobs)
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
# [[SERVER]]
# OPTIONAL: The port the server will listen on. Defaults to 3000.
PORT=3000
# [[DATABASE]] # [[DATABASE]]
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool. # Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.

View File

@ -1,692 +0,0 @@
# Documenso Code Style Guide
This document captures the code style, patterns, and conventions used in the Documenso codebase. It covers both enforceable rules and subjective "taste" elements that make our code consistent and maintainable.
## Table of Contents
1. [General Principles](#general-principles)
2. [TypeScript Conventions](#typescript-conventions)
3. [Imports & Dependencies](#imports--dependencies)
4. [Functions & Methods](#functions--methods)
5. [React & Components](#react--components)
6. [Error Handling](#error-handling)
7. [Async/Await Patterns](#asyncawait-patterns)
8. [Whitespace & Formatting](#whitespace--formatting)
9. [Naming Conventions](#naming-conventions)
10. [Pattern Matching](#pattern-matching)
11. [Database & Prisma](#database--prisma)
12. [TRPC Patterns](#trpc-patterns)
---
## General Principles
- **Functional over Object-Oriented**: Prefer functional programming patterns over classes
- **Explicit over Implicit**: Be explicit about types, return values, and error cases
- **Early Returns**: Use guard clauses and early returns to reduce nesting
- **Immutability**: Favor `const` over `let`; avoid mutation where possible
---
## TypeScript Conventions
### Type Definitions
```typescript
// ✅ Prefer `type` over `interface`
type CreateDocumentOptions = {
templateId: number;
userId: number;
recipients: Recipient[];
};
// ❌ Avoid interfaces unless absolutely necessary
interface CreateDocumentOptions {
templateId: number;
}
```
### Type Imports
```typescript
// ✅ Use `type` keyword for type-only imports
import type { Document, Recipient } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
// Types in function signatures
export const findDocuments = async ({ userId, teamId }: FindDocumentsOptions) => {
// ...
};
```
### Inline Types for Function Parameters
```typescript
// ✅ Extract inline types to named types
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
templateRecipientId: number;
fields: Field[];
};
const finalRecipients: FinalRecipient[] = [];
```
---
## Imports & Dependencies
### Import Organization
Imports should be organized in the following order with blank lines between groups:
```typescript
// 1. React imports
import { useCallback, useEffect, useMemo } from 'react';
// 2. Third-party library imports (alphabetically)
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { Document, Recipient } from '@prisma/client';
import { DocumentStatus, RecipientRole } from '@prisma/client';
import { match } from 'ts-pattern';
// 3. Internal package imports (from @documenso/*)
import { AppError } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
// 4. Relative imports
import { getTeamById } from '../team/get-team';
import type { FindResultResponse } from './types';
```
### Destructuring Imports
```typescript
// ✅ Destructure specific exports
// ✅ Use type imports for types
import type { Document } from '@prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
```
---
## Functions & Methods
### Arrow Functions
```typescript
// ✅ Always use arrow functions for functions
export const createDocument = async ({
userId,
title,
}: CreateDocumentOptions) => {
// ...
};
// ✅ Callbacks and handlers
const onSubmit = useCallback(async () => {
// ...
}, [dependencies]);
// ❌ Avoid regular function declarations
function createDocument() {
// ...
}
```
### Function Parameters
```typescript
// ✅ Use destructured object parameters for multiple params
export const findDocuments = async ({
userId,
teamId,
status = ExtendedDocumentStatus.ALL,
page = 1,
perPage = 10,
}: FindDocumentsOptions) => {
// ...
};
// ✅ Destructure on separate line when needed
const onFormSubmit = form.handleSubmit(onSubmit);
// ✅ Deconstruct nested properties explicitly
const { user } = ctx;
const { templateId } = input;
```
---
## React & Components
### Component Definition
```typescript
// ✅ Use const with arrow function
export const AddSignersFormPartial = ({
documentFlow,
recipients,
fields,
onSubmit,
}: AddSignersFormProps) => {
// ...
};
// ❌ Never use classes
class MyComponent extends React.Component {
// ...
}
```
### Hooks
```typescript
// ✅ Group related hooks together with blank line separation
const { _ } = useLingui();
const { toast } = useToast();
const { currentStep, totalSteps, previousStep } = useStep();
const form = useForm<TFormSchema>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
// ...
},
});
```
### Event Handlers
```typescript
// ✅ Use arrow functions with descriptive names
const onFormSubmit = async () => {
await form.trigger();
// ...
};
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null) => {
event?.preventDefault();
// ...
},
[dependencies],
);
// ✅ Inline handlers for simple operations
<Button onClick={() => setOpen(false)}>Close</Button>
```
### State Management
```typescript
// ✅ Descriptive state names with auxiliary verbs
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
// ✅ Complex state in single useState when related
const [coords, setCoords] = useState({
x: 0,
y: 0,
});
```
---
## Error Handling
### Try-Catch Blocks
```typescript
// ✅ Use try-catch for operations that might fail
try {
const document = await getDocumentById({
documentId: Number(documentId),
userId: user.id,
});
return {
status: 200,
body: document,
};
} catch (err) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
```
### Throwing Errors
```typescript
// ✅ Use AppError for application errors
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
// ✅ Use descriptive error messages
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Template with ID ${templateId} not found`,
});
}
```
### Error Parsing on Frontend
```typescript
// ✅ Parse errors on the frontend
try {
await updateOrganisation({ organisationId, data });
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An error occurred`,
description: error.message,
variant: 'destructive',
});
}
```
---
## Async/Await Patterns
### Async Function Definitions
```typescript
// ✅ Mark async functions clearly
export const createDocument = async ({
userId,
title,
}: Options): Promise<Document> => {
// ...
};
// ✅ Use await for promises
const document = await prisma.document.create({ data });
// ✅ Use Promise.all for parallel operations
const [document, recipients] = await Promise.all([
getDocumentById({ documentId }),
getRecipientsForDocument({ documentId }),
]);
```
### Void for Fire-and-Forget
```typescript
// ✅ Use void for intentionally unwaited promises
void handleAutoSave();
// ✅ Or in event handlers
onClick={() => void onFormSubmit()}
```
---
## Whitespace & Formatting
### Blank Lines Between Concepts
```typescript
// ✅ Blank line after imports
import { prisma } from '@documenso/prisma';
export const findDocuments = async () => {
// ...
};
// ✅ Blank line between logical sections
const user = await prisma.user.findFirst({ where: { id: userId } });
let team = null;
if (teamId !== undefined) {
team = await getTeamById({ userId, teamId });
}
// ✅ Blank line before return statements
const result = await someOperation();
return result;
```
### Function/Method Spacing
```typescript
// ✅ No blank lines between chained methods in same operation
const documents = await prisma.document
.findMany({ where: { userId } })
.then((docs) => docs.map(maskTokens));
// ✅ Blank line between different operations
const document = await createDocument({ userId });
await sendDocument({ documentId: document.id });
return document;
```
### Object and Array Formatting
```typescript
// ✅ Multi-line when complex
const options = {
userId,
teamId,
status: ExtendedDocumentStatus.ALL,
page: 1,
};
// ✅ Single line when simple
const coords = { x: 0, y: 0 };
// ✅ Array items on separate lines when objects
const recipients = [
{
name: 'John',
email: 'john@example.com',
},
{
name: 'Jane',
email: 'jane@example.com',
},
];
```
---
## Naming Conventions
### Variables
```typescript
// ✅ camelCase for variables and functions
const documentId = 123;
const onSubmit = () => {};
// ✅ Descriptive names with auxiliary verbs for booleans
const isLoading = false;
const hasError = false;
const canEdit = true;
const shouldRender = true;
// ✅ Prefix with $ for DOM elements
const $page = document.querySelector('.page');
const $inputRef = useRef<HTMLInputElement>(null);
```
### Types and Schemas
```typescript
// ✅ PascalCase for types
type CreateDocumentOptions = {
userId: number;
};
// ✅ Prefix Zod schemas with Z
const ZCreateDocumentSchema = z.object({
title: z.string(),
});
// ✅ Prefix type from Zod schema with T
type TCreateDocumentSchema = z.infer<typeof ZCreateDocumentSchema>;
```
### Constants
```typescript
// ✅ UPPER_SNAKE_CASE for true constants
const DEFAULT_DOCUMENT_DATE_FORMAT = 'dd/MM/yyyy';
const MAX_FILE_SIZE = 1024 * 1024 * 5;
// ✅ camelCase for const variables that aren't "constants"
const userId = await getUserId();
```
### Functions
```typescript
// ✅ Verb-based names for functions
const createDocument = async () => {};
const findDocuments = async () => {};
const updateDocument = async () => {};
const deleteDocument = async () => {};
// ✅ On prefix for event handlers
const onSubmit = () => {};
const onClick = () => {};
const onFieldCopy = () => {}; // 'on' is also acceptable
```
### Clarity Over Brevity
```typescript
// ✅ Prefer descriptive names over abbreviations
const superLongMethodThatIsCorrect = () => {};
const recipientAuthenticationOptions = {};
const documentMetadata = {};
// ❌ Avoid abbreviations that sacrifice clarity
const supLongMethThatIsCorrect = () => {};
const recipAuthOpts = {};
const docMeta = {};
// ✅ Common abbreviations that are widely understood are acceptable
const userId = 123;
const htmlElement = document.querySelector('div');
const apiResponse = await fetch('/api');
```
---
## Pattern Matching
### Using ts-pattern
```typescript
import { match } from 'ts-pattern';
// ✅ Use match for complex conditionals
const result = match(status)
.with(ExtendedDocumentStatus.DRAFT, () => ({
status: 'draft',
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
status: 'pending',
}))
.with(ExtendedDocumentStatus.COMPLETED, () => ({
status: 'completed',
}))
.exhaustive();
// ✅ Use .otherwise() for default case when not exhaustive
const value = match(type)
.with('text', () => 'Text field')
.with('number', () => 'Number field')
.otherwise(() => 'Unknown field');
```
---
## Database & Prisma
### Query Structure
```typescript
// ✅ Destructure commonly used fields
const { id, email, name } = user;
// ✅ Use select to limit returned fields
const user = await prisma.user.findFirst({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
},
});
// ✅ Use include for relations
const document = await prisma.document.findFirst({
where: { id: documentId },
include: {
recipients: true,
fields: true,
},
});
```
### Transactions
```typescript
// ✅ Use transactions for related operations
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({ data });
await tx.field.createMany({ data: fieldsData });
await tx.documentAuditLog.create({ data: auditData });
return document;
});
```
### Where Clauses
```typescript
// ✅ Build complex where clauses separately
const whereClause: Prisma.DocumentWhereInput = {
AND: [
{ userId: user.id },
{ deletedAt: null },
{ status: { in: [DocumentStatus.DRAFT, DocumentStatus.PENDING] } },
],
};
const documents = await prisma.document.findMany({
where: whereClause,
});
```
---
## TRPC Patterns
### Router Structure
```typescript
// ✅ Destructure context and input at start
.query(async ({ input, ctx }) => {
const { teamId } = ctx;
const { templateId } = input;
ctx.logger.info({
input: { templateId },
});
return await getTemplateById({
id: templateId,
userId: ctx.user.id,
teamId,
});
});
```
### Request/Response Schemas
```typescript
// ✅ Name schemas clearly
const ZCreateDocumentRequestSchema = z.object({
title: z.string(),
recipients: z.array(ZRecipientSchema),
});
const ZCreateDocumentResponseSchema = z.object({
documentId: z.number(),
status: z.string(),
});
```
### Error Handling in TRPC
```typescript
// ✅ Catch and transform errors appropriately
try {
const result = await createDocument({ userId, data });
return result;
} catch (err) {
return AppError.toRestAPIError(err);
}
// ✅ Or throw AppError directly
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
```
---
## Additional Patterns
### Optional Chaining
```typescript
// ✅ Use optional chaining for potentially undefined values
const email = user?.email;
const recipientToken = recipient?.token ?? '';
// ✅ Use nullish coalescing for defaults
const pageSize = perPage ?? 10;
const status = documentStatus ?? DocumentStatus.DRAFT;
```
### Array Operations
```typescript
// ✅ Use functional array methods
const activeRecipients = recipients.filter((r) => r.signingStatus === 'SIGNED');
const recipientEmails = recipients.map((r) => r.email);
const hasSignedRecipients = recipients.some((r) => r.signingStatus === 'SIGNED');
// ✅ Use find instead of filter + [0]
const recipient = recipients.find((r) => r.id === recipientId);
```
### Conditional Rendering
```typescript
// ✅ Use && for conditional rendering
{isLoading && <Loader />}
// ✅ Use ternary for either/or
{isLoading ? <Loader /> : <Content />}
// ✅ Extract complex conditions to variables
const shouldShowAdvanced = isAdmin && hasPermission && !isDisabled;
{shouldShowAdvanced && <AdvancedSettings />}
```
---
## When in Doubt
- **Consistency**: Follow the patterns you see in similar files
- **Readability**: Favor code that's easy to read over clever one-liners
- **Explicitness**: Be explicit rather than implicit
- **Whitespace**: Use blank lines to separate logical sections
- **Early Returns**: Use guard clauses to reduce nesting
- **Functional**: Prefer functional patterns over imperative ones

View File

@ -5,7 +5,7 @@
"scripts": { "scripts": {
"dev": "next dev -p 3003", "dev": "next dev -p 3003",
"build": "next build", "build": "next build",
"start": "next start -p 3003", "start": "next start",
"lint:fix": "next lint --fix", "lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules" "clean": "rimraf .next && rimraf node_modules"
}, },

View File

@ -1,218 +0,0 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminOrganisationMemberUpdateDialogProps = {
trigger?: React.ReactNode;
organisationId: string;
organisationMember: TGetAdminOrganisationResponse['members'][number];
isOwner: boolean;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateOrganisationMemberFormSchema = z.object({
role: z.enum(['OWNER', 'ADMIN', 'MANAGER', 'MEMBER']),
});
type ZUpdateOrganisationMemberSchema = z.infer<typeof ZUpdateOrganisationMemberFormSchema>;
export const AdminOrganisationMemberUpdateDialog = ({
trigger,
organisationId,
organisationMember,
isOwner,
...props
}: AdminOrganisationMemberUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
// Determine the current role value for the form
const currentRoleValue = isOwner
? 'OWNER'
: getHighestOrganisationRoleInGroup(
organisationMember.organisationGroupMembers.map((ogm) => ogm.group),
);
const organisationMemberName = organisationMember.user.name ?? organisationMember.user.email;
const form = useForm<ZUpdateOrganisationMemberSchema>({
resolver: zodResolver(ZUpdateOrganisationMemberFormSchema),
defaultValues: {
role: currentRoleValue,
},
});
const { mutateAsync: updateOrganisationMemberRole } =
trpc.admin.organisationMember.updateRole.useMutation();
const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => {
try {
await updateOrganisationMemberRole({
organisationId,
userId: organisationMember.userId,
role,
});
const roleLabel = match(role)
.with('OWNER', () => t`Owner`)
.with(OrganisationMemberRole.ADMIN, () => t`Admin`)
.with(OrganisationMemberRole.MANAGER, () => t`Manager`)
.with(OrganisationMemberRole.MEMBER, () => t`Member`)
.exhaustive();
toast({
title: t`Success`,
description:
role === 'OWNER'
? t`Ownership transferred to ${organisationMemberName}.`
: t`Updated ${organisationMemberName} to ${roleLabel}.`,
duration: 5000,
});
setOpen(false);
// Refresh the page to show updated data
await navigate(0);
} catch (err) {
console.error(err);
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to update this organisation member. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
return;
}
form.reset({
role: currentRoleValue,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentRoleValue, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Update role</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Update organisation member</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are currently updating{' '}
<span className="font-bold">{organisationMemberName}.</span>
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel required>
<Trans>Role</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="w-full" position="popper">
<SelectItem value="OWNER">
<Trans>Owner</Trans>
</SelectItem>
<SelectItem value={OrganisationMemberRole.ADMIN}>
<Trans>Admin</Trans>
</SelectItem>
<SelectItem value={OrganisationMemberRole.MANAGER}>
<Trans>Manager</Trans>
</SelectItem>
<SelectItem value={OrganisationMemberRole.MEMBER}>
<Trans>Member</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -15,16 +15,18 @@ import {
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react'; import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import * as z from 'zod'; import * as z from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TEnvelope } from '@documenso/lib/types/envelope'; import type { TEnvelope } from '@documenso/lib/types/envelope';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react'; import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -63,7 +65,6 @@ export type EnvelopeDistributeDialogProps = {
fields: Pick<Field, 'type' | 'recipientId'>[]; fields: Pick<Field, 'type' | 'recipientId'>[];
}; };
onDistribute?: () => Promise<void>; onDistribute?: () => Promise<void>;
documentRootPath: string;
trigger?: React.ReactNode; trigger?: React.ReactNode;
}; };
@ -88,7 +89,6 @@ export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFo
export const EnvelopeDistributeDialog = ({ export const EnvelopeDistributeDialog = ({
envelope, envelope,
trigger, trigger,
documentRootPath,
onDistribute, onDistribute,
}: EnvelopeDistributeDialogProps) => { }: EnvelopeDistributeDialogProps) => {
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
@ -97,7 +97,6 @@ export const EnvelopeDistributeDialog = ({
const { toast } = useToast(); const { toast } = useToast();
const { t } = useLingui(); const { t } = useLingui();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -164,14 +163,6 @@ export const EnvelopeDistributeDialog = ({
await onDistribute?.(); await onDistribute?.();
let redirectPath = `${documentRootPath}/${envelope.id}`;
if (meta.distributionMethod === DocumentDistributionMethod.NONE) {
redirectPath += '?action=copy-links';
}
await navigate(redirectPath);
toast({ toast({
title: t`Envelope distributed`, title: t`Envelope distributed`,
description: t`Your envelope has been distributed successfully.`, description: t`Your envelope has been distributed successfully.`,
@ -207,7 +198,6 @@ export const EnvelopeDistributeDialog = ({
<Trans>Recipients will be able to sign the document once sent</Trans> <Trans>Recipients will be able to sign the document once sent</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{!invalidEnvelopeCode ? ( {!invalidEnvelopeCode ? (
<Form {...form}> <Form {...form}>
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
@ -230,11 +220,7 @@ export const EnvelopeDistributeDialog = ({
</TabsList> </TabsList>
</Tabs> </Tabs>
<div <div className="min-h-72">
className={cn('min-h-72', {
'min-h-[23rem]': organisation.organisationClaim.flags.emailDomains,
})}
>
<AnimatePresence initial={false} mode="wait"> <AnimatePresence initial={false} mode="wait">
{distributionMethod === DocumentDistributionMethod.EMAIL && ( {distributionMethod === DocumentDistributionMethod.EMAIL && (
<motion.div <motion.div
@ -369,18 +355,73 @@ export const EnvelopeDistributeDialog = ({
exit={{ opacity: 0, transition: { duration: 0.15 } }} exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="min-h-60 rounded-lg border" className="min-h-60 rounded-lg border"
> >
<div className="text-muted-foreground py-24 text-center text-sm"> {envelope.status === DocumentStatus.DRAFT ? (
<p> <div className="text-muted-foreground py-24 text-center text-sm">
<Trans>We won't send anything to notify recipients.</Trans> <p>
</p> <Trans>We won't send anything to notify recipients.</Trans>
</p>
<p className="mt-2"> <p className="mt-2">
<Trans> <Trans>
We will generate signing links for you, which you can send to the We will generate signing links for you, which you can send to the
recipients through your method of choice. recipients through your method of choice.
</Trans> </Trans>
</p> </p>
</div> </div>
) : (
<ul className="text-muted-foreground divide-y">
{/* Todo: Envelopes - I don't think this section shows up */}
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
</li>
)}
{recipients.map((recipient) => (
<li
key={recipient.id}
className="flex items-center justify-between px-4 py-3 text-sm"
>
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={
<p className="text-muted-foreground text-sm">
{recipient.email}
</p>
}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{t(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
/>
{recipient.role !== RecipientRole.CC && (
<CopyTextButton
value={formatSigningLink(recipient.token)}
onCopySuccess={() => {
toast({
title: t`Copied to clipboard`,
description: t`The signing link has been copied to your clipboard.`,
});
}}
badgeContentUncopied={
<p className="ml-1 text-xs">
<Trans>Copy</Trans>
</p>
}
badgeContentCopied={
<p className="ml-1 text-xs">
<Trans>Copied</Trans>
</p>
}
/>
)}
</li>
))}
</ul>
)}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>

View File

@ -213,6 +213,8 @@ export const EnvelopeDownloadDialog = ({
</div> </div>
)) ))
)} )}
{/* Todo: Envelopes - Download all button */}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -7,9 +7,9 @@ import { FilePlus, Loader } from 'lucide-react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -54,13 +54,17 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
setIsUploadingFile(true); setIsUploadingFile(true);
try { try {
const response = await putPdfFile(file); const payload = {
const { legacyTemplateId: id } = await createTemplate({
title: file.name, title: file.name,
templateDocumentDataId: response.id,
folderId: folderId, folderId: folderId,
}); } satisfies TCreateTemplatePayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createTemplate(formData);
toast({ toast({
title: _(msg`Template document uploaded`), title: _(msg`Template document uploaded`),

View File

@ -1,7 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod'; import type { z } from 'zod';
@ -61,12 +60,7 @@ export const EditorFieldSignatureForm = ({
<Form {...form}> <Form {...form}>
<form> <form>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<div> <EditorGenericFontSizeField formControl={form.control} />
<EditorGenericFontSizeField formControl={form.control} />
<p className="text-muted-foreground mt-0.5 text-xs">
<Trans>The typed signature font size</Trans>
</p>
</div>
</fieldset> </fieldset>
</form> </form>
</Form> </Form>

View File

@ -8,13 +8,11 @@ import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitive
export type DocumentSigningAttachmentsPopoverProps = { export type DocumentSigningAttachmentsPopoverProps = {
envelopeId: string; envelopeId: string;
token: string; token: string;
trigger?: React.ReactNode;
}; };
export const DocumentSigningAttachmentsPopover = ({ export const DocumentSigningAttachmentsPopover = ({
envelopeId, envelopeId,
token, token,
trigger,
}: DocumentSigningAttachmentsPopoverProps) => { }: DocumentSigningAttachmentsPopoverProps) => {
const { data: attachments } = trpc.envelope.attachment.find.useQuery({ const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId, envelopeId,
@ -28,17 +26,15 @@ export const DocumentSigningAttachmentsPopover = ({
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
{trigger ?? ( <Button variant="outline" className="gap-2">
<Button variant="outline" className="gap-2"> <PaperclipIcon className="h-4 w-4" />
<PaperclipIcon className="h-4 w-4" /> <span>
<span> <Trans>Attachments</Trans>{' '}
<Trans>Attachments</Trans>{' '} {attachments && attachments.data.length > 0 && (
{attachments && attachments.data.length > 0 && ( <span className="ml-1">({attachments.data.length})</span>
<span className="ml-1">({attachments.data.length})</span> )}
)} </span>
</span> </Button>
</Button>
)}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-96" align="start"> <PopoverContent className="w-96" align="start">

View File

@ -3,7 +3,7 @@ import { lazy, useMemo } from 'react';
import { Plural, Trans } from '@lingui/react/macro'; import { Plural, Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client'; import { EnvelopeType, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon, PaperclipIcon } from 'lucide-react'; import { ArrowLeftIcon, BanIcon, DownloadCloudIcon } from 'lucide-react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -75,7 +75,7 @@ export const DocumentSigningPageViewV2 = () => {
<EnvelopeSignerHeader /> <EnvelopeSignerHeader />
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen"> <div className="flex h-[calc(100vh-73px)] w-screen">
{/* Left Section - Step Navigation */} {/* Left Section - Step Navigation */}
<div className="bg-background border-border hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4 lg:flex"> <div className="bg-background border-border hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4 lg:flex">
<div className="px-4"> <div className="px-4">
@ -121,16 +121,12 @@ export const DocumentSigningPageViewV2 = () => {
<Trans>Actions</Trans> <Trans>Actions</Trans>
</h4> </h4>
<DocumentSigningAttachmentsPopover <div className="w-full">
envelopeId={envelope.id} <DocumentSigningAttachmentsPopover
token={recipient.token} envelopeId={envelope.id}
trigger={ token={recipient.token}
<Button variant="ghost" size="sm" className="w-full justify-start"> />
<PaperclipIcon className="mr-2 h-4 w-4" /> </div>
<Trans>Attachments</Trans>
</Button>
}
/>
<EnvelopeDownloadDialog <EnvelopeDownloadDialog
envelopeId={envelope.id} envelopeId={envelope.id}

View File

@ -8,7 +8,6 @@ import {
RecipientRole, RecipientRole,
SigningStatus, SigningStatus,
} from '@prisma/client'; } from '@prisma/client';
import { prop, sortBy } from 'remeda';
import { isBase64Image } from '@documenso/lib/constants/signatures'; import { isBase64Image } from '@documenso/lib/constants/signatures';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@ -166,29 +165,7 @@ export const EnvelopeSigningProvider = ({
* The fields that are still required to be signed by the actual recipient. * The fields that are still required to be signed by the actual recipient.
*/ */
const recipientFieldsRemaining = useMemo(() => { const recipientFieldsRemaining = useMemo(() => {
const requiredFields = envelopeData.recipient.fields return envelopeData.recipient.fields.filter((field) => isFieldUnsignedAndRequired(field));
.filter((field) => isFieldUnsignedAndRequired(field))
.map((field) => {
const envelopeItem = envelope.envelopeItems.find(
(item) => item.id === field.envelopeItemId,
);
if (!envelopeItem) {
throw new Error('Missing envelope item');
}
return {
...field,
envelopeItemOrder: envelopeItem.order,
};
});
return sortBy(
requiredFields,
[prop('envelopeItemOrder'), 'asc'],
[prop('page'), 'asc'],
[prop('positionY'), 'asc'],
);
}, [envelopeData.recipient.fields]); }, [envelopeData.recipient.fields]);
/** /**

View File

@ -4,10 +4,7 @@ import { Trans } from '@lingui/react/macro';
import type { DocumentData, EnvelopeItem } from '@prisma/client'; import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
@ -95,60 +92,6 @@ export const DocumentCertificateQRView = ({
</Dialog> </Dialog>
)} )}
{internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
<DocumentCertificateQrV2
title={title}
recipientCount={recipientCount}
formattedDate={formattedDate}
/>
</EnvelopeRenderProvider>
) : (
<>
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
<div className="space-y-1">
<h1 className="text-xl font-medium">{title}</h1>
<div className="text-muted-foreground flex flex-col gap-0.5 text-sm">
<p>
<Trans>{recipientCount} recipients</Trans>
</p>
<p>
<Trans>Completed on {formattedDate}</Trans>
</p>
</div>
</div>
<ShareDocumentDownloadButton
title={title}
documentData={envelopeItems[0].documentData}
/>
</div>
<div className="mt-12 w-full">
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
</div>
</>
)}
</div>
);
};
type DocumentCertificateQrV2Props = {
title: string;
recipientCount: number;
formattedDate: string;
};
const DocumentCertificateQrV2 = ({
title,
recipientCount,
formattedDate,
}: DocumentCertificateQrV2Props) => {
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
return (
<div className="flex min-h-screen flex-col items-start">
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end"> <div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-xl font-medium">{title}</h1> <h1 className="text-xl font-medium">{title}</h1>
@ -163,18 +106,21 @@ const DocumentCertificateQrV2 = ({
</div> </div>
</div> </div>
{currentEnvelopeItem && ( <ShareDocumentDownloadButton title={title} documentData={envelopeItems[0].documentData} />
<ShareDocumentDownloadButton
title={title}
documentData={currentEnvelopeItem.documentData}
/>
)}
</div> </div>
<div className="mt-12 w-full"> <div className="mt-12 w-full">
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} /> {internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} /> <PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</EnvelopeRenderProvider>
) : (
<>
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
</>
)}
</div> </div>
</div> </div>
); );

View File

@ -16,9 +16,9 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/l
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@ -62,14 +62,18 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
try { try {
setIsLoading(true); setIsLoading(true);
const response = await putPdfFile(file); const payload = {
const { legacyDocumentId: id } = await createDocument({
title: file.name, title: file.name,
documentDataId: response.id, timezone: userTimezone,
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
folderId: folderId ?? undefined, folderId: folderId ?? undefined,
}); } satisfies TCreateDocumentPayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
void refreshLimits(); void refreshLimits();

View File

@ -1,10 +1,7 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { TooltipArrow } from '@radix-ui/react-tooltip';
import { import {
AlertTriangle, AlertTriangle,
CheckIcon, CheckIcon,
@ -15,7 +12,7 @@ import {
PlusIcon, PlusIcon,
UserIcon, UserIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Link, useSearchParams } from 'react-router'; import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
@ -27,12 +24,6 @@ import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { PopoverHover } from '@documenso/ui/primitives/popover'; import { PopoverHover } from '@documenso/ui/primitives/popover';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewRecipientsProps = { export type DocumentPageViewRecipientsProps = {
@ -46,24 +37,8 @@ export const DocumentPageViewRecipients = ({
}: DocumentPageViewRecipientsProps) => { }: DocumentPageViewRecipientsProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const recipients = envelope.recipients; const recipients = envelope.recipients;
const [shouldHighlightCopyButtons, setShouldHighlightCopyButtons] = useState(false);
// Check for action=view-tokens query parameter and set highlighting state
useEffect(() => {
const hasViewTokensAction = searchParams.get('action') === 'copy-links';
if (hasViewTokensAction) {
setShouldHighlightCopyButtons(true);
// Remove the query parameter immediately
const params = new URLSearchParams(searchParams);
params.delete('action');
setSearchParams(params);
}
}, [searchParams, setSearchParams]);
return ( return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border"> <section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
@ -94,7 +69,7 @@ export const DocumentPageViewRecipients = ({
</li> </li>
)} )}
{recipients.map((recipient, i) => ( {recipients.map((recipient) => (
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm"> <li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText <AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()} avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
@ -184,33 +159,15 @@ export const DocumentPageViewRecipients = ({
{envelope.status === DocumentStatus.PENDING && {envelope.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && ( recipient.role !== RecipientRole.CC && (
<TooltipProvider> <CopyTextButton
<Tooltip open={shouldHighlightCopyButtons && i === 0}> value={formatSigningLink(recipient.token)}
<TooltipTrigger asChild> onCopySuccess={() => {
<div toast({
className={shouldHighlightCopyButtons ? 'animate-pulse' : ''} title: _(msg`Copied to clipboard`),
onClick={() => setShouldHighlightCopyButtons(false)} description: _(msg`The signing link has been copied to your clipboard.`),
> });
<CopyTextButton }}
value={formatSigningLink(recipient.token)} />
onCopySuccess={() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(
msg`The signing link has been copied to your clipboard.`,
),
});
setShouldHighlightCopyButtons(false);
}}
/>
</div>
</TooltipTrigger>
<TooltipContent sideOffset={2}>
<Trans>Copy Signing Links</Trans>
<TooltipArrow className="fill-background" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
)} )}
</div> </div>
</li> </li>

View File

@ -13,9 +13,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload'; import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import { import {
@ -73,14 +73,18 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
try { try {
setIsLoading(true); setIsLoading(true);
const response = await putPdfFile(file); const payload = {
const { legacyDocumentId: id } = await createDocument({
title: file.name, title: file.name,
documentDataId: response.id,
timezone: userTimezone, timezone: userTimezone,
folderId: folderId ?? undefined, folderId: folderId ?? undefined,
}); } satisfies TCreateDocumentPayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
void refreshLimits(); void refreshLimits();

View File

@ -14,9 +14,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload'; import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import { import {
@ -78,35 +78,24 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
try { try {
setIsLoading(true); setIsLoading(true);
const result = await Promise.all( const payload = {
files.map(async (file) => {
try {
const response = await putPdfFile(file);
return {
title: file.name,
documentDataId: response.id,
};
} catch (err) {
console.error(err);
throw new Error('Failed to upload document');
}
}),
);
const envelopeItemsToCreate = result.filter(
(item): item is { title: string; documentDataId: string } => item !== undefined,
);
const { id } = await createEnvelope({
folderId, folderId,
type, type,
title: files[0].name, title: files[0].name,
items: envelopeItemsToCreate,
meta: { meta: {
timezone: userTimezone, timezone: userTimezone,
}, },
}).catch((error) => { } satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
const { id } = await createEnvelope(formData).catch((error) => {
console.error(error); console.error(error);
throw error; throw error;

View File

@ -57,6 +57,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
); );
const handleResizeOrMove = (event: KonvaEventObject<Event>) => { const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
console.log('Field resized or moved');
const { current: container } = canvasElement; const { current: container } = canvasElement;
if (!container) { if (!container) {
@ -271,6 +273,9 @@ export default function EnvelopeEditorFieldsPageRenderer() {
return; return;
} }
console.log(`pointerPosition.x: ${pointerPosition.x}`);
console.log(`pointerPosition.y: ${pointerPosition.y}`);
x1 = pointerPosition.x / scale; x1 = pointerPosition.x / scale;
y1 = pointerPosition.y / scale; y1 = pointerPosition.y / scale;
x2 = pointerPosition.x / scale; x2 = pointerPosition.x / scale;

View File

@ -5,7 +5,6 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon } from 'lucide-react'; import { FileTextIcon } from 'lucide-react';
import { Link } from 'react-router';
import { isDeepEqual } from 'remeda'; import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -21,7 +20,6 @@ import type {
TNameFieldMeta, TNameFieldMeta,
TNumberFieldMeta, TNumberFieldMeta,
TRadioFieldMeta, TRadioFieldMeta,
TSignatureFieldMeta,
TTextFieldMeta, TTextFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
@ -39,7 +37,6 @@ import { EditorFieldInitialsForm } from '~/components/forms/editor/editor-field-
import { EditorFieldNameForm } from '~/components/forms/editor/editor-field-name-form'; import { EditorFieldNameForm } from '~/components/forms/editor/editor-field-name-form';
import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-number-form'; import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-number-form';
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form'; import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form'; import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop'; import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
@ -64,7 +61,7 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
}; };
export const EnvelopeEditorFieldsPage = () => { export const EnvelopeEditorFieldsPage = () => {
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor(); const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -107,12 +104,12 @@ export const EnvelopeEditorFieldsPage = () => {
return ( return (
<div className="relative flex h-full"> <div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto"> <div className="flex w-full flex-col">
{/* Horizontal envelope item selector */} {/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} /> <EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */} {/* Document View */}
<div className="mt-4 flex h-full justify-center p-4"> <div className="mt-4 flex justify-center p-4">
{currentEnvelopeItem !== null ? ( {currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} /> <PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
) : ( ) : (
@ -131,7 +128,7 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Right Section - Form Fields Panel */} {/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && ( {currentEnvelopeItem && (
<div className="bg-background border-border sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l py-4"> <div className="bg-background border-border sticky top-0 h-[calc(100vh-73px)] w-80 flex-shrink-0 overflow-y-auto border-l py-4">
{/* Recipient selector section. */} {/* Recipient selector section. */}
<section className="px-4"> <section className="px-4">
<h3 className="text-foreground mb-2 text-sm font-semibold"> <h3 className="text-foreground mb-2 text-sm font-semibold">
@ -140,14 +137,8 @@ export const EnvelopeEditorFieldsPage = () => {
{envelope.recipients.length === 0 ? ( {envelope.recipients.length === 0 ? (
<Alert variant="warning"> <Alert variant="warning">
<AlertDescription className="flex flex-col gap-2"> <AlertDescription>
<Trans>You need at least one recipient to add fields</Trans> <Trans>You need at least one recipient to add fields</Trans>
<Link to={`${relativePath.editorPath}`} className="text-sm">
<p>
<Trans>Click here to add a recipient</Trans>
</p>
</Link>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : ( ) : (
@ -191,7 +182,7 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Field details section. */} {/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}> <AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && ( {selectedField && selectedField.type !== FieldType.SIGNATURE && (
<section> <section>
<Separator className="my-4" /> <Separator className="my-4" />
@ -201,12 +192,6 @@ export const EnvelopeEditorFieldsPage = () => {
</h3> </h3>
{match(selectedField.type) {match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => ( .with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm <EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined} value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}

View File

@ -37,6 +37,7 @@ export default function EnvelopeEditorHeader() {
updateEnvelope, updateEnvelope,
autosaveError, autosaveError,
relativePath, relativePath,
syncEnvelope,
editorFields, editorFields,
} = useCurrentEnvelopeEditor(); } = useCurrentEnvelopeEditor();
@ -151,7 +152,7 @@ export default function EnvelopeEditorHeader() {
...envelope, ...envelope,
fields: editorFields.localFields, fields: editorFields.localFields,
}} }}
documentRootPath={relativePath.documentRootPath} onDistribute={syncEnvelope}
trigger={ trigger={
<Button size="sm"> <Button size="sm">
<SendIcon className="mr-2 h-4 w-4" /> <SendIcon className="mr-2 h-4 w-4" />

View File

@ -33,7 +33,7 @@ export const EnvelopeEditorPreviewPage = () => {
return ( return (
<div className="relative flex h-full"> <div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto"> <div className="flex w-full flex-col">
{/* Horizontal envelope item selector */} {/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} /> <EnvelopeRendererFileSelector fields={editorFields.localFields} />
@ -82,7 +82,7 @@ export const EnvelopeEditorPreviewPage = () => {
{/* Right Section - Form Fields Panel */} {/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && false && ( {currentEnvelopeItem && false && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4"> <div className="sticky top-0 h-[calc(100vh-73px)] w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
{/* Add fields section. */} {/* Add fields section. */}
<section className="px-4"> <section className="px-4">
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900"> {/* <h3 className="mb-2 text-sm font-semibold text-gray-900">

View File

@ -14,7 +14,7 @@ import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react'; import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form'; import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { isDeepEqual, prop, sortBy } from 'remeda'; import { prop, sortBy } from 'remeda';
import { z } from 'zod'; import { z } from 'zod';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
@ -148,7 +148,8 @@ export const EnvelopeEditorRecipientForm = () => {
}, },
}); });
const recipientHasAuthSettings = useMemo(() => { // Always show advanced settings if any recipient has auth options.
const alwaysShowAdvancedSettings = useMemo(() => {
const recipientHasAuthOptions = recipients.find((recipient) => { const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
@ -164,7 +165,7 @@ export const EnvelopeEditorRecipientForm = () => {
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined; return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]); }, [recipients, form]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(recipientHasAuthSettings); const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false); const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
const { const {
@ -463,7 +464,7 @@ export const EnvelopeEditorRecipientForm = () => {
const formValueSigners = formValues.signers || []; const formValueSigners = formValues.signers || [];
// Remove the last signer if it's empty. // Remove the last signer if it's empty.
const nonEmptyRecipients = formValueSigners.filter((signer, i) => { const recipients = formValueSigners.filter((signer, i) => {
if (i === formValueSigners.length - 1 && signer.email === '') { if (i === formValueSigners.length - 1 && signer.email === '') {
return false; return false;
} }
@ -473,48 +474,26 @@ export const EnvelopeEditorRecipientForm = () => {
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({ const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
...formValues, ...formValues,
signers: nonEmptyRecipients, signers: recipients,
}); });
if (!validatedFormValues.success) { if (validatedFormValues.success) {
return; console.log('validatedFormValues', validatedFormValues);
}
const { data } = validatedFormValues;
const hasSigningOrderChanged = envelope.documentMeta.signingOrder !== data.signingOrder;
const hasAllowDictateNextSignerChanged =
envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner;
const hasSignersChanged =
data.signers.length !== recipients.length ||
data.signers.some((signer) => {
const recipient = recipients.find((recipient) => recipient.id === signer.id);
if (!recipient) {
return true;
}
return (
signer.email !== recipient.email ||
signer.name !== recipient.name ||
signer.role !== recipient.role ||
signer.signingOrder !== recipient.signingOrder ||
!isDeepEqual(signer.actionAuth, recipient.authOptions?.actionAuth)
);
});
if (hasSignersChanged) {
setRecipientsDebounced(validatedFormValues.data.signers); setRecipientsDebounced(validatedFormValues.data.signers);
}
if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) { if (
updateEnvelope({ validatedFormValues.data.signingOrder !== envelope.documentMeta.signingOrder ||
meta: { validatedFormValues.data.allowDictateNextSigner !==
signingOrder: validatedFormValues.data.signingOrder, envelope.documentMeta.allowDictateNextSigner
allowDictateNextSigner: validatedFormValues.data.allowDictateNextSigner, ) {
}, updateEnvelope({
}); meta: {
signingOrder: validatedFormValues.data.signingOrder,
allowDictateNextSigner: validatedFormValues.data.allowDictateNextSigner,
},
});
}
} }
}, [formValues]); }, [formValues]);
@ -555,16 +534,17 @@ export const EnvelopeEditorRecipientForm = () => {
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}> <AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}> <Form {...form}>
<div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4"> <div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4">
{organisation.organisationClaim.flags.cfr21 && ( {!alwaysShowAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<Checkbox <Checkbox
id="showAdvancedRecipientSettings" id="showAdvancedRecipientSettings"
className="h-5 w-5"
checked={showAdvancedSettings} checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))} onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/> />
<label <label
className="ml-2 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className="text-muted-foreground ml-2 text-sm"
htmlFor="showAdvancedRecipientSettings" htmlFor="showAdvancedRecipientSettings"
> >
<Trans>Show advanced settings</Trans> <Trans>Show advanced settings</Trans>
@ -723,48 +703,171 @@ export const EnvelopeEditorRecipientForm = () => {
<motion.fieldset <motion.fieldset
data-native-id={signer.id} data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)} disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', { className={cn('grid grid-cols-10 items-end gap-2 pb-2', {
'border-b pb-4': 'border-b pt-2': showAdvancedSettings,
showAdvancedSettings && index !== signers.length - 1, 'grid-cols-12 pr-3': isSigningOrderSequential,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
})} })}
> >
<div className="flex flex-row items-center gap-x-2"> {isSigningOrderSequential && (
{isSigningOrderSequential && ( <FormField
control={form.control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn(
'col-span-1 mt-auto flex items-center gap-x-1 space-y-0',
{
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
},
)}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-full text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
'col-span-4': !showAdvancedSettings,
'col-span-5': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn({
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
'col-span-4': !showAdvancedSettings,
'col-span-5': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Name`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField <FormField
control={form.control} control={form.control}
name={`signers.${index}.signingOrder`} name={`signers.${index}.actionAuth`}
render={({ field }) => ( render={({ field }) => (
<FormItem <FormItem
className={cn( className={cn('col-span-8', {
'mt-auto flex items-center gap-x-1 space-y-0', 'mb-6':
{ form.formState.errors.signers?.[index] &&
'mb-6': !form.formState.errors.signers[index]?.actionAuth,
form.formState.errors.signers?.[index] && 'col-span-10': isSigningOrderSequential,
!form.formState.errors.signers[index]?.signingOrder, })}
},
)}
> >
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl> <FormControl>
<Input <RecipientActionAuthSelect
type="number"
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field} {...field}
onChange={(e) => { onValueChange={field.onChange}
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={ disabled={
snapshot.isDragging || snapshot.isDragging ||
isSubmitting || isSubmitting ||
@ -772,109 +875,20 @@ export const EnvelopeEditorRecipientForm = () => {
} }
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
)} )}
<FormField <div className="col-span-2 flex gap-x-2">
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Name`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name={`signers.${index}.role`} name={`signers.${index}.role`}
render={({ field }) => ( render={({ field }) => (
<FormItem <FormItem
className={cn('mt-auto w-fit', { className={cn('mt-auto', {
'mb-6': 'mb-6':
form.formState.errors.signers?.[index] && form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role, !form.formState.errors.signers[index]?.role,
@ -902,11 +916,14 @@ export const EnvelopeEditorRecipientForm = () => {
)} )}
/> />
<Button <button
variant="ghost" type="button"
className={cn('mt-auto px-2', { className={cn(
'mb-6': form.formState.errors.signers?.[index], 'mt-auto inline-flex h-10 w-10 items-center justify-center hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
})} {
'mb-6': form.formState.errors.signers?.[index],
},
)}
data-testid="remove-signer-button" data-testid="remove-signer-button"
disabled={ disabled={
snapshot.isDragging || snapshot.isDragging ||
@ -917,40 +934,8 @@ export const EnvelopeEditorRecipientForm = () => {
onClick={() => onRemoveSigner(index)} onClick={() => onRemoveSigner(index)}
> >
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
</Button> </button>
</div> </div>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</motion.fieldset> </motion.fieldset>
</div> </div>
)} )}

View File

@ -355,7 +355,7 @@ export const EnvelopeEditorSettingsDialog = ({
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}> <form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset <fieldset
className="flex h-[45rem] max-h-[calc(100vh-14rem)] w-full flex-col space-y-6 overflow-y-auto px-6 pt-6" className="flex min-h-[45rem] w-full flex-col space-y-6 px-6 pt-6"
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
key={activeTab} key={activeTab}
> >

View File

@ -81,6 +81,7 @@ export default function EnvelopeEditor() {
isAutosaving, isAutosaving,
flushAutosave, flushAutosave,
relativePath, relativePath,
syncEnvelope,
editorFields, editorFields,
} = useCurrentEnvelopeEditor(); } = useCurrentEnvelopeEditor();
@ -156,7 +157,7 @@ export default function EnvelopeEditor() {
<EnvelopeEditorHeader /> <EnvelopeEditorHeader />
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen"> <div className="flex h-[calc(100vh-73px)] w-screen">
{/* Left Section - Step Navigation */} {/* Left Section - Step Navigation */}
<div className="bg-background border-border flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4"> <div className="bg-background border-border flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4">
{/* Left section step selector. */} {/* Left section step selector. */}
@ -250,7 +251,7 @@ export default function EnvelopeEditor() {
...envelope, ...envelope,
fields: editorFields.localFields, fields: editorFields.localFields,
}} }}
documentRootPath={relativePath.documentRootPath} onDistribute={syncEnvelope}
trigger={ trigger={
<Button variant="ghost" size="sm" className="w-full justify-start"> <Button variant="ghost" size="sm" className="w-full justify-start">
<SendIcon className="mr-2 h-4 w-4" /> <SendIcon className="mr-2 h-4 w-4" />
@ -368,14 +369,16 @@ export default function EnvelopeEditor() {
</div> </div>
{/* Main Content - Changes based on current step */} {/* Main Content - Changes based on current step */}
<AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}> <div className="flex-1 overflow-y-auto">
{match({ currentStep, isStepLoading }) <AnimateGenericFadeInOut key={currentStep}>
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />) {match({ currentStep, isStepLoading })
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />) .with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />) .with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />) .with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
.exhaustive()} .with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
</AnimateGenericFadeInOut> .exhaustive()}
</AnimateGenericFadeInOut>
</div>
</div> </div>
</div> </div>
); );

View File

@ -20,8 +20,7 @@ export const EnvelopeItemSelector = ({
}: EnvelopeItemSelectorProps) => { }: EnvelopeItemSelectorProps) => {
return ( return (
<button <button
title={typeof primaryText === 'string' ? primaryText : undefined} className={`flex min-w-0 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
className={`flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
isSelected isSelected
? 'border-green-200 bg-green-50 text-green-900 dark:border-green-400/30 dark:bg-green-400/10 dark:text-green-400' ? 'border-green-200 bg-green-50 text-green-900 dark:border-green-400/30 dark:bg-green-400/10 dark:text-green-400'
: 'border-border bg-muted/50 hover:bg-muted/70' : 'border-border bg-muted/50 hover:bg-muted/70'
@ -40,7 +39,7 @@ export const EnvelopeItemSelector = ({
<div className="text-xs text-gray-500">{secondaryText}</div> <div className="text-xs text-gray-500">{secondaryText}</div>
</div> </div>
<div <div
className={cn('h-2 w-2 flex-shrink-0 rounded-full', { className={cn('h-2 w-2 rounded-full', {
'bg-green-500': isSelected, 'bg-green-500': isSelected,
})} })}
></div> ></div>
@ -62,7 +61,7 @@ export const EnvelopeRendererFileSelector = ({
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender(); const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
return ( return (
<div className={cn('flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4', className)}> <div className={cn('flex h-fit space-x-2 overflow-x-auto p-4', className)}>
{envelopeItems.map((doc, i) => ( {envelopeItems.map((doc, i) => (
<EnvelopeItemSelector <EnvelopeItemSelector
key={doc.id} key={doc.id}

View File

@ -12,7 +12,7 @@ import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
export default function EnvelopeGenericPageRenderer() { export default function EnvelopeGenericPageRenderer() {
const { i18n } = useLingui(); const { i18n } = useLingui();
const { currentEnvelopeItem, fields, getRecipientColorKey } = useCurrentEnvelopeRender(); const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const { const {
stage, stage,
@ -60,7 +60,8 @@ export default function EnvelopeGenericPageRenderer() {
translations: getClientSideFieldTranslations(i18n), translations: getClientSideFieldTranslations(i18n),
pageWidth: unscaledViewport.width, pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height, pageHeight: unscaledViewport.height,
color: getRecipientColorKey(field.recipientId), // color: getRecipientColorKey(field.recipientId),
color: 'purple', // Todo
editable: false, editable: false,
mode: 'sign', mode: 'sign',
}); });
@ -79,7 +80,7 @@ export default function EnvelopeGenericPageRenderer() {
}; };
/** /**
* Render fields when they are added or removed * Render fields when they are added or removed from the localFields.
*/ */
useEffect(() => { useEffect(() => {
if (!pageLayer.current || !stage.current) { if (!pageLayer.current || !stage.current) {
@ -92,12 +93,14 @@ export default function EnvelopeGenericPageRenderer() {
group.name() === 'field-group' && group.name() === 'field-group' &&
!localPageFields.some((field) => field.id.toString() === group.id()) !localPageFields.some((field) => field.id.toString() === group.id())
) { ) {
console.log('Field removed, removing from canvas');
group.destroy(); group.destroy();
} }
}); });
// If it exists, rerender. // If it exists, rerender.
localPageFields.forEach((field) => { localPageFields.forEach((field) => {
console.log('Field created/updated, rendering on canvas');
renderFieldOnLayer(field); renderFieldOnLayer(field);
}); });

View File

@ -6,7 +6,6 @@ import { FolderIcon, HomeIcon } from 'lucide-react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_ENVELOPES_ENABLED } from '@documenso/lib/constants/app';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
@ -99,7 +98,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
</div> </div>
<div className="flex gap-4 sm:flex-row sm:justify-end"> <div className="flex gap-4 sm:flex-row sm:justify-end">
{(IS_ENVELOPES_ENABLED || organisation.organisationClaim.flags.allowEnvelopes) && ( {organisation.organisationClaim.flags.allowEnvelopes && (
<EnvelopeUploadButton type={type} folderId={parentId || undefined} /> <EnvelopeUploadButton type={type} folderId={parentId || undefined} />
)} )}

View File

@ -15,6 +15,7 @@ export type ShareDocumentDownloadButtonProps = {
documentData: DocumentData; documentData: DocumentData;
}; };
// Todo: Envelopes - Support multiple item downloads.
export const ShareDocumentDownloadButton = ({ export const ShareDocumentDownloadButton = ({
title, title,
documentData, documentData,

View File

@ -10,9 +10,9 @@ import { match } from 'ts-pattern';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@ -40,13 +40,17 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
try { try {
setIsLoading(true); setIsLoading(true);
const documentData = await putPdfFile(file); const payload = {
const { legacyTemplateId: id } = await createTemplate({
title: file.name, title: file.name,
templateDocumentDataId: documentData.id,
folderId: folderId ?? undefined, folderId: folderId ?? undefined,
}); } satisfies TCreateTemplatePayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createTemplate(formData);
toast({ toast({
title: _(msg`Template uploaded`), title: _(msg`Template uploaded`),

View File

@ -116,7 +116,7 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
{!user.emailVerified && <VerifyEmailBanner email={user.email} />} {!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{banner && !hideHeader && <AppBanner banner={banner} />} {banner && <AppBanner banner={banner} />}
{!hideHeader && <Header />} {!hideHeader && <Header />}

View File

@ -34,7 +34,6 @@ import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
@ -72,6 +71,23 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
}, },
}); });
const { mutateAsync: promoteToOwner, isPending: isPromotingToOwner } =
trpc.admin.organisationMember.promoteToOwner.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`Member promoted to owner successfully`,
});
},
onError: () => {
toast({
title: t`Error`,
description: t`We couldn't promote the member to owner. Please try again.`,
variant: 'destructive',
});
},
});
const teamsColumns = useMemo(() => { const teamsColumns = useMemo(() => {
return [ return [
{ {
@ -104,24 +120,23 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
}, },
{ {
header: t`Actions`, header: t`Actions`,
cell: ({ row }) => { cell: ({ row }) => (
const isOwner = row.original.userId === organisation?.ownerUserId; <div className="flex justify-end space-x-2">
<Button
return ( variant="outline"
<div className="flex justify-end space-x-2"> disabled={row.original.userId === organisation?.ownerUserId}
<AdminOrganisationMemberUpdateDialog loading={isPromotingToOwner}
trigger={ onClick={async () =>
<Button variant="outline"> promoteToOwner({
<Trans>Update role</Trans> organisationId,
</Button> userId: row.original.userId,
} })
organisationId={organisationId} }
organisationMember={row.original} >
isOwner={isOwner} <Trans>Promote to owner</Trans>
/> </Button>
</div> </div>
); ),
},
}, },
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[]; ] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
}, [organisation]); }, [organisation]);

View File

@ -148,7 +148,6 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<EnvelopeRenderProvider <EnvelopeRenderProvider
envelope={envelope} envelope={envelope}
fields={envelope.status == DocumentStatus.COMPLETED ? [] : envelope.fields} fields={envelope.status == DocumentStatus.COMPLETED ? [] : envelope.fields}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
> >
{isMultiEnvelopeItem && ( {isMultiEnvelopeItem && (
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" /> <EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />

View File

@ -99,11 +99,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
return ( return (
<EnvelopeEditorProvider initialEnvelope={envelope}> <EnvelopeEditorProvider initialEnvelope={envelope}>
<EnvelopeRenderProvider <EnvelopeRenderProvider envelope={envelope}>
envelope={envelope}
fields={envelope.fields}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
>
<EnvelopeEditor /> <EnvelopeEditor />
</EnvelopeRenderProvider> </EnvelopeRenderProvider>
</EnvelopeEditorProvider> </EnvelopeEditorProvider>

View File

@ -168,11 +168,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<div className="mt-6 grid w-full grid-cols-12 gap-8"> <div className="mt-6 grid w-full grid-cols-12 gap-8">
{envelope.internalVersion === 2 ? ( {envelope.internalVersion === 2 ? (
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7"> <div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
<EnvelopeRenderProvider <EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
envelope={envelope}
fields={envelope.fields}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
>
{isMultiEnvelopeItem && ( {isMultiEnvelopeItem && (
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" /> <EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
)} )}

View File

@ -30,6 +30,4 @@ server.use(
const handler = handle(build, server); const handler = handle(build, server);
const port = parseInt(process.env.PORT || '3000', 10); serve({ fetch: handler.fetch, port: 3000 });
serve({ fetch: handler.fetch, port });

View File

@ -1,10 +1,10 @@
import type { Context } from 'hono'; import type { Context } from 'hono';
import { createOpenApiFetchHandler } from 'trpc-to-openapi';
import { API_V2_BETA_URL } from '@documenso/lib/constants/app'; import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error'; import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
import { createTrpcContext } from '@documenso/trpc/server/context'; import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router'; import { appRouter } from '@documenso/trpc/server/router';
import { createOpenApiFetchHandler } from '@documenso/trpc/utils/openapi-fetch-handler';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler'; import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
export const openApiTrpcServerHandler = async (c: Context) => { export const openApiTrpcServerHandler = async (c: Context) => {

View File

@ -21,7 +21,7 @@ export default defineConfig({
}, },
}, },
server: { server: {
port: parseInt(process.env.PORT || '3000', 10), port: 3000,
strictPort: true, strictPort: true,
}, },
plugins: [ plugins: [

820
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -44,7 +44,7 @@
"@commitlint/cli": "^17.7.1", "@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0", "@commitlint/config-conventional": "^17.7.0",
"@lingui/cli": "^5.2.0", "@lingui/cli": "^5.2.0",
"@prisma/client": "^6.8.2", "@prisma/client": "^6.18.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"eslint": "^8.40.0", "eslint": "^8.40.0",
@ -54,11 +54,21 @@
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"playwright": "1.52.0", "playwright": "1.52.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prisma": "^6.8.2", "prisma": "^6.18.0",
"prisma-extension-kysely": "^3.0.0", "prisma-extension-kysely": "^3.0.0",
"prisma-kysely": "^1.8.0", "prisma-kysely": "^1.8.0",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3", "turbo": "^1.9.3",
"@trpc/client": "11.7.0",
"@trpc/react-query": "11.7.0",
"@trpc/server": "11.7.0",
"superjson": "^2.2.5",
"trpc-to-openapi": "2.4.0",
"zod-openapi": "^4.2.4",
"@ts-rest/core": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"@ts-rest/serverless": "^3.52.1",
"zod-prisma-types": "3.3.5",
"vite": "^6.3.5" "vite": "^6.3.5"
}, },
"name": "@documenso/root", "name": "@documenso/root",
@ -76,10 +86,10 @@
"mupdf": "^1.0.0", "mupdf": "^1.0.0",
"react": "^18", "react": "^18",
"typescript": "5.6.2", "typescript": "5.6.2",
"zod": "3.24.1" "zod": "^3.25.76"
}, },
"overrides": { "overrides": {
"zod": "3.24.1" "zod": "^3.25.76"
}, },
"trigger.dev": { "trigger.dev": {
"endpointId": "documenso-app" "endpointId": "documenso-app"

View File

@ -17,14 +17,14 @@
"dependencies": { "dependencies": {
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@ts-rest/core": "^3.30.5", "@ts-rest/core": "^3.52.0",
"@ts-rest/open-api": "^3.33.0", "@ts-rest/open-api": "^3.52.0",
"@ts-rest/serverless": "^3.30.5", "@ts-rest/serverless": "^3.52.0",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^5.18.0",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"superjson": "^1.13.1", "superjson": "^2.2.5",
"swagger-ui-react": "^5.21.0", "swagger-ui-react": "^5.21.0",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "3.24.1" "zod": "^3.25.76"
} }
} }

View File

@ -68,29 +68,15 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
// Test promoting a MEMBER to owner // Test promoting a MEMBER to owner
const memberRow = page.getByRole('row', { name: memberUser.email }); const memberRow = page.getByRole('row', { name: memberUser.email });
// Find and click the "Update role" button for the member // Find and click the "Promote to owner" button for the member
const updateRoleButton = memberRow.getByRole('button', { const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
name: 'Update role', await expect(promoteButton).toBeVisible();
}); await expect(promoteButton).not.toBeDisabled();
await expect(updateRoleButton).toBeVisible();
await expect(updateRoleButton).not.toBeDisabled();
await updateRoleButton.click(); await promoteButton.click();
// Wait for dialog to open and select Owner role // Verify success toast appears
await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload the page to see the changes // Reload the page to see the changes
await page.reload(); await page.reload();
@ -103,18 +89,12 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
const previousOwnerRow = page.getByRole('row', { name: ownerUser.email }); const previousOwnerRow = page.getByRole('row', { name: ownerUser.email });
await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible(); await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Verify that the Update role button exists for the new owner and shows Owner as current role // Verify that the promote button is now disabled for the new owner
const newOwnerUpdateButton = newOwnerRow.getByRole('button', { const newOwnerPromoteButton = newOwnerRow.getByRole('button', { name: 'Promote to owner' });
name: 'Update role', await expect(newOwnerPromoteButton).toBeDisabled();
});
await expect(newOwnerUpdateButton).toBeVisible();
// Verify clicking it shows the dialog with Owner already selected // Test that we can't promote the current owner (button should be disabled)
await newOwnerUpdateButton.click(); await expect(newOwnerPromoteButton).toHaveAttribute('disabled');
await expect(page.getByRole('dialog')).toBeVisible();
// Close the dialog without making changes
await page.getByRole('button', { name: 'Cancel' }).click();
}); });
test('[ADMIN]: promote manager to owner', async ({ page }) => { test('[ADMIN]: promote manager to owner', async ({ page }) => {
@ -150,26 +130,10 @@ test('[ADMIN]: promote manager to owner', async ({ page }) => {
// Promote the manager to owner // Promote the manager to owner
const managerRow = page.getByRole('row', { name: managerUser.email }); const managerRow = page.getByRole('row', { name: managerUser.email });
const updateRoleButton = managerRow.getByRole('button', { const promoteButton = managerRow.getByRole('button', { name: 'Promote to owner' });
name: 'Update role',
});
await updateRoleButton.click(); await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload and verify the change // Reload and verify the change
await page.reload(); await page.reload();
@ -209,27 +173,14 @@ test('[ADMIN]: promote admin member to owner', async ({ page }) => {
// Promote the admin member to owner // Promote the admin member to owner
const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email }); const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email });
const updateRoleButton = adminMemberRow.getByRole('button', { const promoteButton = adminMemberRow.getByRole('button', { name: 'Promote to owner' });
name: 'Update role',
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
}); });
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload and verify the change // Reload and verify the change
await page.reload(); await page.reload();
await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible(); await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
@ -298,25 +249,11 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible(); await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Promote member to owner // Promote member to owner
const updateRoleButton = memberRow.getByRole('button', { const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
name: 'Update role', await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
}); });
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload page to see updated state // Reload page to see updated state
await page.reload(); await page.reload();
@ -325,11 +262,9 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
memberRow = page.getByRole('row', { name: memberUser.email }); memberRow = page.getByRole('row', { name: memberUser.email });
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible(); await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
// Verify the Update role button exists and shows Owner as current role // Verify the promote button is now disabled for the new owner
const newOwnerUpdateButton = memberRow.getByRole('button', { const newOwnerPromoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
name: 'Update role', await expect(newOwnerPromoteButton).toBeDisabled();
});
await expect(newOwnerUpdateButton).toBeVisible();
// Sign in as the newly promoted user to verify they have owner permissions // Sign in as the newly promoted user to verify they have owner permissions
await apiSignin({ await apiSignin({
@ -401,56 +336,28 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
// First promotion: Member 1 becomes owner // First promotion: Member 1 becomes owner
let member1Row = page.getByRole('row', { name: member1User.email }); let member1Row = page.getByRole('row', { name: member1User.email });
let updateRoleButton1 = member1Row.getByRole('button', { let promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
name: 'Update role', await promoteButton1.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
}); });
await updateRoleButton1.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
await page.reload(); await page.reload();
// Verify Member 1 is now owner // Verify Member 1 is now owner and button is disabled
member1Row = page.getByRole('row', { name: member1User.email }); member1Row = page.getByRole('row', { name: member1User.email });
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible(); await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
updateRoleButton1 = member1Row.getByRole('button', { name: 'Update role' }); promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await expect(updateRoleButton1).toBeVisible(); await expect(promoteButton1).toBeDisabled();
// Second promotion: Member 2 becomes the new owner // Second promotion: Member 2 becomes the new owner
const member2Row = page.getByRole('row', { name: member2User.email }); const member2Row = page.getByRole('row', { name: member2User.email });
const updateRoleButton2 = member2Row.getByRole('button', { const promoteButton2 = member2Row.getByRole('button', { name: 'Promote to owner' });
name: 'Update role', await expect(promoteButton2).not.toBeDisabled();
await promoteButton2.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
}); });
await expect(updateRoleButton2).toBeVisible();
await updateRoleButton2.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
await page.reload(); await page.reload();
@ -458,11 +365,9 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible(); await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible(); await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Verify Member 1's Update role button is still visible // Verify Member 1's promote button is now enabled again
const newUpdateButton1 = member1Row.getByRole('button', { const newPromoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
name: 'Update role', await expect(newPromoteButton1).not.toBeDisabled();
});
await expect(newUpdateButton1).toBeVisible();
}); });
test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => { test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => {
@ -497,25 +402,11 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
}); });
const memberRow = page.getByRole('row', { name: memberUser.email }); const memberRow = page.getByRole('row', { name: memberUser.email });
const updateRoleButton = memberRow.getByRole('button', { const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
name: 'Update role', await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
}); });
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Test that the new owner can access organisation settings // Test that the new owner can access organisation settings
await apiSignin({ await apiSignin({

View File

@ -20,6 +20,6 @@
"luxon": "^3.5.0", "luxon": "^3.5.0",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "3.24.1" "zod": "^3.25.76"
} }
} }

View File

@ -19,6 +19,6 @@
"micro": "^10.0.1", "micro": "^10.0.1",
"react": "^18", "react": "^18",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "3.24.1" "zod": "^3.25.76"
} }
} }

View File

@ -50,7 +50,6 @@ type UseEditorFieldsResponse = {
// Field operations // Field operations
addField: (field: Omit<TLocalField, 'formId'>) => TLocalField; addField: (field: Omit<TLocalField, 'formId'>) => TLocalField;
setFieldId: (formId: string, id: number) => void;
removeFieldsByFormId: (formIds: string[]) => void; removeFieldsByFormId: (formIds: string[]) => void;
updateFieldByFormId: (formId: string, updates: Partial<TLocalField>) => void; updateFieldByFormId: (formId: string, updates: Partial<TLocalField>) => void;
duplicateField: (field: TLocalField, recipientId?: number) => TLocalField; duplicateField: (field: TLocalField, recipientId?: number) => TLocalField;
@ -161,17 +160,6 @@ export const useEditorFields = ({
[localFields, remove, triggerFieldsUpdate], [localFields, remove, triggerFieldsUpdate],
); );
const setFieldId = (formId: string, id: number) => {
const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) {
update(index, {
...localFields[index],
id,
});
}
};
const updateFieldByFormId = useCallback( const updateFieldByFormId = useCallback(
(formId: string, updates: Partial<TLocalField>) => { (formId: string, updates: Partial<TLocalField>) => {
const index = localFields.findIndex((field) => field.formId === formId); const index = localFields.findIndex((field) => field.formId === formId);
@ -281,7 +269,6 @@ export const useEditorFields = ({
// Field operations // Field operations
addField, addField,
setFieldId,
removeFieldsByFormId, removeFieldsByFormId,
updateFieldByFormId, updateFieldByFormId,
duplicateField, duplicateField,

View File

@ -97,11 +97,6 @@ export const EnvelopeEditorProvider = ({
const [envelope, setEnvelope] = useState(initialEnvelope); const [envelope, setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false); const [autosaveError, setAutosaveError] = useState<boolean>(false);
const editorFields = useEditorFields({
envelope,
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
});
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({ const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
onSuccess: (response, input) => { onSuccess: (response, input) => {
setEnvelope({ setEnvelope({
@ -189,24 +184,13 @@ export const EnvelopeEditorProvider = ({
triggerSave: setFieldsDebounced, triggerSave: setFieldsDebounced,
flush: setFieldsAsync, flush: setFieldsAsync,
isPending: isFieldsMutationPending, isPending: isFieldsMutationPending,
} = useEnvelopeAutosave(async (localFields: TLocalField[]) => { } = useEnvelopeAutosave(async (fields: TLocalField[]) => {
const envelopeFields = await envelopeFieldSetMutationQuery.mutateAsync({ await envelopeFieldSetMutationQuery.mutateAsync({
envelopeId: envelope.id, envelopeId: envelope.id,
envelopeType: envelope.type, envelopeType: envelope.type,
fields: localFields, fields,
}); });
}, 1000);
// Insert the IDs into the local fields.
envelopeFields.fields.forEach((field) => {
const localField = localFields.find((localField) => localField.formId === field.formId);
if (localField && !localField.id) {
localField.id = field.id;
editorFields.setFieldId(localField.formId, field.id);
}
});
}, 2000);
const { const {
triggerSave: setEnvelopeDebounced, triggerSave: setEnvelopeDebounced,
@ -237,6 +221,11 @@ export const EnvelopeEditorProvider = ({
setEnvelopeDebounced(envelopeUpdates); setEnvelopeDebounced(envelopeUpdates);
}; };
const editorFields = useEditorFields({
envelope,
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
});
const getRecipientColorKey = useCallback( const getRecipientColorKey = useCallback(
(recipientId: number) => { (recipientId: number) => {
const recipientIndex = envelope.recipients.findIndex( const recipientIndex = envelope.recipients.findIndex(

View File

@ -3,9 +3,6 @@ import React from 'react';
import type { DocumentData } from '@prisma/client'; import type { DocumentData } from '@prisma/client';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import type { TEnvelope } from '../../types/envelope'; import type { TEnvelope } from '../../types/envelope';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
@ -26,7 +23,6 @@ type EnvelopeRenderProviderValue = {
currentEnvelopeItem: EnvelopeRenderItem | null; currentEnvelopeItem: EnvelopeRenderItem | null;
setCurrentEnvelopeItem: (envelopeItemId: string) => void; setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: TEnvelope['fields']; fields: TEnvelope['fields'];
getRecipientColorKey: (recipientId: number) => TRecipientColor;
}; };
interface EnvelopeRenderProviderProps { interface EnvelopeRenderProviderProps {
@ -39,13 +35,6 @@ interface EnvelopeRenderProviderProps {
* Only pass if the CustomRenderer you are passing in wants fields. * Only pass if the CustomRenderer you are passing in wants fields.
*/ */
fields?: TEnvelope['fields']; fields?: TEnvelope['fields'];
/**
* Optional recipient IDs used to determine the color of the fields.
*
* Only required for generic page renderers.
*/
recipientIds?: number[];
} }
const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null); const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null);
@ -67,7 +56,6 @@ export const EnvelopeRenderProvider = ({
children, children,
envelope, envelope,
fields, fields,
recipientIds = [],
}: EnvelopeRenderProviderProps) => { }: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId. // Indexed by documentDataId.
const [files, setFiles] = useState<Record<string, FileData>>({}); const [files, setFiles] = useState<Record<string, FileData>>({});
@ -144,17 +132,6 @@ export const EnvelopeRenderProvider = ({
} }
}, [envelope.envelopeItems]); }, [envelope.envelopeItems]);
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
return AVAILABLE_RECIPIENT_COLORS[
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
},
[recipientIds],
);
return ( return (
<EnvelopeRenderContext.Provider <EnvelopeRenderContext.Provider
value={{ value={{
@ -163,7 +140,6 @@ export const EnvelopeRenderProvider = ({
currentEnvelopeItem: currentItem, currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem, setCurrentEnvelopeItem,
fields: fields ?? [], fields: fields ?? [],
getRecipientColorKey,
}} }}
> >
{children} {children}

View File

@ -14,5 +14,3 @@ export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED
export const API_V2_BETA_URL = '/api/v2-beta'; export const API_V2_BETA_URL = '/api/v2-beta';
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com'; export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';
export const IS_ENVELOPES_ENABLED = env('NEXT_PUBLIC_FEATURE_ENVELOPES_ENABLED') === 'true';

View File

@ -189,6 +189,7 @@ export const run = async ({
settings, settings,
}); });
// Todo: Envelopes - Is it okay to have dynamic IDs?
const newDocumentData = await Promise.all( const newDocumentData = await Promise.all(
envelopeItems.map(async (envelopeItem) => envelopeItems.map(async (envelopeItem) =>
io.runTask(`decorate-and-sign-envelope-item-${envelopeItem.id}`, async () => { io.runTask(`decorate-and-sign-envelope-item-${envelopeItem.id}`, async () => {

View File

@ -55,7 +55,7 @@
"skia-canvas": "^3.0.8", "skia-canvas": "^3.0.8",
"stripe": "^12.7.0", "stripe": "^12.7.0",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "3.24.1" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@playwright/browser-chromium": "1.52.0", "@playwright/browser-chromium": "1.52.0",

View File

@ -1,5 +1,7 @@
import { EnvelopeType, TeamMemberRole } from '@prisma/client';
import type { Prisma, User } from '@prisma/client'; import type { Prisma, User } from '@prisma/client';
import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client'; import { SigningStatus } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -213,14 +215,13 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
], ],
}; };
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = { let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause, userId: userIdWhereClause,
createdAt, createdAt,
teamId, teamId,
deletedAt: null, deletedAt: null,
folderId,
}; };
let notSignedCountsGroupByArgs = null; let notSignedCountsGroupByArgs = null;
@ -264,16 +265,8 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
ownerCountsWhereInput = { ownerCountsWhereInput = {
...ownerCountsWhereInput, ...ownerCountsWhereInput,
AND: [ ...visibilityFiltersWhereInput,
...(Array.isArray(visibilityFiltersWhereInput.AND) ...searchFilter,
? visibilityFiltersWhereInput.AND
: visibilityFiltersWhereInput.AND
? [visibilityFiltersWhereInput.AND]
: []),
searchFilter,
rootPageFilter,
folderId ? { folderId } : {},
],
}; };
if (teamEmail) { if (teamEmail) {
@ -292,7 +285,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
}, },
], ],
deletedAt: null, deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
}; };
notSignedCountsGroupByArgs = { notSignedCountsGroupByArgs = {
@ -304,6 +296,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause, userId: userIdWhereClause,
createdAt, createdAt,
folderId,
status: ExtendedDocumentStatus.PENDING, status: ExtendedDocumentStatus.PENDING,
recipients: { recipients: {
some: { some: {
@ -313,7 +306,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
}, },
}, },
deletedAt: null, deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
}, },
} satisfies Prisma.EnvelopeGroupByArgs; } satisfies Prisma.EnvelopeGroupByArgs;
@ -326,6 +318,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause, userId: userIdWhereClause,
createdAt, createdAt,
folderId,
OR: [ OR: [
{ {
status: ExtendedDocumentStatus.PENDING, status: ExtendedDocumentStatus.PENDING,
@ -349,7 +342,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
}, },
}, },
], ],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
}, },
} satisfies Prisma.EnvelopeGroupByArgs; } satisfies Prisma.EnvelopeGroupByArgs;
} }

View File

@ -256,10 +256,11 @@ export const sendDocument = async ({
}); });
} }
// Todo: Envelopes - [AUDIT_LOGS]
if (envelope.internalVersion === 2) { if (envelope.internalVersion === 2) {
const autoInsertedFields = await Promise.all( await Promise.all(
fieldsToAutoInsert.map(async (field) => { fieldsToAutoInsert.map(async (field) => {
return await tx.field.update({ await tx.field.update({
where: { where: {
id: field.fieldId, id: field.fieldId,
}, },
@ -270,21 +271,6 @@ export const sendDocument = async ({
}); });
}), }),
); );
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED,
envelopeId: envelope.id,
data: {
fields: autoInsertedFields.map((field) => ({
fieldId: field.id,
fieldType: field.type,
recipientId: field.recipientId,
})),
},
// Don't put metadata or user here since it's a system event.
}),
});
} }
return await tx.envelope.update({ return await tx.envelope.update({

View File

@ -16,11 +16,16 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { nanoid, prefixedId } from '@documenso/lib/universal/id'; import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth'; import type {
TDocumentAccessAuthTypes,
TDocumentActionAuthTypes,
TRecipientAccessAuthTypes,
TRecipientActionAuthTypes,
} from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values'; import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment'; import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import type { TFieldAndMeta } from '../../types/field-meta';
import { import {
ZWebhookDocumentSchema, ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload, mapEnvelopeToWebhookDocumentPayload,
@ -34,6 +39,25 @@ import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-
import { getTeamSettings } from '../team/get-team-settings'; import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & {
documentDataId: string;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
type CreateEnvelopeRecipientOptions = {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
fields?: CreateEnvelopeRecipientFieldOptions[];
};
export type CreateEnvelopeOptions = { export type CreateEnvelopeOptions = {
userId: number; userId: number;
teamId: number; teamId: number;
@ -56,7 +80,7 @@ export type CreateEnvelopeOptions = {
visibility?: DocumentVisibility; visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[]; globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[]; globalActionAuth?: TDocumentActionAuthTypes[];
recipients?: TCreateEnvelopeRequest['recipients']; recipients?: CreateEnvelopeRecipientOptions[];
folderId?: string; folderId?: string;
}; };
attachments?: Array<{ attachments?: Array<{

View File

@ -306,10 +306,7 @@ export const setFieldsForDocument = async ({
}); });
} }
return { return upsertedField;
...upsertedField,
formId: field.formId,
};
}), }),
); );
}); });
@ -343,25 +340,17 @@ export const setFieldsForDocument = async ({
} }
// Filter out fields that have been removed or have been updated. // Filter out fields that have been removed or have been updated.
const mappedFilteredFields = existingFields const filteredFields = existingFields.filter((field) => {
.filter((field) => { const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
const isRemoved = removedFields.find((removedField) => removedField.id === field.id); const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
return !isRemoved && !isUpdated; return !isRemoved && !isUpdated;
}) });
.map((field) => ({
...mapFieldToLegacyField(field, envelope),
formId: undefined,
}));
const mappedPersistentFields = persistedFields.map((field) => ({
...mapFieldToLegacyField(field, envelope),
formId: field?.formId,
}));
return { return {
fields: [...mappedFilteredFields, ...mappedPersistentFields], fields: [...filteredFields, ...persistedFields].map((field) =>
mapFieldToLegacyField(field, envelope),
),
}; };
}; };
@ -370,7 +359,6 @@ export const setFieldsForDocument = async ({
*/ */
type FieldData = { type FieldData = {
id?: number | null; id?: number | null;
formId?: string;
envelopeItemId: string; envelopeItemId: string;
type: FieldType; type: FieldType;
recipientId: number; recipientId: number;

View File

@ -27,7 +27,6 @@ export type SetFieldsForTemplateOptions = {
id: EnvelopeIdOptions; id: EnvelopeIdOptions;
fields: { fields: {
id?: number | null; id?: number | null;
formId?: string;
envelopeItemId: string; envelopeItemId: string;
type: FieldType; type: FieldType;
recipientId: number; recipientId: number;
@ -112,10 +111,10 @@ export const setFieldsForTemplate = async ({
}; };
}); });
const persistedFields = await Promise.all( const persistedFields = await prisma.$transaction(
// Disabling as wrapping promises here causes type issues // Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async // eslint-disable-next-line @typescript-eslint/promise-function-async
linkedFields.map(async (field) => { linkedFields.map((field) => {
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined; const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
if (field.type === FieldType.TEXT && field.fieldMeta) { if (field.type === FieldType.TEXT && field.fieldMeta) {
@ -177,7 +176,7 @@ export const setFieldsForTemplate = async ({
} }
// Proceed with upsert operation // Proceed with upsert operation
const upsertedField = await prisma.field.upsert({ return prisma.field.upsert({
where: { where: {
id: field._persisted?.id ?? -1, id: field._persisted?.id ?? -1,
envelopeId: envelope.id, envelopeId: envelope.id,
@ -220,11 +219,6 @@ export const setFieldsForTemplate = async ({
}, },
}, },
}); });
return {
...upsertedField,
formId: field.formId,
};
}), }),
); );
@ -246,17 +240,9 @@ export const setFieldsForTemplate = async ({
return !isRemoved && !isUpdated; return !isRemoved && !isUpdated;
}); });
const mappedFilteredFields = filteredFields.map((field) => ({
...mapFieldToLegacyField(field, envelope),
formId: undefined,
}));
const mappedPersistentFields = persistedFields.map((field) => ({
...mapFieldToLegacyField(field, envelope),
formId: field?.formId,
}));
return { return {
fields: [...mappedFilteredFields, ...mappedPersistentFields], fields: [...filteredFields, ...persistedFields].map((field) =>
mapFieldToLegacyField(field, envelope),
),
}; };
}; };

View File

@ -1,13 +1,22 @@
import { PDFDocument } from '@cantoo/pdf-lib'; import { PDFDocument } from '@cantoo/pdf-lib';
import { AppError } from '../../errors/app-error';
import { flattenAnnotations } from './flatten-annotations'; import { flattenAnnotations } from './flatten-annotations';
import { flattenForm, removeOptionalContentGroups } from './flatten-form'; import { flattenForm, removeOptionalContentGroups } from './flatten-form';
export const normalizePdf = async (pdf: Buffer) => { export const normalizePdf = async (pdf: Buffer) => {
const pdfDoc = await PDFDocument.load(pdf).catch(() => null); const pdfDoc = await PDFDocument.load(pdf).catch((e) => {
console.error(`PDF normalization error: ${e.message}`);
if (!pdfDoc) { throw new AppError('INVALID_DOCUMENT_FILE', {
return pdf; message: 'The document is not a valid PDF',
});
});
if (pdfDoc.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is encrypted',
});
} }
removeOptionalContentGroups(pdfDoc); removeOptionalContentGroups(pdfDoc);

File diff suppressed because it is too large Load Diff

View File

@ -257,20 +257,10 @@ msgstr "{prefix} added a field"
msgid "{prefix} added a recipient" msgid "{prefix} added a recipient"
msgstr "{prefix} added a recipient" msgstr "{prefix} added a recipient"
#. placeholder {0}: data.envelopeItemTitle
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} created an envelope item with title {0}"
msgstr "{prefix} created an envelope item with title {0}"
#: packages/lib/utils/document-audit-logs.ts #: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} created the document" msgid "{prefix} created the document"
msgstr "{prefix} created the document" msgstr "{prefix} created the document"
#. placeholder {0}: data.envelopeItemTitle
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} deleted an envelope item with title {0}"
msgstr "{prefix} deleted an envelope item with title {0}"
#: packages/lib/utils/document-audit-logs.ts #: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} deleted the document" msgid "{prefix} deleted the document"
msgstr "{prefix} deleted the document" msgstr "{prefix} deleted the document"
@ -361,7 +351,6 @@ msgstr "{recipientActionVerb} document"
msgid "{recipientActionVerb} the document to complete the process." msgid "{recipientActionVerb} the document to complete the process."
msgstr "{recipientActionVerb} the document to complete the process." msgstr "{recipientActionVerb} the document to complete the process."
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx #: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
msgid "{recipientCount} recipients" msgid "{recipientCount} recipients"
msgstr "{recipientCount} recipients" msgstr "{recipientCount} recipients"
@ -1748,9 +1737,8 @@ msgstr "Attachment added successfully."
#: apps/remix/app/components/general/document/document-attachments-popover.tsx #: apps/remix/app/components/general/document/document-attachments-popover.tsx
msgid "Attachment removed successfully." msgid "Attachment removed successfully."
msgstr "Attachment removed successfully.<<<<<<< Updated upstream=======" msgstr "Attachment removed successfully."
#: apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx
#: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx #: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx
#: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx #: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx
#: apps/remix/app/components/general/document/document-attachments-popover.tsx #: apps/remix/app/components/general/document/document-attachments-popover.tsx
@ -2165,10 +2153,6 @@ msgstr "Clear filters"
msgid "Clear Signature" msgid "Clear Signature"
msgstr "Clear Signature" msgstr "Clear Signature"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Click here to add a recipient"
msgstr "Click here to add a recipient"
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx #: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
msgid "Click here to get started" msgid "Click here to get started"
msgstr "Click here to get started" msgstr "Click here to get started"
@ -2291,7 +2275,6 @@ msgstr "Completed documents"
msgid "Completed Documents" msgid "Completed Documents"
msgstr "Completed Documents" msgstr "Completed Documents"
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx #: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
msgid "Completed on {formattedDate}" msgid "Completed on {formattedDate}"
msgstr "Completed on {formattedDate}" msgstr "Completed on {formattedDate}"
@ -2491,6 +2474,7 @@ msgid "Controls which signatures are allowed to be used when signing a document.
msgstr "Controls which signatures are allowed to be used when signing a document." msgstr "Controls which signatures are allowed to be used when signing a document."
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx #: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx #: packages/ui/primitives/document-flow/add-subject.tsx
msgid "Copied" msgid "Copied"
msgstr "Copied" msgstr "Copied"
@ -2508,12 +2492,14 @@ msgstr "Copied"
#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx #: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx #: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx #: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx #: packages/ui/primitives/document-flow/add-subject.tsx
#: packages/ui/components/document/document-share-button.tsx #: packages/ui/components/document/document-share-button.tsx
msgid "Copied to clipboard" msgid "Copied to clipboard"
msgstr "Copied to clipboard" msgstr "Copied to clipboard"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx #: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx #: packages/ui/primitives/document-flow/add-subject.tsx
msgid "Copy" msgid "Copy"
msgstr "Copy" msgstr "Copy"
@ -2531,7 +2517,6 @@ msgid "Copy Shareable Link"
msgstr "Copy Shareable Link" msgstr "Copy Shareable Link"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx #: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Copy Signing Links" msgid "Copy Signing Links"
msgstr "Copy Signing Links" msgstr "Copy Signing Links"
@ -3656,6 +3641,7 @@ msgstr "Drop your document here"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx
#: packages/ui/primitives/template-flow/add-template-fields.tsx #: packages/ui/primitives/template-flow/add-template-fields.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx #: packages/ui/primitives/document-flow/add-fields.tsx
#: packages/lib/utils/fields.ts
msgid "Dropdown" msgid "Dropdown"
msgstr "Dropdown" msgstr "Dropdown"
@ -4044,14 +4030,6 @@ msgstr "Envelope ID"
msgid "Envelope Item Count" msgid "Envelope Item Count"
msgstr "Envelope Item Count" msgstr "Envelope Item Count"
#: packages/lib/utils/document-audit-logs.ts
msgid "Envelope item created"
msgstr "Envelope item created"
#: packages/lib/utils/document-audit-logs.ts
msgid "Envelope item deleted"
msgstr "Envelope item deleted"
#: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx
msgid "Envelope resent" msgid "Envelope resent"
msgstr "Envelope resent" msgstr "Envelope resent"
@ -5561,6 +5539,7 @@ msgstr "No recipient matching this description was found."
#: apps/remix/app/components/general/template/template-page-view-recipients.tsx #: apps/remix/app/components/general/template/template-page-view-recipients.tsx
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx #: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx #: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx #: packages/ui/primitives/document-flow/add-subject.tsx
msgid "No recipients" msgid "No recipients"
msgstr "No recipients" msgstr "No recipients"
@ -7082,10 +7061,6 @@ msgstr "Select members or groups of members to add to the team."
msgid "Select members to add to this team" msgid "Select members to add to this team"
msgstr "Select members to add to this team" msgstr "Select members to add to this team"
#: packages/lib/utils/fields.ts
msgid "Select Option"
msgstr "Select Option"
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx #: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
msgid "Select passkey" msgid "Select passkey"
msgstr "Select passkey" msgstr "Select passkey"
@ -7890,15 +7865,6 @@ msgstr "Sync Email Domains"
msgid "Sync failed, changes not saved" msgid "Sync failed, changes not saved"
msgstr "Sync failed, changes not saved" msgstr "Sync failed, changes not saved"
#: packages/lib/utils/document-audit-logs.ts
msgctxt "Audit log format"
msgid "System auto inserted fields"
msgstr "System auto inserted fields"
#: packages/lib/utils/document-audit-logs.ts
msgid "System auto inserted fields"
msgstr "System auto inserted fields"
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "System Requirements" msgid "System Requirements"
msgstr "System Requirements" msgstr "System Requirements"
@ -8451,6 +8417,7 @@ msgstr "The signer's name"
#: apps/remix/app/components/general/avatar-with-recipient.tsx #: apps/remix/app/components/general/avatar-with-recipient.tsx
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx #: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx #: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx #: packages/ui/primitives/document-flow/add-subject.tsx
msgid "The signing link has been copied to your clipboard." msgid "The signing link has been copied to your clipboard."
msgstr "The signing link has been copied to your clipboard." msgstr "The signing link has been copied to your clipboard."

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

File diff suppressed because it is too large Load Diff

View File

@ -21,14 +21,10 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'RECIPIENT_DELETED', 'RECIPIENT_DELETED',
'RECIPIENT_UPDATED', 'RECIPIENT_UPDATED',
'ENVELOPE_ITEM_CREATED',
'ENVELOPE_ITEM_DELETED',
// Document events. // Document events.
'DOCUMENT_COMPLETED', // When the document is sealed and fully completed. 'DOCUMENT_COMPLETED', // When the document is sealed and fully completed.
'DOCUMENT_CREATED', // When the document is created. 'DOCUMENT_CREATED', // When the document is created.
'DOCUMENT_DELETED', // When the document is soft deleted. 'DOCUMENT_DELETED', // When the document is soft deleted.
'DOCUMENT_FIELDS_AUTO_INSERTED', // When a field is auto inserted during send due to default values (radio/dropdown/checkbox).
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient. 'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient. 'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
'DOCUMENT_FIELD_PREFILLED', // When a field is prefilled by an assistant. 'DOCUMENT_FIELD_PREFILLED', // When a field is prefilled by an assistant.
@ -185,28 +181,6 @@ const ZBaseRecipientDataSchema = z.object({
recipientRole: z.string(), recipientRole: z.string(),
}); });
/**
* Event: Envelope item created.
*/
export const ZDocumentAuditLogEventEnvelopeItemCreatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED),
data: z.object({
envelopeItemId: z.string(),
envelopeItemTitle: z.string(),
}),
});
/**
* Event: Envelope item deleted.
*/
export const ZDocumentAuditLogEventEnvelopeItemDeletedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED),
data: z.object({
envelopeItemId: z.string(),
envelopeItemTitle: z.string(),
}),
});
/** /**
* Event: Email sent. * Event: Email sent.
*/ */
@ -341,22 +315,6 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
}), }),
}); });
/**
* Event: Document field auto inserted.
*/
export const ZDocumentAuditLogEventDocumentFieldsAutoInsertedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED),
data: z.object({
fields: z.array(
z.object({
fieldId: z.number(),
fieldType: z.nativeEnum(FieldType),
recipientId: z.number(),
}),
),
}),
});
/** /**
* Event: Document field uninserted. * Event: Document field uninserted.
*/ */
@ -694,14 +652,11 @@ export const ZDocumentAuditLogBaseSchema = z.object({
export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
z.union([ z.union([
ZDocumentAuditLogEventEnvelopeItemCreatedSchema,
ZDocumentAuditLogEventEnvelopeItemDeletedSchema,
ZDocumentAuditLogEventEmailSentSchema, ZDocumentAuditLogEventEmailSentSchema,
ZDocumentAuditLogEventDocumentCompletedSchema, ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema, ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentDeletedSchema, ZDocumentAuditLogEventDocumentDeletedSchema,
ZDocumentAuditLogEventDocumentMovedToTeamSchema, ZDocumentAuditLogEventDocumentMovedToTeamSchema,
ZDocumentAuditLogEventDocumentFieldsAutoInsertedSchema,
ZDocumentAuditLogEventDocumentFieldInsertedSchema, ZDocumentAuditLogEventDocumentFieldInsertedSchema,
ZDocumentAuditLogEventDocumentFieldUninsertedSchema, ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
ZDocumentAuditLogEventDocumentFieldPrefilledSchema, ZDocumentAuditLogEventDocumentFieldPrefilledSchema,

View File

@ -71,6 +71,7 @@ export const ZFieldHeightSchema = z.number().min(1).describe('The height of the
// --------------------------------------------- // ---------------------------------------------
// Todo: Envelopes - dunno man
const PrismaDecimalSchema = z.preprocess( const PrismaDecimalSchema = z.preprocess(
(val) => (typeof val === 'string' ? new Prisma.Decimal(val) : val), (val) => (typeof val === 'string' ? new Prisma.Decimal(val) : val),
z.instanceof(Prisma.Decimal, { message: 'Must be a Decimal' }), z.instanceof(Prisma.Decimal, { message: 'Must be a Decimal' }),

View File

@ -129,58 +129,3 @@ export const createSpinner = ({
return loadingGroup; return loadingGroup;
}; };
type CreateFieldHoverInteractionOptions = {
options: RenderFieldElementOptions;
fieldGroup: Konva.Group;
fieldRect: Konva.Rect;
};
/**
* Adds smooth transition-like behavior for hover effects to the field group and rectangle.
*/
export const createFieldHoverInteraction = ({
options,
fieldGroup,
fieldRect,
}: CreateFieldHoverInteractionOptions) => {
const { mode } = options;
if (mode === 'export' || !options.color) {
return;
}
const hoverColor = RECIPIENT_COLOR_STYLES[options.color].baseRingHover;
fieldGroup.on('mouseover', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: hoverColor,
}).play();
});
fieldGroup.on('mouseout', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: DEFAULT_RECT_BACKGROUND,
}).play();
});
fieldGroup.on('transformstart', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: hoverColor,
}).play();
});
fieldGroup.on('transformend', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: DEFAULT_RECT_BACKGROUND,
}).play();
});
};

View File

@ -4,7 +4,6 @@ import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf'; import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TCheckboxFieldMeta } from '../../types/field-meta'; import type { TCheckboxFieldMeta } from '../../types/field-meta';
import { import {
createFieldHoverInteraction,
konvaTextFill, konvaTextFill,
konvaTextFontFamily, konvaTextFontFamily,
upsertFieldGroup, upsertFieldGroup,
@ -27,27 +26,25 @@ export const renderCheckboxFieldElement = (
) => { ) => {
const { pageWidth, pageHeight, pageLayer, mode } = options; const { pageWidth, pageHeight, pageLayer, mode } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight); const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
fieldGroup.add(upsertFieldRect(field, options));
const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null; const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null;
const checkboxValues = checkboxMeta?.values || []; const checkboxValues = checkboxMeta?.values || [];
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`); const fontSize = checkboxMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
// Clear previous children and listeners to re-render fresh.
const fieldGroup = upsertFieldGroup(field, options);
fieldGroup.removeChildren();
fieldGroup.off('transform');
if (isFirstRender) { if (isFirstRender) {
pageLayer.add(fieldGroup); pageLayer.add(fieldGroup);
} }
const fieldRect = upsertFieldRect(field, options);
fieldGroup.add(fieldRect);
const fontSize = checkboxMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
// Handle rescaling items during transforms. // Handle rescaling items during transforms.
fieldGroup.on('transform', () => { fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX(); const groupScaleX = fieldGroup.scaleX();
@ -130,9 +127,11 @@ export const renderCheckboxFieldElement = (
pageLayer.batchDraw(); pageLayer.batchDraw();
}); });
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const checkedValues: number[] = field.customText ? JSON.parse(field.customText) : []; const checkedValues: number[] = field.customText ? JSON.parse(field.customText) : [];
checkboxValues.forEach(({ value, checked }, index) => { checkboxValues.forEach(({ id, value, checked }, index) => {
const isCheckboxChecked = match(mode) const isCheckboxChecked = match(mode)
.with('edit', () => checked) .with('edit', () => checked)
.with('sign', () => checkedValues.includes(index)) .with('sign', () => checkedValues.includes(index))
@ -146,6 +145,8 @@ export const renderCheckboxFieldElement = (
}) })
.exhaustive(); .exhaustive();
console.log('wtf?');
const itemSize = calculateCheckboxSize(fontSize); const itemSize = calculateCheckboxSize(fontSize);
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } = const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
@ -210,8 +211,6 @@ export const renderCheckboxFieldElement = (
fieldGroup.add(text); fieldGroup.add(text);
}); });
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
return { return {
fieldGroup, fieldGroup,
isFirstRender, isFirstRender,

View File

@ -1,10 +1,8 @@
import { FieldType } from '@prisma/client';
import Konva from 'konva'; import Konva from 'konva';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf'; import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TDropdownFieldMeta } from '../../types/field-meta'; import type { TDropdownFieldMeta } from '../../types/field-meta';
import { import {
createFieldHoverInteraction,
konvaTextFill, konvaTextFill,
konvaTextFontFamily, konvaTextFontFamily,
upsertFieldGroup, upsertFieldGroup,
@ -50,30 +48,79 @@ export const renderDropdownFieldElement = (
field: FieldToRender, field: FieldToRender,
options: RenderFieldElementOptions, options: RenderFieldElementOptions,
) => { ) => {
const { pageWidth, pageHeight, pageLayer, mode, translations } = options; const { pageWidth, pageHeight, pageLayer, mode } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const dropdownMeta: TDropdownFieldMeta | null = (field.fieldMeta as TDropdownFieldMeta) || null;
let selectedValue = translations?.[FieldType.DROPDOWN] || 'Select Option';
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`); const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
// Clear previous children to re-render fresh.
const fieldGroup = upsertFieldGroup(field, options); const fieldGroup = upsertFieldGroup(field, options);
fieldGroup.removeChildren();
fieldGroup.off('transform');
const fieldRect = upsertFieldRect(field, options); // Clear previous children to re-render fresh.
fieldGroup.add(fieldRect); fieldGroup.removeChildren();
fieldGroup.add(upsertFieldRect(field, options));
if (isFirstRender) { if (isFirstRender) {
pageLayer.add(fieldGroup); pageLayer.add(fieldGroup);
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
const text = fieldGroup.findOne('.dropdown-selected-text');
const arrow = fieldGroup.findOne('.dropdown-arrow');
if (!fieldRect || !text || !arrow) {
console.log('fieldRect or text or arrow not found');
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const { arrowX, arrowY, textX, textY, textWidth, textHeight } = calculateDropdownPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
});
arrow.setAttrs({
x: arrowX,
y: arrowY,
scaleX: 1,
scaleY: 1,
});
text.setAttrs({
scaleX: 1,
scaleY: 1,
x: textX,
y: textY,
width: textWidth,
height: textHeight,
});
fieldRect.setAttrs({
width: rectWidth,
height: rectHeight,
});
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
} }
const dropdownMeta: TDropdownFieldMeta | null = (field.fieldMeta as TDropdownFieldMeta) || null;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const fontSize = dropdownMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE; const fontSize = dropdownMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
// Todo: Envelopes - Translations
let selectedValue = 'Select Option';
if (field.inserted) { if (field.inserted) {
selectedValue = field.customText; selectedValue = field.customText;
} }
@ -111,63 +158,27 @@ export const renderDropdownFieldElement = (
visible: mode !== 'export', visible: mode !== 'export',
}); });
// Add hover state for dropdown
fieldGroup.on('mouseenter', () => {
// dropdownContainer.stroke('#2563EB');
// dropdownContainer.strokeWidth(2);
document.body.style.cursor = 'pointer';
pageLayer.batchDraw();
});
fieldGroup.on('mouseleave', () => {
// dropdownContainer.stroke('#374151');
// dropdownContainer.strokeWidth(2);
document.body.style.cursor = 'default';
pageLayer.batchDraw();
});
fieldGroup.add(selectedText); fieldGroup.add(selectedText);
if (!field.inserted || mode === 'export') { if (!field.inserted || mode === 'export') {
fieldGroup.add(arrow); fieldGroup.add(arrow);
} }
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
const text = fieldGroup.findOne('.dropdown-selected-text');
const arrow = fieldGroup.findOne('.dropdown-arrow');
if (!fieldRect || !text || !arrow) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const { arrowX, arrowY, textX, textY, textWidth, textHeight } = calculateDropdownPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
});
arrow.setAttrs({
x: arrowX,
y: arrowY,
scaleX: 1,
scaleY: 1,
});
text.setAttrs({
scaleX: 1,
scaleY: 1,
x: textX,
y: textY,
width: textWidth,
height: textHeight,
});
fieldRect.setAttrs({
width: rectWidth,
height: rectHeight,
});
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
return { return {
fieldGroup, fieldGroup,
isFirstRender, isFirstRender,

View File

@ -4,7 +4,6 @@ import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf'; import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TRadioFieldMeta } from '../../types/field-meta'; import type { TRadioFieldMeta } from '../../types/field-meta';
import { import {
createFieldHoverInteraction,
konvaTextFill, konvaTextFill,
konvaTextFontFamily, konvaTextFontFamily,
upsertFieldGroup, upsertFieldGroup,
@ -27,24 +26,25 @@ export const renderRadioFieldElement = (
) => { ) => {
const { pageWidth, pageHeight, pageLayer, mode } = options; const { pageWidth, pageHeight, pageLayer, mode } = options;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children to re-render fresh
fieldGroup.removeChildren();
fieldGroup.add(upsertFieldRect(field, options));
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null; const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
const radioValues = radioMeta?.values || []; const radioValues = radioMeta?.values || [];
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`); const fontSize = radioMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
// Clear previous children and listeners to re-render fresh
const fieldGroup = upsertFieldGroup(field, options);
fieldGroup.removeChildren();
fieldGroup.off('transform');
if (isFirstRender) { if (isFirstRender) {
pageLayer.add(fieldGroup); pageLayer.add(fieldGroup);
} }
const fieldRect = upsertFieldRect(field, options); fieldGroup.off('transform');
fieldGroup.add(fieldRect);
const fontSize = radioMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
// Handle rescaling items during transforms. // Handle rescaling items during transforms.
fieldGroup.on('transform', () => { fieldGroup.on('transform', () => {
@ -195,8 +195,6 @@ export const renderRadioFieldElement = (
fieldGroup.add(text); fieldGroup.add(text);
}); });
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
return { return {
fieldGroup, fieldGroup,
isFirstRender, isFirstRender,

View File

@ -1,12 +1,13 @@
import Konva from 'konva'; import Konva from 'konva';
import {
DEFAULT_RECT_BACKGROUND,
RECIPIENT_COLOR_STYLES,
} from '@documenso/ui/lib/recipient-colors';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../../constants/pdf'; import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../../constants/pdf';
import { AppError } from '../../errors/app-error'; import { AppError } from '../../errors/app-error';
import { import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
createFieldHoverInteraction,
upsertFieldGroup,
upsertFieldRect,
} from './field-generic-items';
import { calculateFieldPosition } from './field-renderer'; import { calculateFieldPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer'; import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
@ -211,7 +212,33 @@ export const renderSignatureFieldElement = (
fieldRect.opacity(0); fieldRect.opacity(0);
} }
createFieldHoverInteraction({ fieldGroup, fieldRect, options }); // Todo: Doesn't work.
if (mode !== 'export') {
const hoverColor = options.color
? RECIPIENT_COLOR_STYLES[options.color].baseRingHover
: '#e5e7eb';
// Todo: Envelopes - On hover add text color
// Add smooth transition-like behavior for hover effects
fieldGroup.on('mouseover', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: hoverColor,
}).play();
});
fieldGroup.on('mouseout', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: DEFAULT_RECT_BACKGROUND,
}).play();
});
fieldGroup.add(fieldRect);
}
return { return {
fieldGroup, fieldGroup,

View File

@ -1,9 +1,13 @@
import Konva from 'konva'; import Konva from 'konva';
import {
DEFAULT_RECT_BACKGROUND,
RECIPIENT_COLOR_STYLES,
} from '@documenso/ui/lib/recipient-colors';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf'; import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TTextFieldMeta } from '../../types/field-meta'; import type { TTextFieldMeta } from '../../types/field-meta';
import { import {
createFieldHoverInteraction,
konvaTextFill, konvaTextFill,
konvaTextFontFamily, konvaTextFontFamily,
upsertFieldGroup, upsertFieldGroup,
@ -15,12 +19,12 @@ import { calculateFieldPosition } from './field-renderer';
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => { const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options; const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options;
const fieldTypeName = translations?.[field.type] || field.type;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight); const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const textMeta = field.fieldMeta as TTextFieldMeta | undefined; const textMeta = field.fieldMeta as TTextFieldMeta | undefined;
const fieldTypeName = translations?.[field.type] || field.type;
const fieldText: Konva.Text = const fieldText: Konva.Text =
pageLayer.findOne(`#${field.renderId}-text`) || pageLayer.findOne(`#${field.renderId}-text`) ||
new Konva.Text({ new Konva.Text({
@ -114,8 +118,9 @@ export const renderTextFieldElement = (
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`); const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
// Clear previous children and listeners to re-render fresh.
const fieldGroup = upsertFieldGroup(field, options); const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren(); fieldGroup.removeChildren();
fieldGroup.off('transform'); fieldGroup.off('transform');
@ -178,7 +183,33 @@ export const renderTextFieldElement = (
fieldRect.opacity(0); fieldRect.opacity(0);
} }
createFieldHoverInteraction({ fieldGroup, fieldRect, options }); // Todo: Doesn't work.
if (mode !== 'export') {
const hoverColor = options.color
? RECIPIENT_COLOR_STYLES[options.color].baseRingHover
: '#e5e7eb';
// Todo: Envelopes - On hover add text color
// Add smooth transition-like behavior for hover effects
fieldGroup.on('mouseover', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: hoverColor,
}).play();
});
fieldGroup.on('mouseout', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: DEFAULT_RECT_BACKGROUND,
}).play();
});
fieldGroup.add(fieldRect);
}
return { return {
fieldGroup, fieldGroup,

View File

@ -7,6 +7,7 @@ import { env } from '@documenso/lib/utils/env';
import { AppError } from '../../errors/app-error'; import { AppError } from '../../errors/app-error';
import { createDocumentData } from '../../server-only/document-data/create-document-data'; import { createDocumentData } from '../../server-only/document-data/create-document-data';
import { normalizePdf } from '../../server-only/pdf/normalize-pdf';
import { uploadS3File } from './server-actions'; import { uploadS3File } from './server-actions';
type File = { type File = {
@ -43,6 +44,28 @@ export const putPdfFileServerSide = async (file: File) => {
return await createDocumentData({ type, data }); return await createDocumentData({ type, data });
}; };
/**
* Uploads a pdf file and normalizes it.
*/
export const putNormalizedPdfFileServerSide = async (file: File) => {
const buffer = Buffer.from(await file.arrayBuffer());
const normalized = await normalizePdf(buffer);
const fileName = file.name.endsWith('.pdf') ? file.name : `${file.name}.pdf`;
const documentData = await putFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalized),
});
return await createDocumentData({
type: documentData.type,
data: documentData.data,
});
};
/** /**
* Uploads a file to the appropriate storage location. * Uploads a file to the appropriate storage location.
*/ */

View File

@ -353,13 +353,6 @@ export const formatDocumentAuditLogAction = (
}), }),
identified: msg`${prefix} deleted the document`, identified: msg`${prefix} deleted the document`,
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED }, () => ({
anonymous: msg({
message: `System auto inserted fields`,
context: `Audit log format`,
}),
identified: msg`System auto inserted fields`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
anonymous: msg({ anonymous: msg({
message: `Field signed`, message: `Field signed`,
@ -522,14 +515,6 @@ export const formatDocumentAuditLogAction = (
context: `Audit log format`, context: `Audit log format`,
}), }),
})) }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED }, ({ data }) => ({
anonymous: msg`Envelope item created`,
identified: msg`${prefix} created an envelope item with title ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED }, ({ data }) => ({
anonymous: msg`Envelope item deleted`,
identified: msg`${prefix} deleted an envelope item with title ${data.envelopeItemTitle}`,
}))
.exhaustive(); .exhaustive();
return { return {

View File

@ -101,7 +101,7 @@ export const getClientSideFieldTranslations = ({ t }: I18n): Record<FieldType, s
[FieldType.TEXT]: t(msg`Text`), [FieldType.TEXT]: t(msg`Text`),
[FieldType.CHECKBOX]: t(msg`Checkbox`), [FieldType.CHECKBOX]: t(msg`Checkbox`),
[FieldType.RADIO]: t(msg`Radio`), [FieldType.RADIO]: t(msg`Radio`),
[FieldType.DROPDOWN]: t(msg`Select Option`), [FieldType.DROPDOWN]: t(msg`Dropdown`),
[FieldType.SIGNATURE]: t(msg`Signature`), [FieldType.SIGNATURE]: t(msg`Signature`),
[FieldType.FREE_SIGNATURE]: t(msg`Free Signature`), [FieldType.FREE_SIGNATURE]: t(msg`Free Signature`),
[FieldType.INITIALS]: t(msg`Initials`), [FieldType.INITIALS]: t(msg`Initials`),

View File

@ -21,14 +21,14 @@
"seed": "tsx ./seed-database.ts" "seed": "tsx ./seed-database.ts"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.8.2", "@prisma/client": "^6.18.0",
"kysely": "0.26.3", "kysely": "0.26.3",
"prisma": "^6.8.2", "prisma": "^6.18.0",
"prisma-extension-kysely": "^3.0.0", "prisma-extension-kysely": "^3.0.0",
"prisma-kysely": "^1.8.0", "prisma-kysely": "^1.8.0",
"prisma-json-types-generator": "^3.2.2", "prisma-json-types-generator": "^3.6.2",
"ts-pattern": "^5.0.6", "ts-pattern": "^5.0.6",
"zod-prisma-types": "3.2.4" "zod-prisma-types": "3.3.5"
}, },
"devDependencies": { "devDependencies": {
"dotenv": "^16.5.0", "dotenv": "^16.5.0",

View File

@ -134,8 +134,8 @@ model Passkey {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) updatedAt DateTime @default(now())
lastUsedAt DateTime? lastUsedAt DateTime?
credentialId Bytes credentialId Bytes /// @zod.custom.use(z.instanceof(Uint8Array))
credentialPublicKey Bytes credentialPublicKey Bytes /// @zod.custom.use(z.instanceof(Uint8Array))
counter BigInt counter BigInt
credentialDeviceType String credentialDeviceType String
credentialBackedUp Boolean credentialBackedUp Boolean

View File

@ -1,17 +1,23 @@
import { createTRPCClient, httpBatchLink, httpLink, splitLink } from '@trpc/client'; import {
import SuperJSON from 'superjson'; createTRPCClient,
httpBatchLink,
httpLink,
isNonJsonSerializable,
splitLink,
} from '@trpc/client';
import { getBaseUrl } from '@documenso/lib/universal/get-base-url'; import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
import type { AppRouter } from '../server/router'; import type { AppRouter } from '../server/router';
import { dataTransformer } from '../utils/data-transformer';
export const trpc = createTRPCClient<AppRouter>({ export const trpc = createTRPCClient<AppRouter>({
links: [ links: [
splitLink({ splitLink({
condition: (op) => op.context.skipBatch === true, condition: (op) => op.context.skipBatch === true || isNonJsonSerializable(op.input),
true: httpLink({ true: httpLink({
url: `${getBaseUrl()}/api/trpc`, url: `${getBaseUrl()}/api/trpc`,
transformer: SuperJSON, transformer: dataTransformer,
headers: (opts) => { headers: (opts) => {
if (typeof opts.op.context.teamId === 'string') { if (typeof opts.op.context.teamId === 'string') {
return { return {
@ -24,7 +30,7 @@ export const trpc = createTRPCClient<AppRouter>({
}), }),
false: httpBatchLink({ false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`, url: `${getBaseUrl()}/api/trpc`,
transformer: SuperJSON, transformer: dataTransformer,
headers: (opts) => { headers: (opts) => {
const operationWithTeamId = opts.opList.find( const operationWithTeamId = opts.opList.find(
(op) => op.context.teamId && typeof op.context.teamId === 'string', (op) => op.context.teamId && typeof op.context.teamId === 'string',

View File

@ -12,15 +12,21 @@
"dependencies": { "dependencies": {
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@tanstack/react-query": "5.59.15", "@tanstack/react-query": "5.90.5",
"@trpc/client": "11.0.0-rc.648", "@trpc/client": "11.7.0",
"@trpc/react-query": "11.0.0-rc.648", "@trpc/react-query": "11.7.0",
"@trpc/server": "11.0.0-rc.648", "@trpc/server": "11.7.0",
"@ts-rest/core": "^3.30.5", "@ts-rest/core": "^3.52.0",
"formidable": "^3.5.4",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"superjson": "^1.13.1", "superjson": "^2.2.5",
"trpc-to-openapi": "2.0.4", "trpc-to-openapi": "2.4.0",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "3.24.1" "zod": "^3.25.76",
"zod-form-data": "^2.0.8",
"zod-openapi": "^4.2.4"
},
"devDependencies": {
"@types/formidable": "^3.4.6"
} }
} }

View File

@ -1,13 +1,13 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, httpLink, splitLink } from '@trpc/client'; import { httpBatchLink, httpLink, isNonJsonSerializable, splitLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query'; import { createTRPCReact } from '@trpc/react-query';
import SuperJSON from 'superjson';
import { getBaseUrl } from '@documenso/lib/universal/get-base-url'; import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
import type { AppRouter } from '../server/router'; import type { AppRouter } from '../server/router';
import { dataTransformer } from '../utils/data-transformer';
export { getQueryKey } from '@trpc/react-query'; export { getQueryKey } from '@trpc/react-query';
@ -44,16 +44,16 @@ export function TrpcProvider({ children, headers }: TrpcProviderProps) {
trpc.createClient({ trpc.createClient({
links: [ links: [
splitLink({ splitLink({
condition: (op) => op.context.skipBatch === true, condition: (op) => op.context.skipBatch === true || isNonJsonSerializable(op.input),
true: httpLink({ true: httpLink({
url: `${getBaseUrl()}/api/trpc`, url: `${getBaseUrl()}/api/trpc`,
headers, headers,
transformer: SuperJSON, transformer: dataTransformer,
}), }),
false: httpBatchLink({ false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`, url: `${getBaseUrl()}/api/trpc`,
headers, headers,
transformer: SuperJSON, transformer: dataTransformer,
}), }),
}), }),
], ],

View File

@ -39,11 +39,6 @@ export const getAdminOrganisation = async ({ organisationId }: GetOrganisationOp
teams: true, teams: true,
members: { members: {
include: { include: {
organisationGroupMembers: {
include: {
group: true,
},
},
user: { user: {
select: { select: {
id: true, id: true,

View File

@ -3,8 +3,6 @@ import { z } from 'zod';
import { ZOrganisationSchema } from '@documenso/lib/types/organisation'; import { ZOrganisationSchema } from '@documenso/lib/types/organisation';
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema'; import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema'; import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import OrganisationGroupMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupMemberSchema';
import OrganisationGroupSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupSchema';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema'; import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema'; import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema'; import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
@ -32,18 +30,6 @@ export const ZGetAdminOrganisationResponseSchema = ZOrganisationSchema.extend({
email: true, email: true,
name: true, name: true,
}), }),
organisationGroupMembers: z.array(
OrganisationGroupMemberSchema.pick({
id: true,
groupId: true,
}).extend({
group: OrganisationGroupSchema.pick({
id: true,
type: true,
organisationRole: true,
}),
}),
),
}).array(), }).array(),
subscription: SubscriptionSchema.nullable(), subscription: SubscriptionSchema.nullable(),
organisationClaim: OrganisationClaimSchema, organisationClaim: OrganisationClaimSchema,

View File

@ -17,7 +17,6 @@ import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
import { resealDocumentRoute } from './reseal-document'; import { resealDocumentRoute } from './reseal-document';
import { resetTwoFactorRoute } from './reset-two-factor-authentication'; import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import { updateAdminOrganisationRoute } from './update-admin-organisation'; import { updateAdminOrganisationRoute } from './update-admin-organisation';
import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role';
import { updateRecipientRoute } from './update-recipient'; import { updateRecipientRoute } from './update-recipient';
import { updateSiteSettingRoute } from './update-site-setting'; import { updateSiteSettingRoute } from './update-site-setting';
import { updateSubscriptionClaimRoute } from './update-subscription-claim'; import { updateSubscriptionClaimRoute } from './update-subscription-claim';
@ -32,7 +31,6 @@ export const adminRouter = router({
}, },
organisationMember: { organisationMember: {
promoteToOwner: promoteMemberToOwnerRoute, promoteToOwner: promoteMemberToOwnerRoute,
updateRole: updateOrganisationMemberRoleRoute,
}, },
claims: { claims: {
find: findSubscriptionClaimsRoute, find: findSubscriptionClaimsRoute,

View File

@ -1,220 +0,0 @@
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZUpdateOrganisationMemberRoleRequestSchema,
ZUpdateOrganisationMemberRoleResponseSchema,
} from './update-organisation-member-role.types';
/**
* Admin mutation to update organisation member role or transfer ownership.
*
* This mutation handles two scenarios:
* 1. When role='OWNER': Transfers organisation ownership and promotes to ADMIN
* 2. When role=ADMIN/MANAGER/MEMBER: Updates group membership
*
* Admin privileges bypass normal hierarchy restrictions.
*/
export const updateOrganisationMemberRoleRoute = adminProcedure
.input(ZUpdateOrganisationMemberRoleRequestSchema)
.output(ZUpdateOrganisationMemberRoleResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, userId, role } = input;
ctx.logger.info({
input: {
organisationId,
userId,
role,
},
});
const organisation = await prisma.organisation.findUnique({
where: {
id: organisationId,
},
include: {
groups: {
where: {
type: OrganisationGroupType.INTERNAL_ORGANISATION,
},
},
members: {
where: {
userId,
},
include: {
organisationGroupMembers: {
include: {
group: true,
},
},
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
const [member] = organisation.members;
if (!member) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User is not a member of this organisation',
});
}
const currentOrganisationRole = getHighestOrganisationRoleInGroup(
member.organisationGroupMembers.flatMap((member) => member.group),
);
if (role === 'OWNER') {
if (organisation.ownerUserId === userId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'User is already the owner of this organisation',
});
}
const currentMemberGroup = organisation.groups.find(
(group) => group.organisationRole === currentOrganisationRole,
);
const adminGroup = organisation.groups.find(
(group) => group.organisationRole === OrganisationMemberRole.ADMIN,
);
if (!currentMemberGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
role: currentOrganisationRole,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Current member group not found',
});
}
if (!adminGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
targetRole: 'ADMIN',
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Admin group not found',
});
}
await prisma.$transaction(async (tx) => {
await tx.organisation.update({
where: {
id: organisationId,
},
data: {
ownerUserId: userId,
},
});
if (currentOrganisationRole !== OrganisationMemberRole.ADMIN) {
await tx.organisationGroupMember.delete({
where: {
organisationMemberId_groupId: {
organisationMemberId: member.id,
groupId: currentMemberGroup.id,
},
},
});
await tx.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
organisationMemberId: member.id,
groupId: adminGroup.id,
},
});
}
});
return;
}
const targetRole = role as OrganisationMemberRole;
if (currentOrganisationRole === targetRole) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'User already has this role',
});
}
if (userId === organisation.ownerUserId && targetRole !== OrganisationMemberRole.ADMIN) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Organisation owner must be an admin. Transfer ownership first.',
});
}
const currentMemberGroup = organisation.groups.find(
(group) => group.organisationRole === currentOrganisationRole,
);
const newMemberGroup = organisation.groups.find(
(group) => group.organisationRole === targetRole,
);
if (!currentMemberGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
role: currentOrganisationRole,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Current member group not found',
});
}
if (!newMemberGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
targetRole,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'New member group not found',
});
}
await prisma.$transaction(async (tx) => {
await tx.organisationGroupMember.delete({
where: {
organisationMemberId_groupId: {
organisationMemberId: member.id,
groupId: currentMemberGroup.id,
},
},
});
await tx.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
organisationMemberId: member.id,
groupId: newMemberGroup.id,
},
});
});
});

View File

@ -1,30 +0,0 @@
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
/**
* Admin-only role selection that includes OWNER as a special case.
* OWNER is not a database role but triggers ownership transfer.
*/
export const ZAdminRoleSelection = z.enum([
'OWNER',
OrganisationMemberRole.ADMIN,
OrganisationMemberRole.MANAGER,
OrganisationMemberRole.MEMBER,
]);
export type TAdminRoleSelection = z.infer<typeof ZAdminRoleSelection>;
export const ZUpdateOrganisationMemberRoleRequestSchema = z.object({
organisationId: z.string().min(1),
userId: z.number().min(1),
role: ZAdminRoleSelection,
});
export const ZUpdateOrganisationMemberRoleResponseSchema = z.void();
export type TUpdateOrganisationMemberRoleRequest = z.infer<
typeof ZUpdateOrganisationMemberRoleRequestSchema
>;
export type TUpdateOrganisationMemberRoleResponse = z.infer<
typeof ZUpdateOrganisationMemberRoleResponseSchema
>;

View File

@ -0,0 +1,136 @@
import { EnvelopeType } from '@prisma/client';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZCreateDocumentFormDataRequestSchema,
ZCreateDocumentFormDataResponseSchema,
createDocumentFormDataMeta,
} from './create-document-formdata.types';
/**
* Temporary endpoint for V2 Beta until we allow passthrough documents on create.
*
* @public
*/
export const createDocumentFormDataRoute = authenticatedProcedure
.meta(createDocumentFormDataMeta)
.input(ZCreateDocumentFormDataRequestSchema)
.output(ZCreateDocumentFormDataResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { payload, file } = input;
const {
title,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
recipients,
meta,
folderId,
attachments,
} = payload;
const { remaining } = await getServerLimits({ userId: user.id, teamId });
if (remaining.documents <= 0) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'You have reached your document limit for this month. Please upgrade your plan.',
statusCode: 400,
});
}
const documentData = await putPdfFileServerSide(file);
const createdEnvelope = await createEnvelope({
userId: ctx.user.id,
teamId,
normalizePdf: false, // Not normalizing because of presigned URL.
internalVersion: 1,
data: {
type: EnvelopeType.DOCUMENT,
title,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
recipients: (recipients || []).map((recipient) => ({
...recipient,
fields: (recipient.fields || []).map((field) => ({
...field,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
documentDataId: documentData.id,
})),
})),
folderId,
envelopeItems: [
{
// If you ever allow more than 1 in this endpoint, make sure to use `maximumEnvelopeItemCount` to limit it.
documentDataId: documentData.id,
},
],
},
attachments,
meta: {
...meta,
emailSettings: meta?.emailSettings ?? undefined,
},
requestMetadata: ctx.metadata,
});
const envelopeItems = await prisma.envelopeItem.findMany({
where: {
envelopeId: createdEnvelope.id,
},
include: {
documentData: true,
},
});
const legacyDocumentId = mapSecondaryIdToDocumentId(createdEnvelope.secondaryId);
const firstDocumentData = envelopeItems[0].documentData;
if (!firstDocumentData) {
throw new Error('Document data not found');
}
return {
document: {
...createdEnvelope,
envelopeId: createdEnvelope.id,
documentDataId: firstDocumentData.id,
documentData: {
...firstDocumentData,
envelopeItemId: envelopeItems[0].id,
},
documentMeta: {
...createdEnvelope.documentMeta,
documentId: legacyDocumentId,
},
id: legacyDocumentId,
fields: createdEnvelope.fields.map((field) => ({
...field,
documentId: legacyDocumentId,
templateId: null,
})),
recipients: createdEnvelope.recipients.map((recipient) => ({
...recipient,
documentId: legacyDocumentId,
templateId: null,
})),
},
folder: createdEnvelope.folder, // Todo: Remove this prior to api-v2 release.
};
});

View File

@ -0,0 +1,97 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { ZDocumentSchema } from '@documenso/lib/types/document';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
import { ZDocumentMetaCreateSchema } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import {
ZFieldHeightSchema,
ZFieldPageNumberSchema,
ZFieldPageXSchema,
ZFieldPageYSchema,
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { zodFormData } from '../../utils/zod-form-data';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc';
import {
ZDocumentExternalIdSchema,
ZDocumentTitleSchema,
ZDocumentVisibilitySchema,
} from './schema';
export const createDocumentFormDataMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/document/create/formdata',
contentTypes: ['multipart/form-data'],
summary: 'Create document',
description: 'Create a document using form data.',
tags: ['Document'],
},
};
const ZCreateDocumentFormDataPayloadRequestSchema = z.object({
title: ZDocumentTitleSchema,
externalId: ZDocumentExternalIdSchema.optional(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
formValues: ZDocumentFormValuesSchema.optional(),
folderId: z
.string()
.describe(
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
)
.optional(),
recipients: z
.array(
ZCreateRecipientSchema.extend({
fields: ZFieldAndMetaSchema.and(
z.object({
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
}),
)
.array()
.optional(),
}),
)
.optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
meta: ZDocumentMetaCreateSchema.optional(),
});
// !: Can't use zfd.formData() here because it receives `undefined`
// !: somewhere in the pipeline of our openapi schema generation and throws
// !: an error.
export const ZCreateDocumentFormDataRequestSchema = zodFormData({
payload: zfd.json(ZCreateDocumentFormDataPayloadRequestSchema),
file: zfd.file(),
});
export const ZCreateDocumentFormDataResponseSchema = z.object({
document: ZDocumentSchema,
});
export type TCreateDocumentFormDataRequest = z.infer<typeof ZCreateDocumentFormDataRequestSchema>;
export type TCreateDocumentFormDataResponse = z.infer<typeof ZCreateDocumentFormDataResponseSchema>;

View File

@ -3,6 +3,7 @@ import { EnvelopeType } from '@prisma/client';
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope'; import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { authenticatedProcedure } from '../trpc'; import { authenticatedProcedure } from '../trpc';
@ -16,7 +17,12 @@ export const createDocumentRoute = authenticatedProcedure
.output(ZCreateDocumentResponseSchema) .output(ZCreateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx; const { user, teamId } = ctx;
const { title, documentDataId, timezone, folderId, attachments } = input;
const { payload, file } = input;
const { title, timezone, folderId, attachments } = payload;
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file);
ctx.logger.info({ ctx.logger.info({
input: { input: {
@ -55,6 +61,7 @@ export const createDocumentRoute = authenticatedProcedure
}); });
return { return {
envelopeId: document.id,
legacyDocumentId: mapSecondaryIdToDocumentId(document.secondaryId), legacyDocumentId: mapSecondaryIdToDocumentId(document.secondaryId),
}; };
}); });

View File

@ -1,23 +1,27 @@
import { z } from 'zod'; import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta'; import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment'; import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { zodFormData } from '../../utils/zod-form-data';
import type { TrpcRouteMeta } from '../trpc';
import { ZDocumentTitleSchema } from './schema'; import { ZDocumentTitleSchema } from './schema';
// Currently not in use until we allow passthrough documents on create. // Currently not in use until we allow passthrough documents on create.
// export const createDocumentMeta: TrpcRouteMeta = { export const createDocumentMeta: TrpcRouteMeta = {
// openapi: { openapi: {
// method: 'POST', method: 'POST',
// path: '/document/create', path: '/document/create',
// summary: 'Create document', contentTypes: ['multipart/form-data'],
// tags: ['Document'], summary: 'Create document',
// }, description: 'Create a document using form data.',
// }; tags: ['Document'],
},
};
export const ZCreateDocumentRequestSchema = z.object({ export const ZCreateDocumentPayloadSchema = z.object({
title: ZDocumentTitleSchema, title: ZDocumentTitleSchema,
documentDataId: z.string().min(1),
timezone: ZDocumentMetaTimezoneSchema.optional(), timezone: ZDocumentMetaTimezoneSchema.optional(),
folderId: z.string().describe('The ID of the folder to create the document in').optional(), folderId: z.string().describe('The ID of the folder to create the document in').optional(),
attachments: z attachments: z
@ -31,9 +35,16 @@ export const ZCreateDocumentRequestSchema = z.object({
.optional(), .optional(),
}); });
export const ZCreateDocumentRequestSchema = zodFormData({
payload: zfd.json(ZCreateDocumentPayloadSchema),
file: zfd.file(),
});
export const ZCreateDocumentResponseSchema = z.object({ export const ZCreateDocumentResponseSchema = z.object({
envelopeId: z.string(),
legacyDocumentId: z.number(), legacyDocumentId: z.number(),
}); });
export type TCreateDocumentPayloadSchema = z.infer<typeof ZCreateDocumentPayloadSchema>;
export type TCreateDocumentRequest = z.infer<typeof ZCreateDocumentRequestSchema>; export type TCreateDocumentRequest = z.infer<typeof ZCreateDocumentRequestSchema>;
export type TCreateDocumentResponse = z.infer<typeof ZCreateDocumentResponseSchema>; export type TCreateDocumentResponse = z.infer<typeof ZCreateDocumentResponseSchema>;

View File

@ -5,6 +5,7 @@ import { deleteAttachmentRoute } from './attachment/delete-attachment';
import { findAttachmentsRoute } from './attachment/find-attachments'; import { findAttachmentsRoute } from './attachment/find-attachments';
import { updateAttachmentRoute } from './attachment/update-attachment'; import { updateAttachmentRoute } from './attachment/update-attachment';
import { createDocumentRoute } from './create-document'; import { createDocumentRoute } from './create-document';
import { createDocumentFormDataRoute } from './create-document-formdata';
import { createDocumentTemporaryRoute } from './create-document-temporary'; import { createDocumentTemporaryRoute } from './create-document-temporary';
import { deleteDocumentRoute } from './delete-document'; import { deleteDocumentRoute } from './delete-document';
import { distributeDocumentRoute } from './distribute-document'; import { distributeDocumentRoute } from './distribute-document';
@ -40,6 +41,7 @@ export const documentRouter = router({
// Temporary v2 beta routes to be removed once V2 is fully released. // Temporary v2 beta routes to be removed once V2 is fully released.
download: downloadDocumentRoute, download: downloadDocumentRoute,
createDocumentTemporary: createDocumentTemporaryRoute, createDocumentTemporary: createDocumentTemporaryRoute,
createDocumentFormData: createDocumentFormDataRoute,
// Internal document routes for custom frontend requests. // Internal document routes for custom frontend requests.
getDocumentByToken: getDocumentByTokenRoute, getDocumentByToken: getDocumentByTokenRoute,

View File

@ -1,8 +1,6 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { prefixedId } from '@documenso/lib/universal/id'; import { prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -16,7 +14,7 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
.input(ZCreateEnvelopeItemsRequestSchema) .input(ZCreateEnvelopeItemsRequestSchema)
.output(ZCreateEnvelopeItemsResponseSchema) .output(ZCreateEnvelopeItemsResponseSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { user, teamId, metadata } = ctx; const { user, teamId } = ctx;
const { envelopeId, items } = input; const { envelopeId, items } = input;
ctx.logger.info({ ctx.logger.info({
@ -112,39 +110,17 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
const currentHighestOrderValue = const currentHighestOrderValue =
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1; envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
const result = await prisma.$transaction(async (tx) => { const result = await prisma.envelopeItem.createManyAndReturn({
const createdItems = await tx.envelopeItem.createManyAndReturn({ data: items.map((item) => ({
data: items.map((item) => ({ id: prefixedId('envelope_item'),
id: prefixedId('envelope_item'), envelopeId,
envelopeId, title: item.title,
title: item.title, documentDataId: item.documentDataId,
documentDataId: item.documentDataId, order: currentHighestOrderValue + 1,
order: currentHighestOrderValue + 1, })),
})), include: {
include: { documentData: true,
documentData: true, },
},
});
await tx.documentAuditLog.createMany({
data: createdItems.map((item) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED,
envelopeId: envelope.id,
data: {
envelopeItemId: item.id,
envelopeItemTitle: item.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: metadata.requestMetadata,
}),
),
});
return createdItems;
}); });
return { return {

View File

@ -1,6 +1,7 @@
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope'; import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { authenticatedProcedure } from '../trpc'; import { authenticatedProcedure } from '../trpc';
import { import {
@ -13,6 +14,9 @@ export const createEnvelopeRoute = authenticatedProcedure
.output(ZCreateEnvelopeResponseSchema) .output(ZCreateEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx; const { user, teamId } = ctx;
const { payload, files } = input;
const { const {
title, title,
type, type,
@ -22,10 +26,9 @@ export const createEnvelopeRoute = authenticatedProcedure
globalActionAuth, globalActionAuth,
recipients, recipients,
folderId, folderId,
items,
meta, meta,
attachments, attachments,
} = input; } = payload;
ctx.logger.info({ ctx.logger.info({
input: { input: {
@ -45,13 +48,62 @@ export const createEnvelopeRoute = authenticatedProcedure
}); });
} }
if (items.length > maximumEnvelopeItemCount) { if (files.length > maximumEnvelopeItemCount) {
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', { throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
message: `You cannot upload more than ${maximumEnvelopeItemCount} envelope items per envelope`, message: `You cannot upload more than ${maximumEnvelopeItemCount} envelope items per envelope`,
statusCode: 400, statusCode: 400,
}); });
} }
// For each file, stream to s3 and create the document data.
const envelopeItems = await Promise.all(
files.map(async (file) => {
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file);
return {
title: file.name,
documentDataId,
};
}),
);
const recipientsToCreate = recipients?.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
accessAuth: recipient.accessAuth,
actionAuth: recipient.actionAuth,
fields: recipient.fields?.map((field) => {
let documentDataId: string | undefined = undefined;
if (typeof field.identifier === 'string') {
documentDataId = envelopeItems.find(
(item) => item.title === field.identifier,
)?.documentDataId;
}
if (typeof field.identifier === 'number') {
documentDataId = envelopeItems.at(field.identifier)?.documentDataId;
}
if (field.identifier === undefined) {
documentDataId = envelopeItems[0]?.documentDataId;
}
if (!documentDataId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document data not found',
});
}
return {
...field,
documentDataId,
};
}),
}));
const envelope = await createEnvelope({ const envelope = await createEnvelope({
userId: user.id, userId: user.id,
teamId, teamId,
@ -63,9 +115,9 @@ export const createEnvelopeRoute = authenticatedProcedure
visibility, visibility,
globalAccessAuth, globalAccessAuth,
globalActionAuth, globalActionAuth,
recipients, recipients: recipientsToCreate,
folderId, folderId,
envelopeItems: items, envelopeItems,
}, },
attachments, attachments,
meta, meta,

View File

@ -1,5 +1,6 @@
import { EnvelopeType } from '@prisma/client'; import { EnvelopeType } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { import {
ZDocumentAccessAuthTypesSchema, ZDocumentAccessAuthTypesSchema,
@ -17,24 +18,28 @@ import {
} from '@documenso/lib/types/field'; } from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { zodFormData } from '../../utils/zod-form-data';
import { import {
ZDocumentExternalIdSchema, ZDocumentExternalIdSchema,
ZDocumentTitleSchema, ZDocumentTitleSchema,
ZDocumentVisibilitySchema, ZDocumentVisibilitySchema,
} from '../document-router/schema'; } from '../document-router/schema';
import { ZCreateRecipientSchema } from '../recipient-router/schema'; import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc';
// Currently not in use until we allow passthrough documents on create. // Currently not in use until we allow passthrough documents on create.
// export const createEnvelopeMeta: TrpcRouteMeta = { export const createEnvelopeMeta: TrpcRouteMeta = {
// openapi: { openapi: {
// method: 'POST', method: 'POST',
// path: '/envelope/create', path: '/envelope/create',
// summary: 'Create envelope', contentTypes: ['multipart/form-data'],
// tags: ['Envelope'], summary: 'Create envelope',
// }, description: 'Create a envelope using form data.',
// }; tags: ['Envelope'],
},
};
export const ZCreateEnvelopeRequestSchema = z.object({ export const ZCreateEnvelopePayloadSchema = z.object({
title: ZDocumentTitleSchema, title: ZDocumentTitleSchema,
type: z.nativeEnum(EnvelopeType), type: z.nativeEnum(EnvelopeType),
externalId: ZDocumentExternalIdSchema.optional(), externalId: ZDocumentExternalIdSchema.optional(),
@ -42,12 +47,6 @@ export const ZCreateEnvelopeRequestSchema = z.object({
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(), globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(), globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
formValues: ZDocumentFormValuesSchema.optional(), formValues: ZDocumentFormValuesSchema.optional(),
items: z
.object({
title: ZDocumentTitleSchema.optional(),
documentDataId: z.string(),
})
.array(),
folderId: z folderId: z
.string() .string()
.describe( .describe(
@ -59,11 +58,12 @@ export const ZCreateEnvelopeRequestSchema = z.object({
ZCreateRecipientSchema.extend({ ZCreateRecipientSchema.extend({
fields: ZFieldAndMetaSchema.and( fields: ZFieldAndMetaSchema.and(
z.object({ z.object({
documentDataId: z identifier: z
.string() .union([z.string(), z.number()])
.describe( .describe(
'The ID of the document data to create the field on. If empty, the first document data will be used.', 'Either the filename or the index of the file that was uploaded to attach the field to.',
), )
.optional(),
page: ZFieldPageNumberSchema, page: ZFieldPageNumberSchema,
positionX: ZFieldPageXSchema, positionX: ZFieldPageXSchema,
positionY: ZFieldPageYSchema, positionY: ZFieldPageYSchema,
@ -88,9 +88,15 @@ export const ZCreateEnvelopeRequestSchema = z.object({
.optional(), .optional(),
}); });
export const ZCreateEnvelopeRequestSchema = zodFormData({
payload: zfd.json(ZCreateEnvelopePayloadSchema),
files: zfd.repeatableOfType(zfd.file()),
});
export const ZCreateEnvelopeResponseSchema = z.object({ export const ZCreateEnvelopeResponseSchema = z.object({
id: z.string(), id: z.string(),
}); });
export type TCreateEnvelopePayload = z.infer<typeof ZCreateEnvelopePayloadSchema>;
export type TCreateEnvelopeRequest = z.infer<typeof ZCreateEnvelopeRequestSchema>; export type TCreateEnvelopeRequest = z.infer<typeof ZCreateEnvelopeRequestSchema>;
export type TCreateEnvelopeResponse = z.infer<typeof ZCreateEnvelopeResponseSchema>; export type TCreateEnvelopeResponse = z.infer<typeof ZCreateEnvelopeResponseSchema>;

View File

@ -1,7 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -15,7 +13,7 @@ export const deleteEnvelopeItemRoute = authenticatedProcedure
.input(ZDeleteEnvelopeItemRequestSchema) .input(ZDeleteEnvelopeItemRequestSchema)
.output(ZDeleteEnvelopeItemResponseSchema) .output(ZDeleteEnvelopeItemResponseSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { user, teamId, metadata } = ctx; const { user, teamId } = ctx;
const { envelopeId, envelopeItemId } = input; const { envelopeId, envelopeItemId } = input;
ctx.logger.info({ ctx.logger.info({
@ -54,48 +52,29 @@ export const deleteEnvelopeItemRoute = authenticatedProcedure
}); });
} }
const result = await prisma.$transaction(async (tx) => { const deletedEnvelopeItem = await prisma.envelopeItem.delete({
const deletedEnvelopeItem = await tx.envelopeItem.delete({ where: {
where: { id: envelopeItemId,
id: envelopeItemId, envelopeId: envelope.id,
envelopeId: envelope.id, },
}, select: {
select: { documentData: {
id: true, select: {
title: true, id: true,
documentData: {
select: {
id: true,
},
}, },
}, },
}); },
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED,
envelopeId: envelope.id,
data: {
envelopeItemId: deletedEnvelopeItem.id,
envelopeItemTitle: deletedEnvelopeItem.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: metadata.requestMetadata,
}),
});
return deletedEnvelopeItem;
}); });
// Todo: Envelopes [ASK] - Should we delete the document data?
await prisma.documentData.delete({ await prisma.documentData.delete({
where: { where: {
id: result.documentData.id, id: deletedEnvelopeItem.documentData.id,
envelopeItem: { envelopeItem: {
is: null, is: null,
}, },
}, },
}); });
// Todo: Envelope [AUDIT_LOGS]
}); });

View File

@ -24,7 +24,7 @@ export const setEnvelopeFieldsRoute = authenticatedProcedure
}, },
}); });
const result = await match(envelopeType) await match(envelopeType)
.with(EnvelopeType.DOCUMENT, async () => .with(EnvelopeType.DOCUMENT, async () =>
setFieldsForDocument({ setFieldsForDocument({
userId: ctx.user.id, userId: ctx.user.id,
@ -63,11 +63,4 @@ export const setEnvelopeFieldsRoute = authenticatedProcedure
}), }),
) )
.exhaustive(); .exhaustive();
return {
fields: result.fields.map((field) => ({
id: field.id,
formId: field.formId,
})),
};
}); });

View File

@ -12,7 +12,6 @@ export const ZSetEnvelopeFieldsRequestSchema = z.object({
.number() .number()
.optional() .optional()
.describe('The id of the field. If not provided, a new field will be created.'), .describe('The id of the field. If not provided, a new field will be created.'),
formId: z.string().optional().describe('A temporary ID to keep track of new fields created'),
envelopeItemId: z.string().describe('The id of the envelope item to put the field on'), envelopeItemId: z.string().describe('The id of the envelope item to put the field on'),
recipientId: z.number(), recipientId: z.number(),
type: z.nativeEnum(FieldType), type: z.nativeEnum(FieldType),
@ -46,14 +45,7 @@ export const ZSetEnvelopeFieldsRequestSchema = z.object({
), ),
}); });
export const ZSetEnvelopeFieldsResponseSchema = z.object({ export const ZSetEnvelopeFieldsResponseSchema = z.void();
fields: z
.object({
id: z.number(),
formId: z.string().optional(),
})
.array(),
});
export type TSetEnvelopeFieldsRequest = z.infer<typeof ZSetEnvelopeFieldsRequestSchema>; export type TSetEnvelopeFieldsRequest = z.infer<typeof ZSetEnvelopeFieldsRequestSchema>;
export type TSetEnvelopeFieldsResponse = z.infer<typeof ZSetEnvelopeFieldsResponseSchema>; export type TSetEnvelopeFieldsResponse = z.infer<typeof ZSetEnvelopeFieldsResponseSchema>;

View File

@ -21,6 +21,7 @@ import { deleteTemplateDirectLink } from '@documenso/lib/server-only/template/de
import { findTemplates } from '@documenso/lib/server-only/template/find-templates'; import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link'; import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { mapFieldToLegacyField } from '@documenso/lib/utils/fields'; import { mapFieldToLegacyField } from '@documenso/lib/utils/fields';
@ -159,20 +160,27 @@ export const templateRouter = router({
* @private * @private
*/ */
createTemplate: authenticatedProcedure createTemplate: authenticatedProcedure
// .meta({ // Note before releasing this to public, update the response schema to be correct. .meta({
// openapi: { // Note before releasing this to public, update the response schema to be correct.
// method: 'POST', openapi: {
// path: '/template/create', method: 'POST',
// summary: 'Create template', path: '/template/create',
// description: 'Create a new template', contentTypes: ['multipart/form-data'],
// tags: ['Template'], summary: 'Create template',
// }, description: 'Create a new template',
// }) tags: ['Template'],
},
})
.input(ZCreateTemplateMutationSchema) .input(ZCreateTemplateMutationSchema)
.output(ZCreateTemplateResponseSchema) .output(ZCreateTemplateResponseSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { teamId } = ctx; const { teamId } = ctx;
const { title, templateDocumentDataId, folderId } = input;
const { payload, file } = input;
const { title, folderId } = payload;
const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide(file);
ctx.logger.info({ ctx.logger.info({
input: { input: {
@ -198,6 +206,7 @@ export const templateRouter = router({
}); });
return { return {
envelopeId: envelope.id,
legacyTemplateId: mapSecondaryIdToTemplateId(envelope.secondaryId), legacyTemplateId: mapSecondaryIdToTemplateId(envelope.secondaryId),
}; };
}), }),

View File

@ -1,5 +1,6 @@
import { DocumentSigningOrder, DocumentVisibility, TemplateType } from '@prisma/client'; import { DocumentSigningOrder, DocumentVisibility, TemplateType } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { ZDocumentSchema } from '@documenso/lib/types/document'; import { ZDocumentSchema } from '@documenso/lib/types/document';
import { import {
@ -29,6 +30,7 @@ import {
} from '@documenso/lib/types/template'; } from '@documenso/lib/types/template';
import { LegacyTemplateDirectLinkSchema } from '@documenso/prisma/types/template-legacy-schema'; import { LegacyTemplateDirectLinkSchema } from '@documenso/prisma/types/template-legacy-schema';
import { zodFormData } from '../../utils/zod-form-data';
import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema'; import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema';
export const MAX_TEMPLATE_PUBLIC_TITLE_LENGTH = 50; export const MAX_TEMPLATE_PUBLIC_TITLE_LENGTH = 50;
@ -77,12 +79,16 @@ export const ZTemplateMetaUpsertSchema = z.object({
allowDictateNextSigner: z.boolean().optional(), allowDictateNextSigner: z.boolean().optional(),
}); });
export const ZCreateTemplateMutationSchema = z.object({ export const ZCreateTemplatePayloadSchema = z.object({
title: z.string().min(1).trim(), title: z.string().min(1).trim(),
templateDocumentDataId: z.string().min(1),
folderId: z.string().optional(), folderId: z.string().optional(),
}); });
export const ZCreateTemplateMutationSchema = zodFormData({
payload: zfd.json(ZCreateTemplatePayloadSchema),
file: zfd.file(),
});
export const ZCreateDocumentFromDirectTemplateRequestSchema = z.object({ export const ZCreateDocumentFromDirectTemplateRequestSchema = z.object({
directRecipientName: z.string().max(255).optional(), directRecipientName: z.string().max(255).optional(),
directRecipientEmail: z.string().email().max(254), directRecipientEmail: z.string().email().max(254),
@ -218,6 +224,7 @@ export const ZCreateTemplateV2ResponseSchema = z.object({
}); });
export const ZCreateTemplateResponseSchema = z.object({ export const ZCreateTemplateResponseSchema = z.object({
envelopeId: z.string(),
legacyTemplateId: z.number(), legacyTemplateId: z.number(),
}); });
@ -267,6 +274,7 @@ export const ZBulkSendTemplateMutationSchema = z.object({
sendImmediately: z.boolean(), sendImmediately: z.boolean(),
}); });
export type TCreateTemplatePayloadSchema = z.infer<typeof ZCreateTemplatePayloadSchema>;
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>; export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>; export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>; export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;

View File

@ -1,5 +1,4 @@
import { TRPCError, initTRPC } from '@trpc/server'; import { TRPCError, initTRPC } from '@trpc/server';
import SuperJSON from 'superjson';
import type { AnyZodObject } from 'zod'; import type { AnyZodObject } from 'zod';
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error'; import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
@ -9,6 +8,7 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { alphaid } from '@documenso/lib/universal/id'; import { alphaid } from '@documenso/lib/universal/id';
import { isAdmin } from '@documenso/lib/utils/is-admin'; import { isAdmin } from '@documenso/lib/utils/is-admin';
import { dataTransformer } from '../utils/data-transformer';
import type { TrpcContext } from './context'; import type { TrpcContext } from './context';
// Can't import type from trpc-to-openapi because it breaks build, not sure why. // Can't import type from trpc-to-openapi because it breaks build, not sure why.
@ -35,7 +35,7 @@ const t = initTRPC
.meta<TrpcRouteMeta>() .meta<TrpcRouteMeta>()
.context<TrpcContext>() .context<TrpcContext>()
.create({ .create({
transformer: SuperJSON, transformer: dataTransformer,
errorFormatter(opts) { errorFormatter(opts) {
const { shape, error } = opts; const { shape, error } = opts;

View File

@ -0,0 +1,17 @@
import type { DataTransformer } from '@trpc/server';
import SuperJSON from 'superjson';
export const dataTransformer: DataTransformer = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serialize: (data: any) => {
if (data instanceof FormData) {
return data;
}
return SuperJSON.serialize(data);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
deserialize: (data: any) => {
return SuperJSON.deserialize(data);
},
};

View File

@ -0,0 +1,202 @@
import { TRPCError } from '@trpc/server';
import type { FetchHandlerOptions } from '@trpc/server/adapters/fetch';
import type { ServerResponse } from 'node:http';
import { type OpenApiRouter, createOpenApiNodeHttpHandler } from 'trpc-to-openapi';
const CONTENT_TYPE_JSON = 'application/json';
const CONTENT_TYPE_URLENCODED = 'application/x-www-form-urlencoded';
const CONTENT_TYPE_MULTIPART = 'multipart/form-data';
const getUrlEncodedBody = async (req: Request) => {
const params = new URLSearchParams(await req.text());
const data: Record<string, unknown> = {};
for (const key of params.keys()) {
data[key] = params.getAll(key);
}
return data;
};
const getMultipartBody = async (req: Request) => {
const formData = await req.formData();
const data: Record<string, unknown> = {};
for (const key of formData.keys()) {
const values = formData.getAll(key);
// Return array for multiple values, single value otherwise (matches URL-encoded behavior)
data[key] = values.length > 1 ? values : values[0];
}
return data;
};
/**
* Parses the request body based on its content type.
*
* Handles JSON, URL-encoded, and multipart/form-data requests.
* For multipart requests, converts FormData to a plain object (similar to URL-encoded)
* so it can be validated by tRPC schemas. The content-type header is rewritten
* later to prevent downstream parsing issues.
*/
const getRequestBody = async (req: Request) => {
try {
const contentType = req.headers.get('content-type') || '';
if (contentType.includes(CONTENT_TYPE_JSON)) {
return {
isValid: true,
// Use JSON.parse instead of req.json() because req.json() does not throw on invalid JSON
data: JSON.parse(await req.text()),
};
}
if (contentType.includes(CONTENT_TYPE_URLENCODED)) {
return {
isValid: true,
data: await getUrlEncodedBody(req),
};
}
// Handle multipart/form-data by parsing as FormData and converting to a plain object.
// This mirrors how URL-encoded data is structured, allowing tRPC to validate it normally.
// The content-type header is rewritten to application/json later via the request proxy
// because createOpenApiNodeHttpHandler aborts on any bodied request that isn't application/json.
if (contentType.includes(CONTENT_TYPE_MULTIPART)) {
return {
isValid: true,
data: await getMultipartBody(req),
};
}
return {
isValid: true,
data: req.body,
};
} catch (err) {
return {
isValid: false,
cause: err,
};
}
};
/**
* Creates a proxy around the original Request that intercepts property access
* to transform the request for compatibility with the Node HTTP handler.
*
* Key transformations:
* - Parses and provides the body as a plain object (handles multipart/form-data conversion)
* - Rewrites content-type header for multipart requests to application/json
* (required because createOpenApiNodeHttpHandler aborts on non-JSON bodied requests)
*/
const createRequestProxy = async (req: Request, url?: string) => {
const body = await getRequestBody(req);
const originalContentType = req.headers.get('content-type') || '';
const isMultipart = originalContentType.includes(CONTENT_TYPE_MULTIPART);
return new Proxy(req, {
get: (target, prop) => {
switch (prop) {
case 'url':
return url ?? target.url;
case 'body': {
if (!body.isValid) {
throw new TRPCError({
code: 'PARSE_ERROR',
message: 'Failed to parse request body',
cause: body.cause,
});
}
return body.data;
}
case 'headers': {
const headers = new Headers(target.headers);
// Rewrite content-type header for multipart requests to application/json.
// This is necessary because `createOpenApiNodeHttpHandler` aborts on any bodied
// request that isn't application/json. Since we've already parsed the multipart
// data into a plain object above, this is safe to do.
if (isMultipart) {
headers.set('content-type', CONTENT_TYPE_JSON);
}
return headers;
}
default:
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
return (target as unknown as Record<string | number | symbol, unknown>)[prop];
}
},
});
};
export type CreateOpenApiFetchHandlerOptions<TRouter extends OpenApiRouter> = Omit<
FetchHandlerOptions<TRouter>,
'batching'
> & {
req: Request;
endpoint: `/${string}`;
};
export const createOpenApiFetchHandler = async <TRouter extends OpenApiRouter>(
opts: CreateOpenApiFetchHandlerOptions<TRouter>,
): Promise<Response> => {
const resHeaders = new Headers();
const url = new URL(opts.req.url.replace(opts.endpoint, ''));
const req: Request = await createRequestProxy(opts.req, url.toString());
// @ts-expect-error Inherited from original fetch handler in `trpc-to-openapi`
const openApiHttpHandler = createOpenApiNodeHttpHandler(opts);
return new Promise<Response>((resolve) => {
let statusCode: number;
// Create a mock ServerResponse object that bridges Node HTTP APIs with Fetch API Response.
// This allows the Node HTTP handler to work with Fetch API Request objects.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const res = {
setHeader: (key: string, value: string | readonly string[]) => {
if (typeof value === 'string') {
resHeaders.set(key, value);
} else {
for (const v of value) {
resHeaders.append(key, v);
}
}
},
get statusCode() {
return statusCode;
},
set statusCode(code: number) {
statusCode = code;
},
end: (body: string) => {
resolve(
new Response(body, {
headers: resHeaders,
status: statusCode,
}),
);
},
} as ServerResponse;
// Type assertions are necessary here for interop between Fetch API Request/Response
// and Node HTTP IncomingMessage/ServerResponse types.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const nodeReq = req as unknown as Parameters<typeof openApiHttpHandler>[0];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const nodeRes = res as unknown as Parameters<typeof openApiHttpHandler>[1];
void openApiHttpHandler(nodeReq, nodeRes);
});
};

View File

@ -0,0 +1,32 @@
import type { ZodRawShape } from 'zod';
import z from 'zod';
/**
* This helper takes the place of the `z.object` at the root of your schema.
* It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData`
* and transforms it into a regular object.
* If the `FormData` contains multiple entries with the same field name,
* it will automatically turn that field into an array.
*
* This is used instead of `zfd.formData()` because it receives `undefined`
* somewhere in the pipeline of our openapi schema generation and throws
* an error. This provides the same functionality as `zfd.formData()` but
* can be considered somewhat safer.
*/
export const zodFormData = <T extends ZodRawShape>(schema: T) => {
return z.preprocess((data) => {
if (data instanceof FormData) {
const formData: Record<string, unknown> = {};
for (const key of data.keys()) {
const values = data.getAll(key);
formData[key] = values.length > 1 ? values : values[0];
}
return formData;
}
return data;
}, z.object(schema));
};

View File

@ -1,6 +1,5 @@
declare namespace NodeJS { declare namespace NodeJS {
export interface ProcessEnv { export interface ProcessEnv {
PORT?: string;
NEXT_PUBLIC_WEBAPP_URL?: string; NEXT_PUBLIC_WEBAPP_URL?: string;
NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string; NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string;

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