Compare commits

...

12 Commits

Author SHA1 Message Date
ac4b3737d6 feat: convert AI field placement to server-side processing 2025-11-06 01:43:04 +00:00
cdfd373958 feat: add glow animation 2025-10-31 06:03:38 +00:00
233e6e603c feat: multiple pages 2025-10-31 03:27:37 +00:00
134d5ac03e chore: refactor 2025-10-30 12:20:52 +00:00
00e33c5331 chore: cleanup 2025-10-30 00:33:59 +00:00
29be66a844 feat: add AI field auto-placement with canvas registry 2025-10-29 23:03:58 +00:00
94098bd762 feat: generates coordinates for bounding boxes 2025-10-29 18:14:16 +00:00
9350c53c7d chore: add code styleguide (#2089)
Co-authored-by: Ephraim Atta-Duncan <ephraimduncan68@gmail.com>
2025-10-28 22:25:27 +11:00
ffce7a2c81 fix: filter document stats by folder (#2083)
This pull request refactors the filtering logic in the `getTeamCounts`
function within `get-stats.ts` to improve consistency and
maintainability. The main change is the consolidation of multiple filter
conditions into a single `AND` clause, which now includes search
filters, folder filters, and visibility filters. This ensures that all
relevant filters are applied in a unified way for document count
queries.
2025-10-28 21:16:12 +11:00
353bdce86b feat: admin member role updates (#2093) 2025-10-28 21:09:38 +11:00
e13b9f7c84 fix: hide banner for envelope editor (#2109) 2025-10-28 16:55:44 +11:00
9908580bf1 fix: add envelopes flag (#2104)
## Description

Add a global flag override for envelopes
2025-10-28 11:42:03 +11:00
37 changed files with 2591 additions and 117 deletions

View File

@ -135,6 +135,14 @@ NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Leave blank to allow users to signup through /signup page.
NEXT_PUBLIC_DISABLE_SIGNUP=
# [[AI]]
# AI Gateway
AI_GATEWAY_API_KEY=""
# OPTIONAL: API key for Google Generative AI (Gemini). Get your key from https://ai.google.dev
GOOGLE_GENERATIVE_AI_API_KEY=""
# OPTIONAL: Enable AI field detection debug mode to save preview images with bounding boxes
NEXT_PUBLIC_AI_DEBUG_PREVIEW=
# [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"

3
.gitignore vendored
View File

@ -60,3 +60,6 @@ CLAUDE.md
# agents
.specs
# ai debug previews
packages/assets/ai-previews/

692
CODE_STYLE.md Normal file
View File

@ -0,0 +1,692 @@
# 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

@ -0,0 +1,218 @@
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

@ -11,6 +11,10 @@ import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fi
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
registerPageCanvas,
unregisterPageCanvas,
} from '@documenso/lib/client-only/utils/page-canvas-registry';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import {
MIN_FIELD_HEIGHT_PX,
@ -56,6 +60,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
[editorFields.localFields, pageContext.pageNumber],
);
/**
* Cleanup: Unregister canvas when component unmounts
*/
useEffect(() => {
return () => {
unregisterPageCanvas(pageContext.pageNumber);
};
}, [pageContext.pageNumber]);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const { current: container } = canvasElement;
@ -214,6 +227,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
currentStage.on('transformend', () => setIsFieldChanging(false));
currentPageLayer.batchDraw();
// Register this page's canvas references now that everything is initialized
if (canvasElement.current && currentStage) {
registerPageCanvas({
pageNumber: pageContext.pageNumber,
pdfCanvas: canvasElement.current,
konvaStage: currentStage,
});
}
};
/**

View File

@ -1,4 +1,4 @@
import { lazy, useEffect, useMemo } from 'react';
import { lazy, useEffect, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
@ -11,6 +11,8 @@ import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { getPageCanvasRefs } from '@documenso/lib/client-only/utils/page-canvas-registry';
import type { TDetectedFormField } from '@documenso/lib/types/ai';
import type {
TCheckboxFieldMeta,
TDateFieldMeta,
@ -24,12 +26,15 @@ import type {
TSignatureFieldMeta,
TTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
@ -49,6 +54,136 @@ const EnvelopeEditorFieldsPageRenderer = lazy(
async () => import('./envelope-editor-fields-page-renderer'),
);
/**
* Enforces minimum field dimensions and centers the field when expanding to meet minimums.
*
* AI often detects form lines as very thin fields (0.2-0.5% height). This function ensures
* fields meet minimum usability requirements by expanding them to at least 30px height and
* 36px width, while keeping them centered on their original position.
*
* @param params - Field dimensions and page size
* @param params.positionX - Field X position as percentage (0-100)
* @param params.positionY - Field Y position as percentage (0-100)
* @param params.width - Field width as percentage (0-100)
* @param params.height - Field height as percentage (0-100)
* @param params.pageWidth - Page width in pixels
* @param params.pageHeight - Page height in pixels
* @returns Adjusted field dimensions with minimums enforced and centered
*
* @example
* // AI detected a thin line: 0.3% height
* const adjusted = enforceMinimumFieldDimensions({
* positionX: 20, positionY: 50, width: 30, height: 0.3,
* pageWidth: 800, pageHeight: 1100
* });
* // Result: height expanded to ~2.7% (30px), centered on original position
*/
const enforceMinimumFieldDimensions = (params: {
positionX: number;
positionY: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
}): {
positionX: number;
positionY: number;
width: number;
height: number;
} => {
const MIN_HEIGHT_PX = 30;
const MIN_WIDTH_PX = 36;
// Convert percentage to pixels to check against minimums
const widthPx = (params.width / 100) * params.pageWidth;
const heightPx = (params.height / 100) * params.pageHeight;
let adjustedWidth = params.width;
let adjustedHeight = params.height;
let adjustedPositionX = params.positionX;
let adjustedPositionY = params.positionY;
if (widthPx < MIN_WIDTH_PX) {
const centerXPx = (params.positionX / 100) * params.pageWidth + widthPx / 2;
adjustedWidth = (MIN_WIDTH_PX / params.pageWidth) * 100;
adjustedPositionX = ((centerXPx - MIN_WIDTH_PX / 2) / params.pageWidth) * 100;
if (adjustedPositionX < 0) {
adjustedPositionX = 0;
} else if (adjustedPositionX + adjustedWidth > 100) {
adjustedPositionX = 100 - adjustedWidth;
}
}
if (heightPx < MIN_HEIGHT_PX) {
const centerYPx = (params.positionY / 100) * params.pageHeight + heightPx / 2;
adjustedHeight = (MIN_HEIGHT_PX / params.pageHeight) * 100;
adjustedPositionY = ((centerYPx - MIN_HEIGHT_PX / 2) / params.pageHeight) * 100;
if (adjustedPositionY < 0) {
adjustedPositionY = 0;
} else if (adjustedPositionY + adjustedHeight > 100) {
adjustedPositionY = 100 - adjustedHeight;
}
}
return {
positionX: adjustedPositionX,
positionY: adjustedPositionY,
width: adjustedWidth,
height: adjustedHeight,
};
};
const processAllPagesWithAI = async (params: {
documentDataId: string;
onProgress: (current: number, total: number) => void;
}): Promise<{
fieldsPerPage: Map<number, TDetectedFormField[]>;
errors: Map<number, Error>;
}> => {
const { documentDataId, onProgress } = params;
const fieldsPerPage = new Map<number, TDetectedFormField[]>();
const errors = new Map<number, Error>();
try {
// Make single API call to process all pages server-side
onProgress(0, 1);
const response = await fetch('/api/ai/detect-form-fields', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ documentId: documentDataId }),
credentials: 'include',
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`AI detection failed: ${response.statusText} - ${errorText}`);
}
const detectedFields: TDetectedFormField[] = await response.json();
// Group fields by page number
for (const field of detectedFields) {
if (!fieldsPerPage.has(field.pageNumber)) {
fieldsPerPage.set(field.pageNumber, []);
}
fieldsPerPage.get(field.pageNumber)!.push(field);
}
onProgress(1, 1);
} catch (error) {
// If request fails, treat it as error for all pages
errors.set(0, error instanceof Error ? error : new Error(String(error)));
}
return { fieldsPerPage, errors };
};
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.SIGNATURE]: msg`Signature Settings`,
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
@ -69,6 +204,13 @@ export const EnvelopeEditorFieldsPage = () => {
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { t } = useLingui();
const { toast } = useToast();
const [isAutoAddingFields, setIsAutoAddingFields] = useState(false);
const [processingProgress, setProcessingProgress] = useState<{
current: number;
total: number;
} | null>(null);
const selectedField = useMemo(
() => structuredClone(editorFields.selectedField),
@ -107,8 +249,17 @@ export const EnvelopeEditorFieldsPage = () => {
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
<div className="relative flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */}
{isAutoAddingFields && (
<>
<div className="edge-glow edge-glow-top pointer-events-none fixed left-0 right-0 top-0 z-20 h-16" />
<div className="edge-glow edge-glow-right pointer-events-none fixed bottom-0 right-0 top-0 z-20 w-16" />
<div className="edge-glow edge-glow-bottom pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-16" />
<div className="edge-glow edge-glow-left pointer-events-none fixed bottom-0 left-0 top-0 z-20 w-16" />
</>
)}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */}
@ -187,6 +338,141 @@ export const EnvelopeEditorFieldsPage = () => {
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
<Button
className="mt-4 w-full"
variant="outline"
disabled={isAutoAddingFields}
onClick={async () => {
setIsAutoAddingFields(true);
setProcessingProgress(null);
try {
if (!editorFields.selectedRecipient || !currentEnvelopeItem) {
toast({
title: t`Warning`,
description: t`Please select a recipient before adding fields.`,
variant: 'destructive',
});
return;
}
if (!currentEnvelopeItem.documentDataId) {
toast({
title: t`Error`,
description: t`Document data not found. Please try reloading the page.`,
variant: 'destructive',
});
return;
}
const { fieldsPerPage, errors } = await processAllPagesWithAI({
documentDataId: currentEnvelopeItem.documentDataId,
onProgress: (current, total) => {
setProcessingProgress({ current, total });
},
});
let totalAdded = 0;
for (const [pageNumber, detectedFields] of fieldsPerPage.entries()) {
const pageCanvasRefs = getPageCanvasRefs(pageNumber);
for (const detected of detectedFields) {
const [ymin, xmin, ymax, xmax] = detected.boundingBox;
let positionX = (xmin / 1000) * 100;
let positionY = (ymin / 1000) * 100;
let width = ((xmax - xmin) / 1000) * 100;
let height = ((ymax - ymin) / 1000) * 100;
if (pageCanvasRefs) {
const adjusted = enforceMinimumFieldDimensions({
positionX,
positionY,
width,
height,
pageWidth: pageCanvasRefs.pdfCanvas.width,
pageHeight: pageCanvasRefs.pdfCanvas.height,
});
positionX = adjusted.positionX;
positionY = adjusted.positionY;
width = adjusted.width;
height = adjusted.height;
}
const fieldType = detected.label as FieldType;
try {
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
page: pageNumber,
type: fieldType,
positionX,
positionY,
width,
height,
recipientId: editorFields.selectedRecipient.id,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
});
totalAdded++;
} catch (error) {
console.error(`Failed to add field on page ${pageNumber}:`, error);
}
}
}
const successfulPages = fieldsPerPage.size;
const failedPages = errors.size;
if (totalAdded > 0) {
let description = t`Added ${totalAdded} fields`;
if (fieldsPerPage.size > 1) {
description = t`Added ${totalAdded} fields across ${successfulPages} pages`;
}
if (failedPages > 0) {
description = t`Added ${totalAdded} fields across ${successfulPages} pages. ${failedPages} pages failed.`;
}
toast({
title: t`Success`,
description,
});
} else if (failedPages > 0) {
toast({
title: t`Error`,
description: t`Failed to detect fields on ${failedPages} pages. Please try again.`,
variant: 'destructive',
});
} else {
toast({
title: t`Info`,
description: t`No fields were detected in the document`,
});
}
} catch (error) {
toast({
title: t`Error`,
description: t`An unexpected error occurred while processing pages.`,
variant: 'destructive',
});
} finally {
setIsAutoAddingFields(false);
setProcessingProgress(null);
}
}}
>
{isAutoAddingFields ? (
processingProgress ? (
<Trans>
Processing page {processingProgress.current} of {processingProgress.total}...
</Trans>
) : (
<Trans>Processing...</Trans>
)
) : (
<Trans>Auto add fields</Trans>
)}
</Button>
</section>
{/* Field details section. */}

View File

@ -1,12 +1,11 @@
import { useMemo, useState } from 'react';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import type { DropResult } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
import { X } from 'lucide-react';
import { FileWarningIcon, GripVerticalIcon, Loader2, X } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { Link } from 'react-router';

View File

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

View File

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

View File

@ -34,6 +34,7 @@ import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
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 { SettingsHeader } from '~/components/general/settings-header';
@ -71,23 +72,6 @@ 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(() => {
return [
{
@ -120,23 +104,24 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
},
{
header: t`Actions`,
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button
variant="outline"
disabled={row.original.userId === organisation?.ownerUserId}
loading={isPromotingToOwner}
onClick={async () =>
promoteToOwner({
organisationId,
userId: row.original.userId,
})
}
>
<Trans>Promote to owner</Trans>
</Button>
</div>
),
cell: ({ row }) => {
const isOwner = row.original.userId === organisation?.ownerUserId;
return (
<div className="flex justify-end space-x-2">
<AdminOrganisationMemberUpdateDialog
trigger={
<Button variant="outline">
<Trans>Update role</Trans>
</Button>
}
organisationId={organisationId}
organisationMember={row.original}
isOwner={isOwner}
/>
</div>
);
},
},
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
}, [organisation]);

View File

@ -14,6 +14,8 @@
"with:env": "dotenv -e ../../.env -e ../../.env.local --"
},
"dependencies": {
"@ai-sdk/google": "^2.0.25",
"@ai-sdk/react": "^2.0.82",
"@cantoo/pdf-lib": "^2.5.2",
"@documenso/api": "*",
"@documenso/assets": "*",
@ -38,6 +40,7 @@
"@react-router/serve": "^7.6.0",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"ai": "^5.0.82",
"autoprefixer": "^10.4.13",
"colord": "^2.9.3",
"framer-motion": "^10.12.8",
@ -68,6 +71,7 @@
"remix-themes": "^2.0.4",
"satori": "^0.12.1",
"sharp": "0.32.6",
"skia-canvas": "^3.0.8",
"tailwindcss": "^3.4.15",
"ts-pattern": "^5.0.5",
"ua-parser-js": "^1.0.37",
@ -104,4 +108,4 @@
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.13.1"
}
}

