Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,27 @@ NEXT_PRIVATE_PLAIN_API_KEY=
# does this; the matching values there are `documenso` / `password`).
# NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME=documenso
# NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD=password

# [[DOS ID / ORG SYNC WEBHOOK]]
# REQUIRED FOR WEBHOOK PROCESSING: Shared secret used to verify the HMAC
# signature of DOS.Me org/team sync webhooks (x-dos-signature header). The
# webhook handler performs privileged mutations (member role grants,
# organisation deletion), so it REJECTS ALL EVENTS when this is unset.
# NEXT_PRIVATE_DOS_WEBHOOK_SECRET=""

# [[BLOCKCHAIN ATTESTATION]]
# OPTIONAL: On-chain anchoring is only attempted when BOTH of these are set.
# Without them, anchors stay in RETRYABLE_FAILED / PERMANENT_FAILED and are
# reported as NOT_ANCHORED by the verification endpoints (they are never
# reported as confirmed on-chain).
# CROVE_ANCHOR_GATEWAY_ADDRESS=""
# CROVE_RELAYER_PRIVATE_KEY=""
# OPTIONAL: JSON-RPC endpoints for the DOS chain. Defaults to
# https://main.doschain.com when unset.
# DOS_CHAIN_RPC_URL=""
# DOS_MAINNET_RPC=""
# OPTIONAL: DOS.Me API endpoints / internal API key.
# DOS_API_URL=""
# DOS_INTERNAL_API_KEY=""
# NEXT_PRIVATE_DOS_INTERNAL_API_KEY=""

2 changes: 1 addition & 1 deletion .github/actions/node-install/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: 'Setup node'
inputs:
node_version:
required: false
default: v22.x
default: v24.x

runs:
using: 'composite'
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations

To run Documenso locally, you will need

- Node.js (v22 or above)
- Node.js (v24 or above)
- Postgres SQL Database
- Docker (optional)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Add the variables to your deployment `.env` (or secret manager):

### Option A: Service Account Key (Recommended)

```env
```bash
GOOGLE_VERTEX_PROJECT_ID="<your-gcp-project-id>"
GOOGLE_VERTEX_SERVICE_ACCOUNT_KEY="<raw-json-or-base64-encoded-key>"
# Optional, defaults to "global"
Expand All @@ -41,7 +41,7 @@ GOOGLE_VERTEX_LOCATION="global"

### Option B: Express API Key

```env
```bash
GOOGLE_VERTEX_PROJECT_ID="<your-gcp-project-id>"
GOOGLE_VERTEX_API_KEY="<your-vertex-api-key>"
# Optional, defaults to "global"
Expand All @@ -50,7 +50,7 @@ GOOGLE_VERTEX_LOCATION="global"

### Option C: Application Default Credentials (ADC)

```env
```bash
GOOGLE_VERTEX_PROJECT_ID="<your-gcp-project-id>"
GOOGLE_VERTEX_USE_ADC="true"
# Optional, defaults to "global"
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/docs/self-hosting/deployment/manual.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';

## Prerequisites

- Node.js 22 or later
- npm 11 or later
- Node.js 24 or later
- npm 11.17 or later
- PostgreSQL 14 or later
- A Linux server (for systemd service setup)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ If building from source (not using Docker images):

| Requirement | Version |
| ----------- | ------- |
| Node.js | 22+ |
| npm | 11+ |
| Node.js | 24+ |
| npm | 11.17+ |

---

Expand All @@ -169,7 +169,7 @@ Documenso runs on:
| MySQL/MariaDB | PostgreSQL-specific features required |
| SQLite | Not suitable for production workloads |
| MongoDB | Relational database required |
| Node.js < 22 | Modern JavaScript features required |
| Node.js < 24 | Modern JavaScript features required |

---

Expand Down
25 changes: 0 additions & 25 deletions apps/remix/Dockerfile.bun

This file was deleted.

26 changes: 0 additions & 26 deletions apps/remix/Dockerfile.pnpm

This file was deleted.

123 changes: 114 additions & 9 deletions apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Expand All @@ -17,7 +20,9 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';

