Compare commits

..

16 Commits

Author SHA1 Message Date
45b2f51462 feat: add get documents and templates by IDs routes with validation schemas 2025-11-20 15:55:53 +02:00
29baa3e0d1 Merge branch 'main' into feat/get-documents-by-ids-endpoint 2025-11-20 13:01:52 +02:00
1fe7f78479 feat: implement get envelopes by IDs route with validation schemas 2025-11-20 12:07:15 +02:00
b3b8a35446 feat: get many endpoints 2025-11-20 11:37:01 +02:00
17c6098638 v2.0.14 2025-11-20 15:12:40 +11:00
e5bde53ee4 chore: add translations (#2223) 2025-11-20 15:09:13 +11:00
0663605ffd fix: handle loading files in embedded authoring update flows (#2218) 2025-11-20 15:07:41 +11:00
1bbe561162 chore: add pending ui to signing completion page (#2224)
Adds a pending UI state to the signing completion page for when all
recipients have finished signing but the document hasn't completed the
sealing background job.

<img width="695" height="562" alt="image"
src="https://github.com/user-attachments/assets/b015bc38-9489-4baa-ac0a-07cb1ac24b25"
/>
2025-11-20 15:07:26 +11:00
fbc156722a feat: add Portuguese (Brazil) translation support version 2.0.6 (#2165)
Portuguese (Brazil) Translation Support for Documenso
2025-11-20 14:14:47 +11:00
f5d63fb76c feat: add option to change or disable OIDC login prompt parameter (#2037) 2025-11-20 13:08:36 +11:00
374477e692 refactor: improve layout of completed signing page (#2209) 2025-11-20 11:04:41 +11:00
11d9bde8f8 fix: improve sealing speed (#2210) 2025-11-19 14:15:12 +11:00
fa1680aaf1 v2.0.13 2025-11-18 16:59:02 +11:00
798b6bd750 feat: add japanese chinese and korean support (#2202)
## Description

Adds the following languages since we updated our PDF sealing to support
special characters
- Japanese
- Korean
- Chinese (Simplified)

## Tests

Ran through the signing process in these new languages.
2025-11-18 16:57:38 +11:00
8fbace0f61 fix: viewed webhook had stale data (#2208) 2025-11-18 16:57:14 +11:00
1bbd04be9b feat: add field dev mode (#2203) 2025-11-18 16:57:06 +11:00
35 changed files with 11838 additions and 356 deletions

View File

@ -23,6 +23,10 @@ NEXT_PRIVATE_OIDC_CLIENT_ID=""
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
# Specifies the prompt to use for OIDC signin, explicitly setting
# an empty string will omit the prompt parameter.
# See: https://www.cerberauth.com/blog/openid-connect-oauth2-prompts/
NEXT_PRIVATE_OIDC_PROMPT="login"
# [[URLS]]
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"

View File

@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentData, FieldType } from '@prisma/client';
import type { EnvelopeItem, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { base64 } from '@scure/base';
import { ChevronsUpDown } from 'lucide-react';
@ -40,7 +40,8 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type ConfigureFieldsViewProps = {
configData: TConfigureEmbedFormSchema;
documentData?: DocumentData;
presignToken?: string | undefined;
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
defaultValues?: Partial<TConfigureFieldsFormSchema>;
onBack?: (data: TConfigureFieldsFormSchema) => void;
onSubmit: (data: TConfigureFieldsFormSchema) => void;
@ -48,7 +49,8 @@ export type ConfigureFieldsViewProps = {
export const ConfigureFieldsView = ({
configData,
documentData,
presignToken,
envelopeItem,
defaultValues,
onBack,
onSubmit,
@ -82,17 +84,25 @@ export const ConfigureFieldsView = ({
}, []);
const normalizedDocumentData = useMemo(() => {
if (documentData) {
return documentData.data;
if (envelopeItem) {
return undefined;
}
if (!configData.documentData) {
return null;
return undefined;
}
return base64.encode(configData.documentData.data);
}, [configData.documentData]);
const normalizedEnvelopeItem = useMemo(() => {
if (envelopeItem) {
return envelopeItem;
}
return { id: '', envelopeId: '' };
}, [envelopeItem]);
const recipients = useMemo(() => {
return configData.signers.map<Recipient>((signer, index) => ({
id: signer.nativeId || index,
@ -534,56 +544,50 @@ export const ConfigureFieldsView = ({
)}
<Form {...form}>
{normalizedDocumentData && (
<div>
<PDFViewer
overrideData={normalizedDocumentData}
envelopeItem={{
id: '',
envelopeId: '',
}}
token={undefined}
version="signed"
/>
<div>
<PDFViewer
presignToken={presignToken}
overrideData={normalizedDocumentData}
envelopeItem={normalizedEnvelopeItem}
token={undefined}
version="signed"
/>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
{localFields.map((field, index) => {
const recipientIndex = recipients.findIndex(
(r) => r.id === field.recipientId,
);
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
{localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
return (
<FieldItem
key={field.formId}
field={field}
minHeight={MIN_HEIGHT_PX}
minWidth={MIN_WIDTH_PX}
defaultHeight={DEFAULT_HEIGHT_PX}
defaultWidth={DEFAULT_WIDTH_PX}
onResize={(node) => onFieldResize(node, index)}
onMove={(node) => onFieldMove(node, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)}
onAdvancedSettings={() => {
setCurrentField(field);
setShowAdvancedSettings(true);
}}
recipientIndex={recipientIndex}
active={activeFieldId === field.formId}
onFieldActivate={() => setActiveFieldId(field.formId)}
onFieldDeactivate={() => setActiveFieldId(null)}
disabled={selectedRecipient?.id !== field.recipientId}
/>
);
})}
</ElementVisible>
</div>
)}
return (
<FieldItem
key={field.formId}
field={field}
minHeight={MIN_HEIGHT_PX}
minWidth={MIN_WIDTH_PX}
defaultHeight={DEFAULT_HEIGHT_PX}
defaultWidth={DEFAULT_WIDTH_PX}
onResize={(node) => onFieldResize(node, index)}
onMove={(node) => onFieldMove(node, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)}
onAdvancedSettings={() => {
setCurrentField(field);
setShowAdvancedSettings(true);
}}
recipientIndex={recipientIndex}
active={activeFieldId === field.formId}
onFieldActivate={() => setActiveFieldId(field.formId)}
onFieldDeactivate={() => setActiveFieldId(null)}
disabled={selectedRecipient?.id !== field.recipientId}
/>
);
})}
</ElementVisible>
</div>
</Form>
</div>
</div>

View File

@ -278,20 +278,8 @@ export const EnvelopeEditorFieldDragDrop = ({
onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined}
className={cn(
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
{
'text-recipient-green border-recipient-green/30':
selectedRecipientColor === 'green',
'text-recipient-blue border-recipient-blue/30': selectedRecipientColor === 'blue',
'text-recipient-purple border-recipient-purple/30':
selectedRecipientColor === 'purple',
'text-recipient-orange border-recipient-orange/30':
selectedRecipientColor === 'orange',
'text-recipient-yellow border-recipient-yellow/30':
selectedRecipientColor === 'yellow',
'text-recipient-pink border-recipient-pink/30': selectedRecipientColor === 'pink',
},
)}
>
<p
@ -299,12 +287,12 @@ export const EnvelopeEditorFieldDragDrop = ({
'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
field.className,
{
'text-recipient-green': selectedRecipientColor === 'green',
'text-recipient-blue': selectedRecipientColor === 'blue',
'text-recipient-purple': selectedRecipientColor === 'purple',
'text-recipient-orange': selectedRecipientColor === 'orange',
'text-recipient-yellow': selectedRecipientColor === 'yellow',
'text-recipient-pink': selectedRecipientColor === 'pink',
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
'group-hover:text-recipient-blue': selectedRecipientColor === 'blue',
'group-hover:text-recipient-purple': selectedRecipientColor === 'purple',
'group-hover:text-recipient-orange': selectedRecipientColor === 'orange',
'group-hover:text-recipient-yellow': selectedRecipientColor === 'yellow',
'group-hover:text-recipient-pink': selectedRecipientColor === 'pink',
},
)}
>

View File

@ -5,7 +5,7 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { Link } from 'react-router';
import { Link, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@ -65,6 +65,8 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
};
export const EnvelopeEditorFieldsPage = () => {
const [searchParams] = useSearchParams();
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -106,13 +108,6 @@ export const EnvelopeEditorFieldsPage = () => {
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
}, []);
const canRecipientBeModified = useMemo(
() =>
editorFields.selectedRecipient !== null &&
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields),
[editorFields.selectedRecipient, envelope.fields],
);
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
@ -182,35 +177,32 @@ export const EnvelopeEditorFieldsPage = () => {
align="end"
/>
{!canRecipientBeModified && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or
completed the document.
</Trans>
</AlertDescription>
</Alert>
)}
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or
completed the document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
{canRecipientBeModified && (
<>
<Separator className="my-4" />
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="text-foreground mb-2 text-sm font-semibold">
<Trans>Add Fields</Trans>
</h3>
{/* Add fields section. */}
<section className="px-4">
<h3 className="text-foreground mb-2 text-sm font-semibold">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
</section>
</>
)}
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
</section>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
@ -218,6 +210,37 @@ export const EnvelopeEditorFieldsPage = () => {
<section>
<Separator className="my-4" />
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="text-foreground mb-3 text-sm font-semibold">
<Trans>Developer Mode</Trans>
</h3>
<div className="bg-muted/50 border-border text-foreground space-y-2 rounded-md border p-3 text-sm">
<p>
<span className="text-muted-foreground min-w-12">Pos X:&nbsp;</span>
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="text-muted-foreground min-w-12">Pos Y:&nbsp;</span>
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="text-muted-foreground min-w-12">Width:&nbsp;</span>
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="text-muted-foreground min-w-12">Height:&nbsp;</span>
{selectedField.height.toFixed(2)}
</p>
</div>
</div>
<Separator className="my-4" />
</>
)}
<div className="[&_label]:text-foreground/70 px-4 [&_label]:text-xs">
<h3 className="text-sm font-semibold">
{t(FieldSettingsTypeTranslations[selectedField.type])}

View File

@ -1,10 +1,8 @@
import { useEffect } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { CheckCircle2, Clock8, DownloadIcon } from 'lucide-react';
import { Link, useRevalidator } from 'react-router';
import { CheckCircle2, Clock8, DownloadIcon, Loader2 } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
@ -18,7 +16,7 @@ import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { env } from '@documenso/lib/utils/env';
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
import { trpc } from '@documenso/trpc/react';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils';
@ -84,6 +82,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const canSignUp = !isExistingUser && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true';
const canRedirectToFolder =
user && document.userId === user.id && document.folderId && document.team?.url;
const returnToHomePath = canRedirectToFolder
? `/t/${document.team.url}/documents/f/${document.folderId}`
: '/';
return {
isDocumentAccessValid: true,
canSignUp,
@ -92,6 +97,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
signatures,
document,
recipient,
returnToHomePath,
};
}
@ -109,8 +115,27 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
document,
recipient,
recipientEmail,
returnToHomePath,
} = loaderData;
// Poll signing status every few seconds
const { data: signingStatusData } = trpc.envelope.signingStatus.useQuery(
{
token: recipient?.token || '',
},
{
refetchInterval: 3000,
initialData: match(document?.status)
.with(DocumentStatus.COMPLETED, () => ({ status: 'COMPLETED' }) as const)
.with(DocumentStatus.REJECTED, () => ({ status: 'REJECTED' }) as const)
.with(DocumentStatus.PENDING, () => ({ status: 'PENDING' }) as const)
.otherwise(() => ({ status: 'PENDING' }) as const),
},
);
// Use signing status from query if available, otherwise fall back to document status
const signingStatus = signingStatusData?.status ?? 'PENDING';
if (!isDocumentAccessValid) {
return <DocumentSigningAuthPageView email={recipientEmail} />;
}
@ -118,7 +143,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
return (
<div
className={cn(
'-mx-4 flex flex-col items-center overflow-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
'-mx-4 flex flex-col items-center overflow-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28',
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
)}
>
@ -152,8 +177,8 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
{recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>}
</h2>
{match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => (
{match({ status: signingStatus, deletedAt: document.deletedAt })
.with({ status: 'COMPLETED' }, () => (
<div className="text-documenso-700 mt-4 flex items-center text-center">
<CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">
@ -161,6 +186,14 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
</span>
</div>
))
.with({ status: 'PROCESSING' }, () => (
<div className="mt-4 flex items-center text-center text-orange-600">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
<span className="text-sm">
<Trans>Processing document</Trans>
</span>
</div>
))
.with({ deletedAt: null }, () => (
<div className="mt-4 flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
@ -178,14 +211,22 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
</div>
))}
{match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => (
{match({ status: signingStatus, deletedAt: document.deletedAt })
.with({ status: 'COMPLETED' }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
<Trans>
Everyone has signed! You will receive an Email copy of the signed document.
</Trans>
</p>
))
.with({ status: 'PROCESSING' }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
<Trans>
All recipients have signed. The document is being processed and you will receive
an Email copy shortly.
</Trans>
</p>
))
.with({ deletedAt: null }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
<Trans>
@ -202,23 +243,35 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
</p>
))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
<DocumentShareButton documentId={document.id} token={recipient.token} />
<div className="mt-8 flex w-full max-w-xs flex-col items-stretch gap-4 md:w-auto md:max-w-none md:flex-row md:items-center">
<DocumentShareButton
documentId={document.id}
token={recipient.token}
className="w-full max-w-none md:flex-1"
/>
{isDocumentCompleted(document.status) && (
{isDocumentCompleted(document) && (
<EnvelopeDownloadDialog
envelopeId={document.envelopeId}
envelopeStatus={document.status}
envelopeItems={document.envelopeItems}
token={recipient?.token}
trigger={
<Button type="button" variant="outline" className="flex-1">
<Button type="button" variant="outline" className="flex-1 md:flex-initial">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
}
/>
)}
{user && (
<Button asChild>
<Link to={returnToHomePath}>
<Trans>Go Back Home</Trans>
</Link>
</Button>
)}
</div>
</div>
@ -238,41 +291,8 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div>
)}
{user && (
<Link to="/" className="text-documenso-700 hover:text-documenso-600 mt-2">
<Trans>Go Back Home</Trans>
</Link>
)}
</div>
</div>
<PollUntilDocumentCompleted document={document} />
</div>
);
}
export type PollUntilDocumentCompletedProps = {
document: Pick<Document, 'id' | 'status' | 'deletedAt'>;
};
export const PollUntilDocumentCompleted = ({ document }: PollUntilDocumentCompletedProps) => {
const { revalidate } = useRevalidator();
useEffect(() => {
if (isDocumentCompleted(document.status)) {
return;
}
const interval = setInterval(() => {
if (window.document.hasFocus()) {
void revalidate();
}
}, 5000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [document.status]);
return <></>;
};

View File

@ -75,6 +75,7 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
}));
return {
token,
document: {
...document,
fields,
@ -86,7 +87,7 @@ export default function EmbeddingAuthoringDocumentEditPage() {
const { _ } = useLingui();
const { toast } = useToast();
const { document } = useLoaderData<typeof loader>();
const { document, token } = useLoaderData<typeof loader>();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
@ -321,7 +322,8 @@ export default function EmbeddingAuthoringDocumentEditPage() {
<ConfigureFieldsView
configData={configuration!}
documentData={document.documentData}
presignToken={token}
envelopeItem={document.envelopeItems[0]}
defaultValues={fields ?? undefined}
onBack={canGoBack ? handleBackToConfig : undefined}
onSubmit={handleConfigureFieldsSubmit}

View File

@ -75,6 +75,7 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
}));
return {
token,
template: {
...template,
fields,
@ -86,7 +87,7 @@ export default function EmbeddingAuthoringTemplateEditPage() {
const { _ } = useLingui();
const { toast } = useToast();
const { template } = useLoaderData<typeof loader>();
const { template, token } = useLoaderData<typeof loader>();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
@ -321,7 +322,8 @@ export default function EmbeddingAuthoringTemplateEditPage() {
<ConfigureFieldsView
configData={configuration!}
documentData={template.templateDocumentData}
presignToken={token}
envelopeItem={template.envelopeItems[0]}
defaultValues={fields ?? undefined}
onBack={canGoBack ? handleBackToConfig : undefined}
onSubmit={handleConfigureFieldsSubmit}

View File

@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.0.12"
"version": "2.0.14"
}

View File

@ -5,6 +5,7 @@ import { Hono } from 'hono';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
@ -16,6 +17,7 @@ import {
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileRequestQuerySchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema,
@ -68,12 +70,24 @@ export const filesRoute = new Hono<HonoEnv>()
.get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
sValidator('query', ZGetEnvelopeItemFileRequestQuerySchema),
async (c) => {
const { envelopeId, envelopeItemId } = c.req.valid('param');
const { token } = c.req.query();
const session = await getOptionalSession(c);
if (!session.user) {
let userId = session.user?.id;
if (token) {
const presignToken = await verifyEmbeddingPresignToken({
token,
}).catch(() => undefined);
userId = presignToken?.userId;
}
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
@ -104,7 +118,7 @@ export const filesRoute = new Hono<HonoEnv>()
}
const team = await getTeamById({
userId: session.user.id,
userId: userId,
teamId: envelope.teamId,
}).catch((error) => {
console.error(error);

View File

@ -36,6 +36,14 @@ export type TGetEnvelopeItemFileRequestParams = z.infer<
typeof ZGetEnvelopeItemFileRequestParamsSchema
>;
export const ZGetEnvelopeItemFileRequestQuerySchema = z.object({
token: z.string().optional(),
});
export type TGetEnvelopeItemFileRequestQuery = z.infer<
typeof ZGetEnvelopeItemFileRequestQuerySchema
>;
export const ZGetEnvelopeItemFileTokenRequestParamsSchema = z.object({
token: z.string().min(1),
envelopeItemId: z.string().min(1),

6
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.0.12",
"version": "2.0.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.0.12",
"version": "2.0.14",
"workspaces": [
"apps/*",
"packages/*"
@ -101,7 +101,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.0.12",
"version": "2.0.14",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.2",
"@documenso/api": "*",

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "2.0.12",
"version": "2.0.14",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",

View File

@ -27,13 +27,13 @@ type HandleOAuthAuthorizeUrlOptions = {
/**
* Optional prompt to pass to the authorization endpoint.
*/
prompt?: 'login' | 'consent' | 'select_account';
prompt?: 'none' | 'login' | 'consent' | 'select_account';
};
const oauthCookieMaxAge = 60 * 10; // 10 minutes.
export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOptions) => {
const { c, clientOptions, redirectPath, prompt = 'login' } = options;
const { c, clientOptions, redirectPath } = options;
if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP);
@ -63,7 +63,11 @@ export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOp
);
// Pass the prompt to the authorization endpoint.
url.searchParams.append('prompt', prompt);
if (process.env.NEXT_PRIVATE_OIDC_PROMPT !== '') {
const prompt = process.env.NEXT_PRIVATE_OIDC_PROMPT ?? 'login';
url.searchParams.append('prompt', prompt);
}
setCookie(c, `${clientOptions.id}_oauth_state`, state, {
...sessionCookieOptions,

View File

@ -1,6 +1,17 @@
import { z } from 'zod';
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en', 'fr', 'es', 'it', 'pl'] as const;
export const SUPPORTED_LANGUAGE_CODES = [
'de',
'en',
'fr',
'es',
'it',
'pl',
'pt-BR',
'ja',
'ko',
'zh',
] as const;
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
@ -54,6 +65,22 @@ export const SUPPORTED_LANGUAGES: Record<string, SupportedLanguage> = {
short: 'pl',
full: 'Polish',
},
'pt-BR': {
short: 'pt-BR',
full: 'Portuguese (Brazil)',
},
ja: {
short: 'ja',
full: 'Japanese',
},
ko: {
short: 'ko',
full: 'Korean',
},
zh: {
short: 'zh',
full: 'Chinese',
},
} satisfies Record<SupportedLanguageCodes, SupportedLanguage>;
export const isValidLanguageCode = (code: unknown): code is SupportedLanguageCodes =>

View File

@ -25,7 +25,6 @@ import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
@ -62,171 +61,120 @@ export const run = async ({
}) => {
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
type: EnvelopeType.DOCUMENT,
secondaryId: mapDocumentIdToSecondaryId(documentId),
},
include: {
documentMeta: true,
recipients: true,
envelopeItems: {
include: {
documentData: true,
field: {
include: {
signature: true,
const { envelopeId, envelopeStatus, isRejected } = await io.runTask('seal-document', async () => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
type: EnvelopeType.DOCUMENT,
secondaryId: mapDocumentIdToSecondaryId(documentId),
},
include: {
documentMeta: true,
recipients: true,
envelopeItems: {
include: {
documentData: true,
field: {
include: {
signature: true,
},
},
},
},
},
},
});
if (envelope.envelopeItems.length === 0) {
throw new Error('At least one envelope item required');
}
const settings = await getTeamSettings({
userId: envelope.userId,
teamId: envelope.teamId,
});
const isComplete =
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
if (!isComplete) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Document is not complete',
});
}
// Seems silly but we need to do this in case the job is re-ran
// after it has already run through the update task further below.
// eslint-disable-next-line @typescript-eslint/require-await
const documentStatus = await io.runTask('get-document-status', async () => {
return envelope.status;
});
if (envelope.envelopeItems.length === 0) {
throw new Error('At least one envelope item required');
}
// This is the same case as above.
let envelopeItems = await io.runTask(
'get-document-data-id',
// eslint-disable-next-line @typescript-eslint/require-await
async () => {
// eslint-disable-next-line unused-imports/no-unused-vars
return envelope.envelopeItems.map(({ field, ...rest }) => ({
...rest,
}));
},
);
const settings = await getTeamSettings({
userId: envelope.userId,
teamId: envelope.teamId,
});
if (envelopeItems.length < 1) {
throw new Error(`Document ${envelope.id} has no envelope items`);
}
const isComplete =
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
const recipients = await prisma.recipient.findMany({
where: {
envelopeId: envelope.id,
role: {
not: RecipientRole.CC,
},
},
});
if (!isComplete) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Document is not complete',
});
}
// Determine if the document has been rejected by checking if any recipient has rejected it
const rejectedRecipient = recipients.find(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
);
let envelopeItems = envelope.envelopeItems;
const isRejected = Boolean(rejectedRecipient);
if (envelopeItems.length < 1) {
throw new Error(`Document ${envelope.id} has no envelope items`);
}
// Get the rejection reason from the rejected recipient
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
const fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
},
include: {
signature: true,
},
});
// Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${envelope.id} has unsigned required fields`);
}
if (isResealing) {
// If we're resealing we want to use the initial data for the document
// so we aren't placing fields on top of eachother.
envelopeItems = envelopeItems.map((envelopeItem) => ({
...envelopeItem,
documentData: {
...envelopeItem.documentData,
data: envelopeItem.documentData.initialData,
},
}));
}
if (!envelope.qrToken) {
await prisma.envelope.update({
const recipients = await prisma.recipient.findMany({
where: {
id: envelope.id,
},
data: {
qrToken: prefixedId('qr'),
envelopeId: envelope.id,
role: {
not: RecipientRole.CC,
},
},
});
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// Determine if the document has been rejected by checking if any recipient has rejected it
const rejectedRecipient = recipients.find(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
);
const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
legacyDocumentId,
documentMeta: envelope.documentMeta,
settings,
});
const isRejected = Boolean(rejectedRecipient);
// !: The commented out code is our desired implementation but we're seemingly
// !: running into issues with inngest parallelism in production.
// !: Until this is resolved we will do this sequentially which is slower but
// !: will actually work.
// const decoratePromises: Array<Promise<{ oldDocumentDataId: string; newDocumentDataId: string }>> =
// [];
// Get the rejection reason from the rejected recipient
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
// for (const envelopeItem of envelopeItems) {
// const task = io.runTask(`decorate-${envelopeItem.id}`, async () => {
// const envelopeItemFields = envelope.envelopeItems.find(
// (item) => item.id === envelopeItem.id,
// )?.field;
const fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
},
include: {
signature: true,
},
});
// if (!envelopeItemFields) {
// throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
// }
// Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${envelope.id} has unsigned required fields`);
}
// return decorateAndSignPdf({
// envelope,
// envelopeItem,
// envelopeItemFields,
// isRejected,
// rejectionReason,
// certificateData,
// auditLogData,
// });
// });
if (isResealing) {
// If we're resealing we want to use the initial data for the document
// so we aren't placing fields on top of eachother.
envelopeItems = envelopeItems.map((envelopeItem) => ({
...envelopeItem,
documentData: {
...envelopeItem.documentData,
data: envelopeItem.documentData.initialData,
},
}));
}
// decoratePromises.push(task);
// }
if (!envelope.qrToken) {
await prisma.envelope.update({
where: {
id: envelope.id,
},
data: {
qrToken: prefixedId('qr'),
},
});
}
// const newDocumentData = await Promise.all(decoratePromises);
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// TODO: Remove once parallelization is working
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
legacyDocumentId,
documentMeta: envelope.documentMeta,
settings,
});
for (const envelopeItem of envelopeItems) {
const result = await io.runTask(`decorate-${envelopeItem.id}`, async () => {
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
for (const envelopeItem of envelopeItems) {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;
@ -235,7 +183,7 @@ export const run = async ({
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
}
return decorateAndSignPdf({
const result = await decorateAndSignPdf({
envelope,
envelopeItem,
envelopeItemFields,
@ -244,25 +192,10 @@ export const run = async ({
certificateData,
auditLogData,
});
});
newDocumentData.push(result);
}
newDocumentData.push(result);
}
const postHog = PostHogServerClient();
if (postHog) {
postHog.capture({
distinctId: nanoid(),
event: 'App: Document Sealed',
properties: {
documentId: envelope.id,
isRejected,
},
});
}
await io.runTask('update-document', async () => {
await prisma.$transaction(async (tx) => {
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
const newData = await tx.documentData.findFirstOrThrow({
@ -304,18 +237,24 @@ export const run = async ({
}),
});
});
return {
envelopeId: envelope.id,
envelopeStatus: envelope.status,
isRejected,
};
});
await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
if (isResealing && !isDocumentCompleted(envelope.status)) {
if (isResealing && !isDocumentCompleted(envelopeStatus)) {
shouldSendCompletedEmail = sendEmail;
}
if (shouldSendCompletedEmail) {
await sendCompletedEmail({
id: { type: 'envelopeId', id: envelope.id },
id: { type: 'envelopeId', id: envelopeId },
requestMetadata,
});
}
@ -323,7 +262,7 @@ export const run = async ({
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
id: envelopeId,
},
include: {
documentMeta: true,

View File

@ -103,6 +103,7 @@ export const getDocumentAndSenderByToken = async ({
select: {
name: true,
teamEmail: true,
url: true,
teamGlobalSettings: {
select: {
brandingEnabled: true,

View File

@ -31,26 +31,16 @@ export const viewedDocument = async ({
type: EnvelopeType.DOCUMENT,
},
},
include: {
envelope: {
include: {
documentMeta: true,
recipients: true,
},
},
},
});
if (!recipient) {
return;
}
const { envelope } = recipient;
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED,
envelopeId: envelope.id,
envelopeId: recipient.envelopeId,
user: {
name: recipient.name,
email: recipient.email,
@ -86,7 +76,7 @@ export const viewedDocument = async ({
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
envelopeId: envelope.id,
envelopeId: recipient.envelopeId,
user: {
name: recipient.name,
email: recipient.email,
@ -103,6 +93,16 @@ export const viewedDocument = async ({
});
});
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: recipient.envelopeId,
},
include: {
documentMeta: true,
recipients: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_OPENED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),

View File

@ -8,7 +8,7 @@ msgstr ""
"Language: pl\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-11-17 02:33\n"
"PO-Revision-Date: 2025-11-20 02:32\n"
"Last-Translator: \n"
"Language-Team: Polish\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
@ -179,7 +179,7 @@ msgstr "Sprawdź i {recipientActionVerb} dokument utworzony przez zespół {0}"
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
msgid "{0} of {1} documents remaining this month."
msgstr "{0} z {1} dokumentów pozostałych w tym miesiącu."
msgstr "Pozostało {0} z {1} dokumentów w tym miesiącu."
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length

File diff suppressed because it is too large Load Diff

View File

@ -8,15 +8,17 @@ export type EnvelopeItemPdfUrlOptions =
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined;
version: 'original' | 'signed';
presignToken?: undefined;
}
| {
type: 'view';
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined;
presignToken?: string | undefined;
};
export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => {
const { envelopeItem, token, type } = options;
const { envelopeItem, token, type, presignToken } = options;
const { id, envelopeId } = envelopeItem;
@ -24,11 +26,11 @@ export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => {
const version = options.version;
return token
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}/download/${version}`
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}/download/${version}${presignToken ? `?presignToken=${presignToken}` : ''}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}/download/${version}`;
}
return token
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}`
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}${presignToken ? `?presignToken=${presignToken}` : ''}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}`;
};

View File

@ -0,0 +1,74 @@
import { EnvelopeType } from '@prisma/client';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { mapEnvelopesToDocumentMany } from '@documenso/lib/utils/document';
import { mapDocumentIdToSecondaryId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZGetDocumentsByIdsRequestSchema,
ZGetDocumentsByIdsResponseSchema,
getDocumentsByIdsMeta,
} from './get-documents-by-ids.types';
export const getDocumentsByIdsRoute = authenticatedProcedure
.meta(getDocumentsByIdsMeta)
.input(ZGetDocumentsByIdsRequestSchema)
.output(ZGetDocumentsByIdsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { documentIds } = input;
ctx.logger.info({
input: {
documentIds,
},
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
id: documentIds[0],
},
userId: user.id,
teamId,
type: EnvelopeType.DOCUMENT,
});
const envelopeOrInput = envelopeWhereInput.OR!;
const secondaryIds = documentIds.map((documentId) => mapDocumentIdToSecondaryId(documentId));
const envelopes = await prisma.envelope.findMany({
where: {
type: EnvelopeType.DOCUMENT,
secondaryId: {
in: secondaryIds,
},
OR: envelopeOrInput,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
orderBy: {
id: 'asc',
},
},
team: {
select: {
id: true,
url: true,
},
},
},
});
return envelopes.map((envelope) => mapEnvelopesToDocumentMany(envelope));
});

View File

@ -0,0 +1,24 @@
import { z } from 'zod';
import { ZDocumentManySchema } from '@documenso/lib/types/document';
import type { TrpcRouteMeta } from '../trpc';
export const getDocumentsByIdsMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/document/get-many',
summary: 'Get multiple documents',
description: 'Retrieve multiple documents by their IDs',
tags: ['Document'],
},
};
export const ZGetDocumentsByIdsRequestSchema = z.object({
documentIds: z.array(z.number()).min(1),
});
export const ZGetDocumentsByIdsResponseSchema = z.array(ZDocumentManySchema);
export type TGetDocumentsByIdsRequest = z.infer<typeof ZGetDocumentsByIdsRequestSchema>;
export type TGetDocumentsByIdsResponse = z.infer<typeof ZGetDocumentsByIdsResponseSchema>;

View File

@ -19,6 +19,7 @@ import { findDocumentsInternalRoute } from './find-documents-internal';
import { findInboxRoute } from './find-inbox';
import { getDocumentRoute } from './get-document';
import { getDocumentByTokenRoute } from './get-document-by-token';
import { getDocumentsByIdsRoute } from './get-documents-by-ids';
import { getInboxCountRoute } from './get-inbox-count';
import { redistributeDocumentRoute } from './redistribute-document';
import { searchDocumentRoute } from './search-document';
@ -27,6 +28,7 @@ import { updateDocumentRoute } from './update-document';
export const documentRouter = router({
get: getDocumentRoute,
getMany: getDocumentsByIdsRoute,
find: findDocumentsRoute,
create: createDocumentRoute,
update: updateDocumentRoute,

View File

@ -0,0 +1,93 @@
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZGetEnvelopesByIdsRequestSchema,
ZGetEnvelopesByIdsResponseSchema,
getEnvelopesByIdsMeta,
} from './get-envelopes-by-ids.types';
export const getEnvelopesByIdsRoute = authenticatedProcedure
.meta(getEnvelopesByIdsMeta)
.input(ZGetEnvelopesByIdsRequestSchema)
.output(ZGetEnvelopesByIdsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeIds } = input;
ctx.logger.info({
input: {
envelopeIds,
},
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelopeIds[0],
},
userId: user.id,
teamId,
type: null,
});
const envelopeOrInput = envelopeWhereInput.OR!;
const envelopes = await prisma.envelope.findMany({
where: {
id: {
in: envelopeIds,
},
OR: envelopeOrInput,
},
include: {
envelopeItems: {
include: {
documentData: true,
},
orderBy: {
order: 'asc',
},
},
folder: true,
documentMeta: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
orderBy: {
id: 'asc',
},
},
fields: true,
team: {
select: {
id: true,
url: true,
},
},
directLink: {
select: {
directTemplateRecipientId: true,
enabled: true,
id: true,
token: true,
},
},
},
});
return envelopes.map((envelope) => ({
...envelope,
user: {
id: envelope.user.id,
name: envelope.user.name || '',
email: envelope.user.email,
},
}));
});

View File

@ -0,0 +1,24 @@
import { z } from 'zod';
import { ZEnvelopeSchema } from '@documenso/lib/types/envelope';
import type { TrpcRouteMeta } from '../trpc';
export const getEnvelopesByIdsMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/envelope/get-many',
summary: 'Get multiple envelopes',
description: 'Retrieve multiple envelopes by their IDs',
tags: ['Envelope'],
},
};
export const ZGetEnvelopesByIdsRequestSchema = z.object({
envelopeIds: z.array(z.string()).min(1),
});
export const ZGetEnvelopesByIdsResponseSchema = z.array(ZEnvelopeSchema);
export type TGetEnvelopesByIdsRequest = z.infer<typeof ZGetEnvelopesByIdsRequestSchema>;
export type TGetEnvelopesByIdsResponse = z.infer<typeof ZGetEnvelopesByIdsResponseSchema>;

View File

@ -21,10 +21,12 @@ import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-enve
import { getEnvelopeRoute } from './get-envelope';
import { getEnvelopeItemsRoute } from './get-envelope-items';
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids';
import { redistributeEnvelopeRoute } from './redistribute-envelope';
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
import { signEnvelopeFieldRoute } from './sign-envelope-field';
import { signingStatusEnvelopeRoute } from './signing-status-envelope';
import { updateEnvelopeRoute } from './update-envelope';
import { updateEnvelopeItemsRoute } from './update-envelope-items';
import { useEnvelopeRoute } from './use-envelope';
@ -65,6 +67,7 @@ export const envelopeRouter = router({
sign: signEnvelopeFieldRoute,
},
get: getEnvelopeRoute,
getMany: getEnvelopesByIdsRoute,
create: createEnvelopeRoute,
use: useEnvelopeRoute,
update: updateEnvelopeRoute,
@ -72,4 +75,5 @@ export const envelopeRouter = router({
duplicate: duplicateEnvelopeRoute,
distribute: distributeEnvelopeRoute,
redistribute: redistributeEnvelopeRoute,
signingStatus: signingStatusEnvelopeRoute,
});

View File

@ -0,0 +1,82 @@
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { maybeAuthenticatedProcedure } from '../trpc';
import {
ZSigningStatusEnvelopeRequestSchema,
ZSigningStatusEnvelopeResponseSchema,
} from './signing-status-envelope.types';
// Internal route - not intended for public API usage
export const signingStatusEnvelopeRoute = maybeAuthenticatedProcedure
.input(ZSigningStatusEnvelopeRequestSchema)
.output(ZSigningStatusEnvelopeResponseSchema)
.query(async ({ input, ctx }) => {
const { token } = input;
ctx.logger.info({
input: {
token,
},
});
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.DOCUMENT,
recipients: {
some: {
token,
},
},
},
include: {
recipients: {
select: {
id: true,
name: true,
email: true,
signingStatus: true,
role: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
// Check if envelope is rejected
if (envelope.status === DocumentStatus.REJECTED) {
return {
status: 'REJECTED',
};
}
if (envelope.status === DocumentStatus.COMPLETED) {
return {
status: 'COMPLETED',
};
}
const isComplete =
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
envelope.recipients.every(
(recipient) =>
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
);
if (isComplete) {
return {
status: 'PROCESSING',
};
}
return {
status: 'PENDING',
};
});

View File

@ -0,0 +1,14 @@
import { z } from 'zod';
export const EnvelopeSigningStatus = z.enum(['PENDING', 'PROCESSING', 'COMPLETED', 'REJECTED']);
export const ZSigningStatusEnvelopeRequestSchema = z.object({
token: z.string().describe('The recipient token to check the signing status for'),
});
export const ZSigningStatusEnvelopeResponseSchema = z.object({
status: EnvelopeSigningStatus.describe('The current signing status of the envelope'),
});
export type TSigningStatusEnvelopeRequest = z.infer<typeof ZSigningStatusEnvelopeRequestSchema>;
export type TSigningStatusEnvelopeResponse = z.infer<typeof ZSigningStatusEnvelopeResponseSchema>;

View File

@ -0,0 +1,127 @@
import { EnvelopeType } from '@prisma/client';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import {
mapSecondaryIdToTemplateId,
mapTemplateIdToSecondaryId,
} from '@documenso/lib/utils/envelope';
import { mapFieldToLegacyField } from '@documenso/lib/utils/fields';
import { mapRecipientToLegacyRecipient } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZGetTemplatesByIdsRequestSchema,
ZGetTemplatesByIdsResponseSchema,
getTemplatesByIdsMeta,
} from './get-templates-by-ids.types';
export const getTemplatesByIdsRoute = authenticatedProcedure
.meta(getTemplatesByIdsMeta)
.input(ZGetTemplatesByIdsRequestSchema)
.output(ZGetTemplatesByIdsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { templateIds } = input;
ctx.logger.info({
input: {
templateIds,
},
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateIds[0],
},
userId: user.id,
teamId,
type: EnvelopeType.TEMPLATE,
});
const envelopeOrInput = envelopeWhereInput.OR!;
const secondaryIds = templateIds.map((templateId) => mapTemplateIdToSecondaryId(templateId));
const envelopes = await prisma.envelope.findMany({
where: {
type: EnvelopeType.TEMPLATE,
secondaryId: {
in: secondaryIds,
},
OR: envelopeOrInput,
},
include: {
recipients: {
orderBy: {
id: 'asc',
},
},
fields: true,
team: {
select: {
id: true,
url: true,
},
},
documentMeta: {
select: {
signingOrder: true,
distributionMethod: true,
},
},
directLink: {
select: {
token: true,
enabled: true,
},
},
},
});
return envelopes.map((envelope) => {
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
return {
id: legacyTemplateId,
envelopeId: envelope.id,
type: envelope.templateType,
visibility: envelope.visibility,
externalId: envelope.externalId,
title: envelope.title,
userId: envelope.userId,
teamId: envelope.teamId,
authOptions: envelope.authOptions,
createdAt: envelope.createdAt,
updatedAt: envelope.updatedAt,
publicTitle: envelope.publicTitle,
publicDescription: envelope.publicDescription,
folderId: envelope.folderId,
useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
team: envelope.team
? {
id: envelope.team.id,
url: envelope.team.url,
}
: null,
fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)),
recipients: envelope.recipients.map((recipient) =>
mapRecipientToLegacyRecipient(recipient, envelope),
),
templateMeta: envelope.documentMeta
? {
signingOrder: envelope.documentMeta.signingOrder,
distributionMethod: envelope.documentMeta.distributionMethod,
}
: null,
directLink: envelope.directLink
? {
token: envelope.directLink.token,
enabled: envelope.directLink.enabled,
}
: null,
templateDocumentDataId: '', // Backwards compatibility.
};
});
});

View File

@ -0,0 +1,24 @@
import { z } from 'zod';
import { ZTemplateManySchema } from '@documenso/lib/types/template';
import type { TrpcRouteMeta } from '../trpc';
export const getTemplatesByIdsMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/template/get-many',
summary: 'Get multiple templates',
description: 'Retrieve multiple templates by their IDs',
tags: ['Template'],
},
};
export const ZGetTemplatesByIdsRequestSchema = z.object({
templateIds: z.array(z.number()).min(1),
});
export const ZGetTemplatesByIdsResponseSchema = z.array(ZTemplateManySchema);
export type TGetTemplatesByIdsRequest = z.infer<typeof ZGetTemplatesByIdsRequestSchema>;
export type TGetTemplatesByIdsResponse = z.infer<typeof ZGetTemplatesByIdsResponseSchema>;

View File

@ -30,6 +30,7 @@ import { mapEnvelopeToTemplateLite } from '@documenso/lib/utils/templates';
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
import { getTemplatesByIdsRoute } from './get-templates-by-ids';
import {
ZBulkSendTemplateMutationSchema,
ZCreateDocumentFromDirectTemplateRequestSchema,
@ -154,6 +155,11 @@ export const templateRouter = router({
});
}),
/**
* @public
*/
getMany: getTemplatesByIdsRoute,
/**
* Wait until RR7 so we can passthrough documents.
*

View File

@ -127,11 +127,11 @@ export const DocumentShareButton = ({
<Button
variant="outline"
disabled={!token || !documentId}
className={cn('flex-1 text-[11px]', className)}
className={cn('h-11 w-full max-w-lg flex-1', className)}
loading={isLoading}
>
{!isLoading && <Sparkles className="mr-2 h-5 w-5" />}
<Trans>Share Signature Card</Trans>
<Trans>Share</Trans>
</Button>
)}
</DialogTrigger>

View File

@ -39,7 +39,7 @@ export const RECIPIENT_COLOR_STYLES = {
baseRing: 'rgba(122, 195, 85, 1)',
baseRingHover: 'rgba(122, 195, 85, 0.3)',
baseTextHover: 'rgba(122, 195, 85, 1)',
fieldButton: 'hover:border-recipient-green bg-recipient-green/5 hover:bg-recipient-green/30 ',
fieldButton: 'hover:border-recipient-green hover:bg-recipient-green/30 ',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-green',
comboxBoxTrigger:
@ -52,7 +52,7 @@ export const RECIPIENT_COLOR_STYLES = {
baseRing: 'rgba(56, 123, 199, 1)',
baseRingHover: 'rgba(56, 123, 199, 0.3)',
baseTextHover: 'rgba(56, 123, 199, 1)',
fieldButton: 'hover:border-recipient-blue bg-recipient-blue/5 hover:bg-recipient-blue/30',
fieldButton: 'hover:border-recipient-blue hover:bg-recipient-blue/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-blue',
comboxBoxTrigger:
@ -65,7 +65,7 @@ export const RECIPIENT_COLOR_STYLES = {
baseRing: 'rgba(151, 71, 255, 1)',
baseRingHover: 'rgba(151, 71, 255, 0.3)',
baseTextHover: 'rgba(151, 71, 255, 1)',
fieldButton: 'hover:border-recipient-purple bg-recipient-purple/5 hover:bg-recipient-purple/30',
fieldButton: 'hover:border-recipient-purple hover:bg-recipient-purple/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-purple',
comboxBoxTrigger:
@ -78,7 +78,7 @@ export const RECIPIENT_COLOR_STYLES = {
baseRing: 'rgba(246, 159, 30, 1)',
baseRingHover: 'rgba(246, 159, 30, 0.3)',
baseTextHover: 'rgba(246, 159, 30, 1)',
fieldButton: 'hover:border-recipient-orange bg-recipient-orange/5 hover:bg-recipient-orange/30',
fieldButton: 'hover:border-recipient-orange hover:bg-recipient-orange/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-orange',
comboxBoxTrigger:
@ -91,7 +91,7 @@ export const RECIPIENT_COLOR_STYLES = {
baseRing: 'rgba(219, 186, 0, 1)',
baseRingHover: 'rgba(219, 186, 0, 0.3)',
baseTextHover: 'rgba(219, 186, 0, 1)',
fieldButton: 'hover:border-recipient-yellow bg-recipient-yellow/5 hover:bg-recipient-yellow/30',
fieldButton: 'hover:border-recipient-yellow hover:bg-recipient-yellow/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-yellow',
comboxBoxTrigger:
@ -104,7 +104,7 @@ export const RECIPIENT_COLOR_STYLES = {
baseRing: 'rgba(217, 74, 186, 1)',
baseRingHover: 'rgba(217, 74, 186, 0.3)',
baseTextHover: 'rgba(217, 74, 186, 1)',
fieldButton: 'hover:border-recipient-pink bg-recipient-pink/5 hover:bg-recipient-pink/30',
fieldButton: 'hover:border-recipient-pink hover:bg-recipient-pink/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-pink',
comboxBoxTrigger:

View File

@ -56,6 +56,7 @@ export type PDFViewerProps = {
className?: string;
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined;
presignToken?: string | undefined;
version: 'original' | 'signed';
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
onPageClick?: OnPDFViewerPageClick;
@ -67,6 +68,7 @@ export const PDFViewer = ({
className,
envelopeItem,
token,
presignToken,
version,
onDocumentLoad,
onPageClick,
@ -166,6 +168,7 @@ export const PDFViewer = ({
type: 'view',
envelopeItem: envelopeItem,
token,
presignToken,
});
const bytes = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());

View File

@ -119,6 +119,7 @@
"GOOGLE_APPLICATION_CREDENTIALS",
"E2E_TEST_AUTHENTICATE_USERNAME",
"E2E_TEST_AUTHENTICATE_USER_EMAIL",
"E2E_TEST_AUTHENTICATE_USER_PASSWORD"
"E2E_TEST_AUTHENTICATE_USER_PASSWORD",
"NEXT_PRIVATE_OIDC_PROMPT"
]
}