420
apps/remix/server/api/ai.ts Normal file
View File

@ -0,0 +1,420 @@
// sort-imports-ignore
// ---- PATCH pdfjs-dist's canvas require BEFORE importing it ----
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import { Canvas, Image } from 'skia-canvas';
const require = createRequire(import.meta.url || fileURLToPath(new URL('.', import.meta.url)));
const Module = require('module');
const originalRequire = Module.prototype.require;
Module.prototype.require = function (path: string) {
if (path === 'canvas') {
return {
createCanvas: (width: number, height: number) => new Canvas(width, height),
Image, // needed by pdfjs-dist
};
}
// eslint-disable-next-line prefer-rest-params, @typescript-eslint/consistent-type-assertions
return originalRequire.apply(this, arguments as unknown as [string]);
};
// Use dynamic require to bypass Vite SSR transformation
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pdfjsLib = require('pdfjs-dist/legacy/build/pdf.js');
import { generateObject } from 'ai';
import { mkdir, writeFile } from 'fs/promises';
import { Hono } from 'hono';
import { join } from 'path';
import sharp from 'sharp';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../router';
import {
type TDetectFormFieldsResponse,
ZDetectFormFieldsRequestSchema,
ZDetectedFormFieldSchema,
} from './ai.types';
const renderPdfToImage = async (pdfBytes: Uint8Array) => {
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
const pdf = await loadingTask.promise;
try {
const scale = 4;
const pages = await Promise.all(
Array.from({ length: pdf.numPages }, async (_, index) => {
const pageNumber = index + 1;
const page = await pdf.getPage(pageNumber);
try {
const viewport = page.getViewport({ scale });
const virtualCanvas = new Canvas(viewport.width, viewport.height);
const context = virtualCanvas.getContext('2d');
context.imageSmoothingEnabled = false;
await page.render({ canvasContext: context, viewport }).promise;
return {
image: await virtualCanvas.toBuffer('png'),
pageNumber,
width: Math.floor(viewport.width),
height: Math.floor(viewport.height),
};
} finally {
page.cleanup();
}
}),
);
return pages;
} finally {
await pdf.destroy();
}
};
const resizeAndCompressImage = async (imageBuffer: Buffer): Promise<Buffer> => {
const metadata = await sharp(imageBuffer).metadata();
const originalWidth = metadata.width || 0;
if (originalWidth > 1000) {
return await sharp(imageBuffer)
.resize({ width: 1000, withoutEnlargement: true })
.jpeg({ quality: 70 })
.toBuffer();
}
return await sharp(imageBuffer).jpeg({ quality: 70 }).toBuffer();
};
const detectObjectsPrompt = `You are analyzing a form document image to detect fillable fields for the Documenso document signing platform.
IMPORTANT RULES:
1. Only detect EMPTY/UNFILLED fields (ignore boxes that already contain text or data)
2. Analyze nearby text labels to determine the field type
3. Return bounding boxes for the fillable area only, NOT the label text
4. Each boundingBox must be in the format [ymin, xmin, ymax, xmax] where all coordinates are NORMALIZED to a 0-1000 scale
CRITICAL: UNDERSTANDING FILLABLE AREAS
The "fillable area" is ONLY the empty space where a user will write, type, sign, or check.
- ✓ CORRECT: The blank underscore where someone writes their name: "Name: _________" → box ONLY the underscores
- ✓ CORRECT: The empty white rectangle inside a box outline → box ONLY the empty space, not any printed text
- ✓ CORRECT: The blank space to the right of a label: "Email: [ empty box ]" → box ONLY the empty box, exclude "Email:"
- ✗ INCORRECT: Including the word "Signature:" that appears to the left of a signature line
- ✗ INCORRECT: Including printed labels, instructions, or descriptive text near the field
- ✗ INCORRECT: Extending the box to include text just because it's close to the fillable area
VISUALIZING THE DISTINCTION:
- If there's text (printed words/labels) near an empty box or line, they are SEPARATE elements
- The text is a LABEL telling the user what to fill
- The empty space is the FILLABLE AREA where they actually write/sign
- Your bounding box should capture ONLY the empty space, even if the label is immediately adjacent
FIELD TYPES TO DETECT:
• SIGNATURE - Signature lines, boxes labeled 'Signature', 'Sign here', 'Authorized signature', 'X____'
• INITIALS - Small boxes labeled 'Initials', 'Initial here', typically smaller than signature fields
• NAME - Boxes labeled 'Name', 'Full name', 'Your name', 'Print name', 'Printed name'
• EMAIL - Boxes labeled 'Email', 'Email address', 'E-mail', 'Email:'
• DATE - Boxes labeled 'Date', 'Date signed', "Today's date", or showing date format placeholders like 'MM/DD/YYYY', '__/__/____'
• CHECKBOX - Empty checkbox squares (☐) with or without labels, typically small square boxes
• RADIO - Empty radio button circles (○) in groups, typically circular selection options
• NUMBER - Boxes labeled with numeric context: 'Amount', 'Quantity', 'Phone', 'Phone number', 'ZIP', 'ZIP code', 'Age', 'Price', '#'
• DROPDOWN - Boxes with dropdown indicators (▼, ↓) or labeled 'Select', 'Choose', 'Please select'
• TEXT - Any other empty text input boxes, general input fields, unlabeled boxes, or when field type is uncertain
DETECTION GUIDELINES:
- Read text located near the box (above, to the left, or inside the box boundary) to infer the field type
- IMPORTANT: Use the nearby text to CLASSIFY the field type, but DO NOT include that text in the bounding box
- If you're uncertain which type fits best, default to TEXT
- For checkboxes and radio buttons: Detect each individual box/circle separately, not the label
- Signature fields are often longer horizontal lines or larger boxes
- Date fields often show format hints or date separators (slashes, dashes)
- Look for visual patterns: underscores (____), horizontal lines, box outlines
BOUNDING BOX PLACEMENT (CRITICAL):
- Your coordinates must capture ONLY the empty fillable space (the blank area where input goes)
- EXCLUDE all printed text labels, even if they are:
· Directly to the left of the field (e.g., "Name: _____")
· Directly above the field (e.g., "Signature" printed above a line)
· Very close to the field with minimal spacing
· Inside the same outlined box as the fillable area
- The label text helps you IDENTIFY the field type, but must be EXCLUDED from the bounding box
- If you detect a label "Email:" followed by a blank box, draw the box around ONLY the blank box, not the word "Email:"
COORDINATE SYSTEM:
- [ymin, xmin, ymax, xmax] normalized to 0-1000 scale
- Top-left corner: ymin and xmin close to 0
- Bottom-right corner: ymax and xmax close to 1000
- Coordinates represent positions on a 1000x1000 grid overlaid on the image
FIELD SIZING STRATEGY FOR LINE-BASED FIELDS:
When detecting thin horizontal lines for SIGNATURE, INITIALS, NAME, EMAIL, DATE, TEXT, or NUMBER fields:
1. Analyze the visual context around the detected line:
- Look at the empty space ABOVE the detected line
- Observe the spacing to any text labels, headers, or other form elements above
- Assess what would be a reasonable field height to make the field clearly visible when filled
2. Expand UPWARD from the detected line to create a usable field:
- Keep ymax (bottom) at the detected line position (the line becomes the bottom edge)
- Extend ymin (top) upward into the available whitespace
- Aim to use 60-80% of the clear whitespace above the line, while being reasonable
- The expanded field should provide comfortable space for signing/writing (minimum 30 units tall)
3. Apply minimum dimensions: height at least 30 units (3% of 1000-scale), width at least 36 units
4. Ensure ymin >= 0 (do not go off-page). If ymin would be negative, clamp to 0
5. Do NOT apply this expansion to CHECKBOX, RADIO, or DROPDOWN fields - use detected dimensions for those
6. Example: If you detect a signature line at ymax=500 with clear whitespace extending up to y=400:
- Available whitespace: 100 units
- Use 60-80% of that: 60-80 units
- Expanded field: [ymin=420, xmin=200, ymax=500, xmax=600] (creates 80-unit tall field)
- This gives comfortable signing space while respecting the form layout`;
const runFormFieldDetection = async (
imageBuffer: Buffer,
pageNumber: number,
): Promise<TDetectFormFieldsResponse> => {
const compressedImageBuffer = await resizeAndCompressImage(imageBuffer);
const base64Image = compressedImageBuffer.toString('base64');
const result = await generateObject({
model: 'google/gemini-2.5-pro',
output: 'array',
schema: ZDetectedFormFieldSchema,
messages: [
{
role: 'user',
content: [
{
type: 'image',
image: `data:image/jpeg;base64,${base64Image}`,
},
{
type: 'text',
text: detectObjectsPrompt,
},
],
},
],
});
return result.object.map((field) => ({
...field,
pageNumber,
}));
};
export const aiRoute = new Hono<HonoEnv>().post('/detect-form-fields', async (c) => {
try {
const { user } = await getSession(c.req.raw);
const body = await c.req.json();
const parsed = ZDetectFormFieldsRequestSchema.safeParse(body);
if (!parsed.success) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document ID is required',
userMessage: 'Please provide a valid document ID.',
});
}
const { documentId } = parsed.data;
const documentData = await prisma.documentData.findUnique({
where: { id: documentId },
include: {
envelopeItem: {
include: {
envelope: {
select: {
userId: true,
teamId: true,
},
},
},
},
},
});
if (!documentData || !documentData.envelopeItem) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Document data not found: ${documentId}`,
userMessage: 'The requested document does not exist.',
});
}
const envelope = documentData.envelopeItem.envelope;
const isDirectOwner = envelope.userId === user.id;
let hasTeamAccess = false;
if (envelope.teamId) {
try {
await getTeamById({ teamId: envelope.teamId, userId: user.id });
hasTeamAccess = true;
} catch (error) {
hasTeamAccess = false;
}
}
if (!isDirectOwner && !hasTeamAccess) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: `User ${user.id} does not have access to document ${documentId}`,
userMessage: 'You do not have permission to access this document.',
});
}
const pdfBytes = await getFileServerSide({
type: documentData.type,
data: documentData.initialData || documentData.data,
});
const renderedPages = await renderPdfToImage(pdfBytes);
const results = await Promise.allSettled(
renderedPages.map(async (page) => {
return await runFormFieldDetection(page.image, page.pageNumber);
}),
);
const detectedFields: TDetectFormFieldsResponse = [];
for (const [index, result] of results.entries()) {
if (result.status === 'fulfilled') {
detectedFields.push(...result.value);
} else {
const pageNumber = renderedPages[index]?.pageNumber ?? index + 1;
console.error(`Failed to detect fields on page ${pageNumber}:`, result.reason);
}
}
if (env('NEXT_PUBLIC_AI_DEBUG_PREVIEW') === 'true') {
const debugDir = join(process.cwd(), '..', '..', 'packages', 'assets', 'ai-previews');
await mkdir(debugDir, { recursive: true });
const now = new Date();
const timestamp = now
.toISOString()
.replace(/[-:]/g, '')
.replace(/\..+/, '')
.replace('T', '_');
for (const page of renderedPages) {
const padding = { left: 80, top: 20, right: 20, bottom: 40 };
const canvas = new Canvas(
page.width + padding.left + padding.right,
page.height + padding.top + padding.bottom,
);
const ctx = canvas.getContext('2d');
const img = new Image();
img.src = page.image;
ctx.drawImage(img, padding.left, padding.top);
ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
ctx.lineWidth = 1;
for (let i = 0; i <= 1000; i += 100) {
const x = padding.left + (i / 1000) * page.width;
ctx.beginPath();
ctx.moveTo(x, padding.top);
ctx.lineTo(x, page.height + padding.top);
ctx.stroke();
}
for (let i = 0; i <= 1000; i += 100) {
const y = padding.top + (i / 1000) * page.height;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(page.width + padding.left, y);
ctx.stroke();
}
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
const pageFields = detectedFields.filter((f) => f.pageNumber === page.pageNumber);
pageFields.forEach((field, index) => {
const [ymin, xmin, ymax, xmax] = field.boundingBox.map((coord) => coord / 1000);
const x = xmin * page.width + padding.left;
const y = ymin * page.height + padding.top;
const width = (xmax - xmin) * page.width;
const height = (ymax - ymin) * page.height;
ctx.strokeStyle = colors[index % colors.length];
ctx.lineWidth = 5;
ctx.strokeRect(x, y, width, height);
ctx.fillStyle = colors[index % colors.length];
ctx.font = '20px Arial';
ctx.fillText(field.label, x, y - 5);
});
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.font = '26px Arial';
ctx.beginPath();
ctx.moveTo(padding.left, padding.top);
ctx.lineTo(padding.left, page.height + padding.top);
ctx.stroke();
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let i = 0; i <= 1000; i += 100) {
const y = padding.top + (i / 1000) * page.height;
ctx.fillStyle = '#000000';
ctx.fillText(i.toString(), padding.left - 5, y);
ctx.beginPath();
ctx.moveTo(padding.left - 5, y);
ctx.lineTo(padding.left, y);
ctx.stroke();
}
ctx.beginPath();
ctx.moveTo(padding.left, page.height + padding.top);
ctx.lineTo(page.width + padding.left, page.height + padding.top);
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (let i = 0; i <= 1000; i += 100) {
const x = padding.left + (i / 1000) * page.width;
ctx.fillStyle = '#000000';
ctx.fillText(i.toString(), x, page.height + padding.top + 5);
ctx.beginPath();
ctx.moveTo(x, page.height + padding.top);
ctx.lineTo(x, page.height + padding.top + 5);
ctx.stroke();
}
const outputFilename = `detected_form_fields_${timestamp}_page_${page.pageNumber}.png`;
const outputPath = join(debugDir, outputFilename);
const pngBuffer = await canvas.toBuffer('png');
await writeFile(outputPath, new Uint8Array(pngBuffer));
}
}
return c.json<TDetectFormFieldsResponse>(detectedFields);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
console.error('Failed to detect form fields from PDF:', error);
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Failed to detect form fields from PDF: ${error instanceof Error ? error.message : String(error)}`,
userMessage: 'An error occurred while detecting form fields. Please try again.',
});
}
});