import { useCurrentTeam } from '~/providers/team';
Expand All @@ -29,6 +34,8 @@ const ZBulkSendFormSchema = z.object({

type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;

type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string };

export type TemplateBulkSendDialogProps = {
templateId: number;
recipients: Array<{ email: string; name?: string | null }>;
Expand All @@ -42,6 +49,9 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc

const team = useCurrentTeam();

const [open, setOpen] = useState(false);
const [validationError, setValidationError] = useState<TBulkSendValidationError | null>(null);

const form = useForm<TBulkSendFormSchema>({
resolver: zodResolver(ZBulkSendFormSchema),
defaultValues: {
Expand All @@ -51,6 +61,20 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc

const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();

const onOpenChange = (value: boolean) => {
if (form.formState.isSubmitting) {
return;
}

setOpen(value);

if (!value) {
setValidationError(null);

form.reset();
}
};

const onDownloadTemplate = () => {
const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]);

Expand All @@ -71,36 +95,44 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
};

const onSubmit = async (values: TBulkSendFormSchema) => {
setValidationError(null);

try {
const csv = await values.file.text();

await uploadBulkSend({
const result = await uploadBulkSend({
templateId,
teamId: team?.id,
csv: csv,
sendImmediately: values.sendImmediately,
});

if (!result.success) {
setValidationError(result.error);

return;
}

toast({
title: _(msg`Success`),
description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`),
});

setOpen(false);
form.reset();

onSuccess?.();
} catch (err) {
console.error(err);

toast({
title: _(msg`Error`),
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
variant: 'destructive',
});
const error = AppError.parseError(err);

setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
}
};

return (
<Dialog>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="shrink-0" size="sm">
Expand Down Expand Up @@ -174,7 +206,10 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];

if (file) {
setValidationError(null);

onChange(file);
}
}}
Expand All @@ -195,7 +230,11 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
type="button"
variant="link"
className="p-0 text-destructive text-xs hover:text-destructive"
onClick={() => onChange(null)}
onClick={() => {
setValidationError(null);

form.resetField('file');
}}
disabled={form.formState.isSubmitting}
>
<X className="h-4 w-4" />
Expand All @@ -218,6 +257,67 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
)}
/>

{validationError !== null && (
<Alert variant="destructive">
<AlertDescription className="max-h-32 overflow-y-auto">
{match(validationError)
.with({ type: 'PARSE_ERROR' }, () => (
<Trans>The CSV could not be parsed. Please check the file format and try again.</Trans>
))
.with({ type: 'EMPTY' }, () => (
<Trans>
The CSV does not contain any rows. Please add at least one row of recipient details.
</Trans>
))
.with({ type: 'ROW_LIMIT_EXCEEDED' }, ({ rowCount, maxRows }) => (
<Trans>
The CSV contains {rowCount} rows. A maximum of {maxRows} rows is allowed per upload.
</Trans>
))
.with({ type: 'MISSING_COLUMNS' }, ({ missingColumns }) => (
<>
<Trans>
The CSV is missing the following required columns. Please download the template CSV for the
correct format.
</Trans>

<ul className="mt-1 list-inside list-disc">
{missingColumns.map((column) => (
<li key={column} className="font-mono">
{column}
</li>
))}
</ul>
</>
))
.with({ type: 'INVALID_RECIPIENTS' }, ({ rowErrors }) => (
<>
<Trans>The CSV contains invalid recipient emails. Please fix the following rows:</Trans>

<ul className="mt-1 list-inside list-disc">
{rowErrors.map((rowError, index) => (
<li key={index}>
<Trans>
Row {rowError.row}: <span className="font-mono">{rowError.column}</span> must be a valid
email or empty
</Trans>
</li>
))}
</ul>
</>
))
.with({ type: 'UPLOAD_ERROR' }, ({ code }) =>
code === AppErrorCode.LIMIT_EXCEEDED ? (
<Trans>The CSV exceeds the maximum file size.</Trans>
) : (
<Trans>Failed to upload CSV. Please check the file format and try again.</Trans>
),
)
.exhaustive()}
</AlertDescription>
</Alert>
)}

<FormField
control={form.control}
name="sendImmediately"
Expand All @@ -240,7 +340,12 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
/>

<DialogFooter className="mt-4">
<Button variant="secondary" onClick={() => form.reset()} type="button">
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={form.formState.isSubmitting}
type="button"
>
<Trans>Cancel</Trans>
</Button>

Expand Down
Loading
Loading