View File

@ -0,0 +1,50 @@
import { z } from 'zod';
import type { TDetectedFormField } from '@documenso/lib/types/ai';
export const ZGenerateTextRequestSchema = z.object({
prompt: z.string().min(1, 'Prompt is required').max(5000, 'Prompt is too long'),
});
export const ZGenerateTextResponseSchema = z.object({
text: z.string(),
});
export type TGenerateTextRequest = z.infer<typeof ZGenerateTextRequestSchema>;
export type TGenerateTextResponse = z.infer<typeof ZGenerateTextResponseSchema>;
export const ZDetectedFormFieldSchema = z.object({
boundingBox: z
.array(z.number())
.length(4)
.describe('Bounding box [ymin, xmin, ymax, xmax] in normalized 0-1000 range'),
label: z
.enum([
'SIGNATURE',
'INITIALS',
'NAME',
'EMAIL',
'DATE',
'TEXT',
'NUMBER',
'RADIO',
'CHECKBOX',
'DROPDOWN',
])
.describe('Documenso field type inferred from nearby label text or visual characteristics'),
pageNumber: z
.number()
.int()
.positive()
.describe('1-indexed page number where field was detected'),
});
export const ZDetectFormFieldsRequestSchema = z.object({
documentId: z.string().min(1, { message: 'Document ID is required' }),
});
export const ZDetectFormFieldsResponseSchema = z.array(ZDetectedFormFieldSchema);
export type TDetectFormFieldsRequest = z.infer<typeof ZDetectFormFieldsRequestSchema>;
export type TDetectFormFieldsResponse = z.infer<typeof ZDetectFormFieldsResponseSchema>;
export type { TDetectedFormField };

View File

@ -14,6 +14,7 @@ import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
import { logger } from '@documenso/lib/utils/logger';
import { openApiDocument } from '@documenso/trpc/server/open-api';
import { aiRoute } from './api/ai';
import { filesRoute } from './api/files';
import { type AppContext, appContext } from './context';
import { appMiddleware } from './middleware';
@ -83,6 +84,9 @@ app.route('/api/auth', auth);
// Files route.
app.route('/api/files', filesRoute);
// AI route.
app.route('/api/ai', aiRoute);
// API servers.
app.use(`/api/v1/*`, cors());
app.route('/api/v1', tsRestHonoApp);

212
package-lock.json generated
View File

@ -16,12 +16,13 @@
"@documenso/prisma": "^0.0.0",
"@lingui/conf": "^5.2.0",
"@lingui/core": "^5.2.0",
"ai": "^5.0.82",
"inngest-cli": "^0.29.1",
"luxon": "^3.5.0",
"mupdf": "^1.0.0",
"react": "^18",
"typescript": "5.6.2",
"zod": "3.24.1"
"zod": "3.25.76"
},
"devDependencies": {
"@commitlint/cli": "^17.7.1",
@ -91,6 +92,8 @@
"name": "@documenso/remix",
"version": "1.13.1",
"dependencies": {
"@ai-sdk/google": "^2.0.25",
"@ai-sdk/react": "^2.0.82",
"@cantoo/pdf-lib": "^2.5.2",
"@documenso/api": "*",
"@documenso/assets": "*",
@ -115,6 +118,7 @@
"@react-router/serve": "^7.6.0",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"ai": "^5.0.82",
"autoprefixer": "^10.4.13",
"colord": "^2.9.3",
"framer-motion": "^10.12.8",
@ -145,6 +149,7 @@
"remix-themes": "^2.0.4",
"satori": "^0.12.1",
"sharp": "0.32.6",
"skia-canvas": "^3.0.8",
"tailwindcss": "^3.4.15",
"ts-pattern": "^5.0.5",
"ua-parser-js": "^1.0.37",
@ -187,6 +192,92 @@
"integrity": "sha512-EGs8PguQqAAUIcQfK4E9xdXxB6s2GK4sJfT/vcc9V1ELIvC4LH/zXu2t/5fajtv6oiRCxdv7BgtVK3vWgROxag==",
"license": "MIT"
},
"node_modules/@ai-sdk/gateway": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.3.tgz",
"integrity": "sha512-/vCoMKtod+A74/BbkWsaAflWKz1ovhX5lmJpIaXQXtd6gyexZncjotBTbFM8rVJT9LKJ/Kx7iVVo3vh+KT+IJg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.14",
"@vercel/oidc": "3.0.3"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/google": {
"version": "2.0.25",
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-2.0.25.tgz",
"integrity": "sha512-tH2rA3428jnY6COoPfKB/BoQMs57sv9t+PEdyIB9ePtlV9dnVUbfKcdKoEcAaVffNZ6pzk8otrQYnu67pyn8TQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.14"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz",
"integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "3.0.14",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.14.tgz",
"integrity": "sha512-CYRU6L7IlR7KslSBVxvlqlybQvXJln/PI57O8swhOaDIURZbjRP2AY3igKgUsrmWqqnFFUHP+AwTN8xqJAknnA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@standard-schema/spec": "^1.0.0",
"eventsource-parser": "^3.0.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/react": {
"version": "2.0.82",
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.82.tgz",
"integrity": "sha512-InaGqykKGFq/XA6Vhh2Hyy38nzeMpqp8eWxjTNEQA5Gwcal0BVNuZyTbTIL5t5VNXV+pQPDhe9ak1+mc9qxjog==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "3.0.14",
"ai": "5.0.82",
"swr": "^2.2.5",
"throttleit": "2.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"zod": "^3.25.76 || ^4.1.8"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@ -11178,6 +11269,12 @@
"node": ">=18.0.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@swagger-api/apidom-ast": {
"version": "1.0.0-beta.39",
"resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.0.0-beta.39.tgz",
@ -11866,6 +11963,15 @@
"zod": "3.22.4"
}
},
"node_modules/@team-plain/typescript-sdk/node_modules/zod": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
"integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@theguild/remark-mermaid": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@theguild/remark-mermaid/-/remark-mermaid-0.0.5.tgz",
@ -13130,6 +13236,15 @@
"win32"
]
},
"node_modules/@vercel/oidc": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.3.tgz",
"integrity": "sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
}
},
"node_modules/@vvo/tzdb": {
"version": "6.161.0",
"resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.161.0.tgz",
@ -13271,6 +13386,33 @@
"node": ">=8"
}
},
"node_modules/ai": {
"version": "5.0.82",
"resolved": "https://registry.npmjs.org/ai/-/ai-5.0.82.tgz",
"integrity": "sha512-wmZZfsU40qB77umrcj3YzMSk6cUP5gxLXZDPfiSQLBLegTVXPUdSJC603tR7JB5JkhBDzN5VLaliuRKQGKpUXg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "2.0.3",
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.14",
"@opentelemetry/api": "1.9.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/ai/node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
@ -18595,6 +18737,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/eventsource-parser": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@ -21307,6 +21458,15 @@
"node": ">=6"
}
},
"node_modules/inngest/node_modules/zod": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.5.tgz",
"integrity": "sha512-HqnGsCdVZ2xc0qWPLdO25WnseXThh0kEYKIdV5F/hTHO75hNZFp8thxSeHhiPrHZKrFTo1SOgkAj9po5bexZlw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/input-otp": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
@ -22405,6 +22565,12 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@ -32722,6 +32888,19 @@
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/swr": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
"integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/tailwind-merge": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz",
@ -33059,6 +33238,18 @@
"real-require": "^0.2.0"
}
},
"node_modules/throttleit": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
"integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
@ -36088,9 +36279,9 @@
}
},
"node_modules/zod": {
"version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
"integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==",
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@ -36163,7 +36354,7 @@
"superjson": "^1.13.1",
"swagger-ui-react": "^5.21.0",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "3.25.76"
}
},
"packages/api/node_modules/ts-pattern": {
@ -36384,7 +36575,7 @@
"luxon": "^3.5.0",
"nanoid": "^5.1.5",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "3.25.76"
}
},
"packages/auth/node_modules/ts-pattern": {
@ -36404,7 +36595,7 @@
"micro": "^10.0.1",
"react": "^18",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "3.25.76"
}
},
"packages/ee/node_modules/ts-pattern": {
@ -36722,6 +36913,7 @@
"micro": "^10.0.1",
"nanoid": "^5.1.5",
"oslo": "^0.17.0",
"pdfjs-dist": "3.11.174",
"pg": "^8.11.3",
"pino": "^9.7.0",
"pino-pretty": "^13.0.0",
@ -36734,7 +36926,7 @@
"skia-canvas": "^3.0.8",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "3.25.76"
},
"devDependencies": {
"@playwright/browser-chromium": "1.52.0",
@ -37164,7 +37356,7 @@
"superjson": "^1.13.1",
"trpc-to-openapi": "2.0.4",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "3.25.76"
}
},
"packages/trpc/node_modules/ts-pattern": {
@ -37235,7 +37427,7 @@
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "3.25.76"
},
"devDependencies": {
"@documenso/tailwind-config": "*",

View File

@ -71,15 +71,13 @@
"@documenso/prisma": "^0.0.0",
"@lingui/conf": "^5.2.0",
"@lingui/core": "^5.2.0",
"ai": "^5.0.82",
"inngest-cli": "^0.29.1",
"luxon": "^3.5.0",
"mupdf": "^1.0.0",
"react": "^18",
"typescript": "5.6.2",
"zod": "3.24.1"
},
"overrides": {
"zod": "3.24.1"
"zod": "3.25.76"
},
"trigger.dev": {
"endpointId": "documenso-app"

View File

@ -25,6 +25,6 @@
"superjson": "^1.13.1",
"swagger-ui-react": "^5.21.0",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "3.25.76"
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,110 @@
import type Konva from 'konva';
/**
* Represents canvas references for a specific PDF page.
*/
export interface PageCanvasRefs {
/** The page number (1-indexed) */
pageNumber: number;
/** The canvas element containing the rendered PDF */
pdfCanvas: HTMLCanvasElement;
/** The Konva stage containing field overlays */
konvaStage: Konva.Stage;
}
/**
* Module-level registry to store canvas references by page number.
* This allows any component to access page canvases without prop drilling.
*/
const pageCanvasRegistry = new Map<number, PageCanvasRefs>();
/**
* Register a page's canvas references.
* Call this when a page renderer mounts and has valid canvas refs.
*
* @param refs - The canvas references to register
*/
export const registerPageCanvas = (refs: PageCanvasRefs): void => {
pageCanvasRegistry.set(refs.pageNumber, refs);
};
/**
* Unregister a page's canvas references.
* Call this when a page renderer unmounts to prevent memory leaks.
*
* @param pageNumber - The page number to unregister
*/
export const unregisterPageCanvas = (pageNumber: number): void => {
pageCanvasRegistry.delete(pageNumber);
};
/**
* Get canvas references for a specific page.
*
* @param pageNumber - The page number to retrieve
* @returns The canvas references, or undefined if not registered
*/
export const getPageCanvasRefs = (pageNumber: number): PageCanvasRefs | undefined => {
return pageCanvasRegistry.get(pageNumber);
};
/**
* Get all registered page numbers.
*
* @returns Array of page numbers currently registered
*/
export const getRegisteredPageNumbers = (): number[] => {
return Array.from(pageCanvasRegistry.keys()).sort((a, b) => a - b);
};
/**
* Composite a PDF page with its field overlays into a single PNG Blob.
* This creates a temporary canvas, draws the PDF canvas first (background),
* then draws the Konva canvas on top (field overlays).
*
* @param pageNumber - The page number to composite (1-indexed)
* @returns Promise that resolves to a PNG Blob, or null if page not found or compositing fails
*/
export const compositePageToBlob = async (pageNumber: number): Promise<Blob | null> => {
const refs = getPageCanvasRefs(pageNumber);
if (!refs) {
console.warn(`Page ${pageNumber} is not registered for canvas capture`);
return null;
}
try {
// Create temporary canvas with same dimensions as PDF canvas
const tempCanvas = document.createElement('canvas');
tempCanvas.width = refs.pdfCanvas.width;
tempCanvas.height = refs.pdfCanvas.height;
const ctx = tempCanvas.getContext('2d');
if (!ctx) {
console.error('Failed to get 2D context for temporary canvas');
return null;
}
// Draw PDF canvas first (background layer)
ctx.drawImage(refs.pdfCanvas, 0, 0);
// Get Konva canvas and draw on top (field overlays)
// Note: Konva's toCanvas() returns a new canvas with all layers rendered
const konvaCanvas = refs.konvaStage.toCanvas();
ctx.drawImage(konvaCanvas, 0, 0);
// Convert to PNG Blob
return new Promise((resolve, reject) => {
tempCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to convert canvas to blob'));
}
}, 'image/png');
});
} catch (error) {
console.error(`Error compositing page ${pageNumber}:`, error);
return null;
}
};

View File

@ -14,3 +14,5 @@ export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED
export const API_V2_BETA_URL = '/api/v2-beta';
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

@ -43,6 +43,7 @@
"micro": "^10.0.1",
"nanoid": "^5.1.5",
"oslo": "^0.17.0",
"pdfjs-dist": "3.11.174",
"pg": "^8.11.3",
"pino": "^9.7.0",
"pino-pretty": "^13.0.0",
@ -55,7 +56,7 @@
"skia-canvas": "^3.0.8",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "3.25.76"
},
"devDependencies": {
"@playwright/browser-chromium": "1.52.0",

View File

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

15
packages/lib/types/ai.ts Normal file
View File

@ -0,0 +1,15 @@
export type TDetectedFormField = {
boundingBox: number[];
label:
| 'SIGNATURE'
| 'INITIALS'
| 'NAME'
| 'EMAIL'
| 'DATE'
| 'TEXT'
| 'NUMBER'
| 'RADIO'
| 'CHECKBOX'
| 'DROPDOWN';
pageNumber: number;
};

View File

@ -257,6 +257,7 @@ export const FIELD_NUMBER_META_DEFAULT_VALUES: TNumberFieldMeta = {
textAlign: 'left',
label: '',
placeholder: '',
value: '',
required: false,
readOnly: false,
};

View File

@ -0,0 +1,19 @@
/**
* Shared constants for field dimension enforcement.
*
* These constants ensure consistency between:
* 1. AI prompt (server/api/ai.ts) - instructs Gemini on minimum field dimensions
* 2. Client enforcement (envelope-editor-fields-page.tsx) - fallback validation
*/
/**
* Minimum field height in pixels.
* Fields smaller than this will be expanded to meet minimum usability requirements.
*/
export const MIN_FIELD_HEIGHT_PX = 30;
/**
* Minimum field width in pixels.
* Fields smaller than this will be expanded to meet minimum usability requirements.
*/
export const MIN_FIELD_WIDTH_PX = 36;

View File

@ -21,6 +21,6 @@
"superjson": "^1.13.1",
"trpc-to-openapi": "2.0.4",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "3.25.76"
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,220 @@
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

@ -0,0 +1,30 @@
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

@ -80,6 +80,11 @@ declare namespace NodeJS {
NEXT_PRIVATE_INNGEST_APP_ID?: string;
NEXT_PRIVATE_INNGEST_EVENT_KEY?: string;
/**
* Google Generative AI (Gemini)
*/
GOOGLE_GENERATIVE_AI_API_KEY?: string;
POSTGRES_URL?: string;
DATABASE_URL?: string;
POSTGRES_PRISMA_URL?: string;

View File

@ -78,6 +78,6 @@
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "3.25.76"
}
}

View File

@ -197,6 +197,56 @@
}
}
@keyframes edgeGlow {
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 0.6;
}
}
.edge-glow {
animation: edgeGlow 2s ease-in-out infinite;
}
.edge-glow-top {
background: linear-gradient(
to bottom,
rgba(162, 231, 113, 0.4) 0%,
rgba(162, 231, 113, 0.2) 20%,
transparent 100%
);
}
.edge-glow-right {
background: linear-gradient(
to left,
rgba(162, 231, 113, 0.4) 0%,
rgba(162, 231, 113, 0.2) 20%,
transparent 100%
);
}
.edge-glow-bottom {
background: linear-gradient(
to top,
rgba(162, 231, 113, 0.4) 0%,
rgba(162, 231, 113, 0.2) 20%,
transparent 100%
);
}
.edge-glow-left {
background: linear-gradient(
to right,
rgba(162, 231, 113, 0.4) 0%,
rgba(162, 231, 113, 0.2) 20%,
transparent 100%
);
}
/*
* Custom CSS for printing reports
* - Sets page margins to 0.5 inches

View File

@ -46,6 +46,7 @@
"NEXT_PUBLIC_WEBAPP_URL",
"NEXT_PRIVATE_INTERNAL_WEBAPP_URL",
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_AI_DEBUG_PREVIEW",
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
"NEXT_PUBLIC_DISABLE_SIGNUP",
"NEXT_PRIVATE_PLAIN_API_KEY",