diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml
index b835896d0..4fcde0ea3 100644
--- a/.github/ISSUE_TEMPLATE/bug-report.yml
+++ b/.github/ISSUE_TEMPLATE/bug-report.yml
@@ -1,11 +1,10 @@
-name: "Bug Report"
-labels: ["bug"]
+name: 'Bug Report'
+labels: ['bug']
description: Create a bug report to help us improve
body:
- type: markdown
attributes:
- value:
- Thank you for reporting an issue.
+ value: Thank you for reporting an issue.
Please fill in as much of the form below as you're able to.
- type: textarea
attributes:
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
index f323f9475..ab21e8828 100644
--- a/.github/ISSUE_TEMPLATE/feature-request.yml
+++ b/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -1,9 +1,9 @@
-name: "Feature Request"
+name: 'Feature Request'
description: Suggest a new idea or enhancement for this project
body:
- type: markdown
attributes:
- value: Please provide a clear and concise title for your feature request
+ value: Please provide a clear and concise title for your feature request
- type: textarea
attributes:
label: Feature Description
@@ -32,4 +32,4 @@ body:
- label: I have provided a detailed description of the requested feature.
- label: I have explained the use case or scenario for this feature.
- label: I have included any relevant technical details or design suggestions.
- - label: I understand that this is a suggestion and that there is no guarantee of implementation.
\ No newline at end of file
+ - label: I understand that this is a suggestion and that there is no guarantee of implementation.
diff --git a/.github/ISSUE_TEMPLATE/improvement.yml b/.github/ISSUE_TEMPLATE/improvement.yml
index bebcb4cb5..058a025e7 100644
--- a/.github/ISSUE_TEMPLATE/improvement.yml
+++ b/.github/ISSUE_TEMPLATE/improvement.yml
@@ -1,4 +1,4 @@
-name: "General Improvement"
+name: 'General Improvement'
description: Suggest a minor enhancement or improvement for this project
body:
- type: markdown
@@ -32,4 +32,4 @@ body:
- label: I have provided a clear description of the improvement being suggested.
- label: I have explained the rationale behind this improvement.
- label: I have included any relevant technical details or design suggestions.
- - label: I understand that this is a suggestion and that there is no guarantee of implementation.
\ No newline at end of file
+ - label: I understand that this is a suggestion and that there is no guarantee of implementation.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index d9013f408..9a20ae923 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -4,29 +4,29 @@ updates:
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
- interval: "weekly"
- target-branch: "main"
+ interval: 'weekly'
+ target-branch: 'main'
labels:
- - "ci dependencies"
- - "ci"
+ - 'ci dependencies'
+ - 'ci'
open-pull-requests-limit: 0
- - package-ecosystem: "npm"
- directory: "/apps/marketing"
+ - package-ecosystem: 'npm'
+ directory: '/apps/marketing'
schedule:
- interval: "weekly"
- target-branch: "main"
+ interval: 'weekly'
+ target-branch: 'main'
labels:
- - "npm dependencies"
- - "frontend"
+ - 'npm dependencies'
+ - 'frontend'
open-pull-requests-limit: 0
- - package-ecosystem: "npm"
- directory: "/apps/web"
+ - package-ecosystem: 'npm'
+ directory: '/apps/web'
schedule:
- interval: "weekly"
- target-branch: "main"
+ interval: 'weekly'
+ target-branch: 'main'
labels:
- - "npm dependencies"
- - "frontend"
+ - 'npm dependencies'
+ - 'frontend'
open-pull-requests-limit: 0
diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml
new file mode 100644
index 000000000..e6ad018a3
--- /dev/null
+++ b/.github/pr-labeler.yml
@@ -0,0 +1,21 @@
+'apps: marketing':
+ - apps/marketing/**
+
+'apps: web':
+ - apps/web/**
+
+'version bump 👀':
+ - '**/package.json'
+ - '**/package-lock.json'
+
+'🚨 migrations 🚨':
+ - packages/prisma/migrations/**/migration.sql
+
+'🚨 e2e changes 🚨':
+ - packages/app-tests/e2e/**
+
+'🚨 .env changes 🚨':
+ - .env.example
+
+'pkg: ee changes':
+ - packages/ee/**
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fa29ae591..deda53ff0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,10 +1,10 @@
-name: "Continuous Integration"
+name: 'Continuous Integration'
on:
push:
- branches: [ "main" ]
+ branches: ['main']
pull_request:
- branches: [ "main" ]
+ branches: ['main']
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
@@ -19,12 +19,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Install Node.js
- uses: actions/setup-node@v3
+ uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
@@ -43,10 +43,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Build Docker Image
run: ./docker/build.sh
-
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index d47c37a00..465041c0a 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -1,11 +1,11 @@
-name: "CodeQL"
+name: 'CodeQL'
on:
workflow_dispatch:
push:
- branches: [ "main" ]
+ branches: ['main']
pull_request:
- branches: [ "main" ]
+ branches: ['main']
jobs:
analyze:
@@ -19,30 +19,30 @@ jobs:
strategy:
fail-fast: true
matrix:
- language: [ 'javascript' ]
+ language: ['javascript']
steps:
- - name: Checkout repository
- uses: actions/checkout@v3
+ - name: Checkout repository
+ uses: actions/checkout@v4
- - uses: actions/setup-node@v3
- with:
- node-version: 18
- cache: npm
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 18
+ cache: npm
- - name: Install Dependencies
- run: npm ci
+ - name: Install Dependencies
+ run: npm ci
- - name: Copy env
- run: cp .env.example .env
+ - name: Copy env
+ run: cp .env.example .env
- - name: Build Documenso
- run: npm run build
+ - name: Build Documenso
+ run: npm run build
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v2
- with:
- languages: ${{ matrix.language }}
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v2
+ with:
+ languages: ${{ matrix.language }}
- - name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v2
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 000000000..80d188964
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,24 @@
+name: Deploy to Production
+
+on:
+ push:
+ tags:
+ - '*'
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: main
+ fetch-depth: 0
+ token: ${{ secrets.GH_TOKEN }}
+
+ - name: Push to release branch
+ run: |
+ git checkout release || git checkout -b release
+ git merge --ff-only main
+ git push origin release
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index a37f001d1..7b05458d9 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -1,51 +1,50 @@
name: Playwright Tests
on:
push:
- branches: [ "main" ]
+ branches: ['main']
pull_request:
- branches: [ "main" ]
+ branches: ['main']
jobs:
e2e_tests:
+ name: "E2E Tests"
timeout-minutes: 60
runs-on: ubuntu-latest
- services:
- postgres:
- image: postgres
- env:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- options: >-
- --health-cmd pg_isready
- --health-interval 10s
- --health-timeout 5s
- --health-retries 5
- ports:
- - 5432:5432
steps:
- - uses: actions/checkout@v3
- - uses: actions/setup-node@v3
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
with:
node-version: 18
+ cache: npm
- name: Install dependencies
run: npm ci
+
- name: Copy env
run: cp .env.example .env
+
+ - name: Start Services
+ run: npm run dx:up
+
- name: Install Playwright Browsers
run: npx playwright install --with-deps
+
- name: Generate Prisma Client
run: npm run prisma:generate -w @documenso/prisma
+
- name: Create the database
run: npm run prisma:migrate-dev
+
+ - name: Seed the database
+ run: npm run prisma:seed
+
- name: Run Playwright tests
run: npm run ci
+
- uses: actions/upload-artifact@v3
if: always()
with:
- name: playwright-report
- path: playwright-report/
+ name: test-results
+ path: "packages/app-tests/**/test-results/*"
retention-days: 30
env:
- NEXT_PRIVATE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
- NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
diff --git a/.github/workflows/first-interaction.yml b/.github/workflows/first-interaction.yml
new file mode 100644
index 000000000..5f53eb280
--- /dev/null
+++ b/.github/workflows/first-interaction.yml
@@ -0,0 +1,29 @@
+name: 'Welcome New Contributors'
+
+on:
+ pull_request:
+ types: ['opened']
+ issues:
+ types: ['opened']
+
+permissions:
+ pull-requests: write
+ issues: write
+
+jobs:
+ welcome-message:
+ name: Welcome Contributors
+ if: github.event.action == 'opened'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/first-interaction@v1
+ with:
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+ pr-message: |
+ Thank you for creating your first Pull Request and for being a part of the open signing revolution! 💚🚀
+ Feel free to hop into our community in [Discord](https://documen.so/discord)
+ issue-message: |
+ Thank you for opening your first issue and for being a part of the open signing revolution!
+ One of our team members will review it and get back to you as soon as it possible 💚
+ Meanwhile, please feel free to hop into our community in [Discord](https://documen.so/discord)
diff --git a/.github/workflows/issue-assignee-check.yml b/.github/workflows/issue-assignee-check.yml
new file mode 100644
index 000000000..1ce7a02be
--- /dev/null
+++ b/.github/workflows/issue-assignee-check.yml
@@ -0,0 +1,59 @@
+name: 'Issue Assignee Check'
+
+on:
+ issues:
+ types: ['assigned']
+
+permissions:
+ issues: write
+
+jobs:
+ countIssues:
+ if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+ cache: npm
+
+ - name: Install Octokit
+ run: npm install @octokit/rest@18
+
+ - name: Check Assigned User's Issue Count
+ id: parse-comment
+ uses: actions/github-script@v5
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { Octokit } = require("@octokit/rest");
+ const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
+
+ const username = context.payload.issue.assignee.login;
+ console.log(`Username Extracted: ${username}`);
+
+ const { data: issues } = await octokit.issues.listForRepo({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ assignee: username,
+ state: 'open'
+ });
+
+ const issueCount = issues.length;
+ console.log(`Issue Count For ${username}: ${issueCount}`);
+
+ if (issueCount > 3) {
+ let issueCountMessage = `### 🚨 Documenso Police 🚨`;
+ issueCountMessage += `\n@${username} has ${issueCount} open issues assigned already. Consider whether this issue should be assigned to them or left open for another contributor.`;
+
+ await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: issueCountMessage,
+ headers: {
+ 'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
+ }
+ });
+ }
diff --git a/.github/workflows/issue-opened.yml b/.github/workflows/issue-opened.yml
new file mode 100644
index 000000000..ed9f2811a
--- /dev/null
+++ b/.github/workflows/issue-opened.yml
@@ -0,0 +1,21 @@
+name: 'Label Issues'
+
+on:
+ issues:
+ types: ['opened', 'reopened']
+
+jobs:
+ label_issues:
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ steps:
+ - uses: actions/github-script@v6
+ with:
+ script: |
+ github.rest.issues.addLabels({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: ["needs triage"]
+ })
diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml
new file mode 100644
index 000000000..1a5afd359
--- /dev/null
+++ b/.github/workflows/pr-labeler.yml
@@ -0,0 +1,20 @@
+name: 'PR Labeler'
+
+on:
+ - pull_request_target
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ labeler:
+ permissions:
+ contents: read
+ pull-requests: write
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/labeler@v4
+ with:
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+ sync-labels: ''
diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml
new file mode 100644
index 000000000..cc272fbfe
--- /dev/null
+++ b/.github/workflows/pr-review-reminder.yml
@@ -0,0 +1,60 @@
+name: 'PR Review Reminder'
+
+on:
+ pull_request:
+ types: ['opened', 'reopened', 'ready_for_review', 'review_requested']
+
+permissions:
+ pull-requests: write
+
+jobs:
+ checkPRs:
+ if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+ cache: npm
+
+ - name: Install Octokit
+ run: npm install @octokit/rest@18
+
+ - name: Check user's PRs awaiting review
+ id: parse-prs
+ uses: actions/github-script@v5
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { Octokit } = require("@octokit/rest");
+ const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
+
+ const username = context.payload.pull_request.user.login;
+ console.log(`Username Extracted: ${username}`);
+
+ const { data: pullRequests } = await octokit.pulls.list({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ state: 'open',
+ sort: 'created',
+ direction: 'asc',
+ });
+
+ const userPullRequests = pullRequests.filter(pr => pr.user.login === username && (pr.state === 'open' || pr.state === 'ready_for_review'));
+ const prCount = userPullRequests.length;
+ console.log(`PR Count for ${username}: ${prCount}`);
+
+ if (prCount > 3) {
+ let prReminderMessage = `🚨 @${username} has ${prCount} pull requests awaiting review. Please consider reviewing them when possible. 🚨`;
+
+ await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.payload.pull_request.number,
+ body: prReminderMessage,
+ headers: {
+ 'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
+ }
+ });
+ }
diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml
index af6e624c6..ef0a87542 100644
--- a/.github/workflows/semantic-pull-requests.yml
+++ b/.github/workflows/semantic-pull-requests.yml
@@ -17,5 +17,28 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
+ id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - uses: marocchino/sticky-pull-request-comment@v2
+ if: always() && (steps.lint_pr_title.outputs.error_message != null)
+ with:
+ header: pr-title-lint-error
+ message: |
+ Hey There! and thank you for opening this pull request! 📝👋🏼
+
+ We require pull request titles to follow the [Conventional Commits Spec](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
+
+ Details:
+
+ ```
+ ${{ steps.lint_pr_title.outputs.error_message }}
+ ```
+
+ - if: ${{ steps.lint_pr_title.outputs.error_message == null }}
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: pr-title-lint-error
+ message: |
+ Thank you for following the naming conventions for pull request titles! 💚🚀
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
new file mode 100644
index 000000000..82beed6e2
--- /dev/null
+++ b/.github/workflows/stale.yml
@@ -0,0 +1,25 @@
+name: 'Mark Stale Issues and PRs'
+
+on:
+ schedule:
+ - cron: '0 */8 * * *'
+
+jobs:
+ stale:
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ pull-requests: write
+
+ steps:
+ - uses: actions/stale@v4
+ with:
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+ days-before-pr-stale: 30
+ days-before-issue-stale: 30
+ stale-issue-message: 'This issue has not seen activity for a while. It will be closed in 30 days unless further activity is detected'
+ stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.'
+ close-issue-message: 'This issue has been closed because of inactivity.'
+ close-pr-message: 'This PR has been closed because of inactivity.'
+ exempt-pr-labels: 'WIP,on-hold,needs review'
+ exempt-issue-labels: 'WIP,on-hold,needs review,roadmap'
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 38d6f1e73..97d5d1948 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -6,5 +6,8 @@
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
"javascript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.useAliasesForRenames": false,
- "typescript.enablePromptUseWorkspaceTsdk": true
+ "typescript.enablePromptUseWorkspaceTsdk": true,
+ "files.eol": "\n",
+ "editor.tabSize": 2,
+ "editor.insertSpaces": true
}
diff --git a/apps/marketing/content/careers.mdx b/apps/marketing/content/careers.mdx
new file mode 100644
index 000000000..69433e127
--- /dev/null
+++ b/apps/marketing/content/careers.mdx
@@ -0,0 +1,13 @@
+---
+title: Careers at Documenso
+---
+
+# Careers at Documenso
+
+So you love Documenso and all the things that we do and now you want to work with us to unlock the future of open signing?
+
+---
+
+## Open Positions
+
+Unfortunately we have no open positions available at the moment. Our team has grown and so we must grow with it, please check back from time to time as now is not forever and we may be hiring again in the future.
diff --git a/apps/marketing/src/app/(marketing)/layout.tsx b/apps/marketing/src/app/(marketing)/layout.tsx
index 365d8a5d1..248414b33 100644
--- a/apps/marketing/src/app/(marketing)/layout.tsx
+++ b/apps/marketing/src/app/(marketing)/layout.tsx
@@ -29,7 +29,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
return (
@@ -41,7 +41,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
- {children}
+ {children}
diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx
index 1dcb2d76b..b7654c7cf 100644
--- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx
+++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx
@@ -17,15 +17,14 @@ import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
-import {
- DocumentFlowFormContainer,
- DocumentFlowFormContainerHeader,
-} from '@documenso/ui/primitives/document-flow/document-flow-root';
+import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
+import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
-type SinglePlayerModeStep = 'fields' | 'sign';
+const SinglePlayerModeSteps = ['fields', 'sign'] as const;
+type SinglePlayerModeStep = (typeof SinglePlayerModeSteps)[number];
// !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during
@@ -226,37 +225,35 @@ export const SinglePlayerClient = () => {
-
e.preventDefault()}>
-
-
- {/* Add fields to PDF page. */}
- {step === 'fields' && (
+ e.preventDefault()}
+ >
+ setStep(SinglePlayerModeSteps[step - 1])}
+ >
+ {/* Add fields to PDF page. */}
- )}
- {/* Enter user details and signature. */}
- {step === 'sign' && (
+ {/* Enter user details and signature. */}
+
field.type === 'NAME'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/>
- )}
+
diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx
index 480043c46..1399297c7 100644
--- a/apps/marketing/src/components/(marketing)/footer.tsx
+++ b/apps/marketing/src/components/(marketing)/footer.tsx
@@ -31,6 +31,7 @@ const FOOTER_LINKS = [
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
{ href: '/oss-friends', text: 'OSS Friends' },
+ { href: '/careers', text: 'Careers' },
{ href: '/privacy', text: 'Privacy' },
];
diff --git a/apps/marketing/src/pages/api/trpc/[trpc].ts b/apps/marketing/src/pages/api/trpc/[trpc].ts
index 0bc991a98..c43291ea1 100644
--- a/apps/marketing/src/pages/api/trpc/[trpc].ts
+++ b/apps/marketing/src/pages/api/trpc/[trpc].ts
@@ -4,6 +4,11 @@ import { appRouter } from '@documenso/trpc/server/router';
export const config = {
maxDuration: 60,
+ api: {
+ bodyParser: {
+ sizeLimit: '50mb',
+ },
+ },
};
export default trpcNext.createNextApiHandler({
diff --git a/apps/web/package.json b/apps/web/package.json
index 47d94fb63..150982c2d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -8,6 +8,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
+ "e2e:prepare": "next build && next start",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
index e775bffdc..ffce3bd6c 100644
--- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
+++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
@@ -4,8 +4,8 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
-import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
+import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -18,12 +18,10 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title';
import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types';
-import {
- DocumentFlowFormContainer,
- DocumentFlowFormContainerHeader,
-} from '@documenso/ui/primitives/document-flow/document-flow-root';
+import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
+import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EditDocumentFormProps = {
@@ -36,6 +34,7 @@ export type EditDocumentFormProps = {
};
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
+const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'subject'];
export const EditDocumentForm = ({
className,
@@ -48,6 +47,7 @@ export const EditDocumentForm = ({
const { toast } = useToast();
const router = useRouter();
+ // controlled stepper state
const [step, setStep] = useState(
document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
);
@@ -67,24 +67,19 @@ export const EditDocumentForm = ({
title: 'Add Signers',
description: 'Add the people who will sign the document.',
stepIndex: 2,
- onBackStep: () => document.status === DocumentStatus.DRAFT && setStep('title'),
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 3,
- onBackStep: () => setStep('signers'),
},
subject: {
title: 'Add Subject',
description: 'Add the subject and message you wish to send to signers.',
stepIndex: 4,
- onBackStep: () => setStep('fields'),
},
};
- const currentDocumentFlow = documentFlow[step];
-
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
try {
// Custom invocation server action
@@ -116,7 +111,6 @@ export const EditDocumentForm = ({
});
router.refresh();
-
setStep('fields');
} catch (err) {
console.error(err);
@@ -138,7 +132,6 @@ export const EditDocumentForm = ({
});
router.refresh();
-
setStep('subject');
} catch (err) {
console.error(err);
@@ -181,6 +174,8 @@ export const EditDocumentForm = ({
}
};
+ const currentDocumentFlow = documentFlow[step];
+
return (
e.preventDefault()}
>
-
-
- {step === 'title' && (
+ setStep(EditDocumentSteps[step - 1])}
+ >
- )}
- {step === 'signers' && (
- )}
-
- {step === 'fields' && (
- )}
-
- {step === 'subject' && (
- )}
+
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx
index 4e63c4475..54a8f6184 100644
--- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx
+++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx
@@ -12,6 +12,7 @@ import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
+import { useToast } from '@documenso/ui/primitives/use-toast';
export type DataTableActionButtonProps = {
row: Document & {
@@ -22,6 +23,7 @@ export type DataTableActionButtonProps = {
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const { data: session } = useSession();
+ const { toast } = useToast();
if (!session) {
return null;
@@ -37,39 +39,47 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const onDownloadClick = async () => {
- let document: DocumentWithData | null = null;
+ try {
+ let document: DocumentWithData | null = null;
- if (!recipient) {
- document = await trpcClient.document.getDocumentById.query({
- id: row.id,
+ if (!recipient) {
+ document = await trpcClient.document.getDocumentById.query({
+ id: row.id,
+ });
+ } else {
+ document = await trpcClient.document.getDocumentByToken.query({
+ token: recipient.token,
+ });
+ }
+
+ const documentData = document?.documentData;
+
+ if (!documentData) {
+ return;
+ }
+
+ const documentBytes = await getFile(documentData);
+
+ const blob = new Blob([documentBytes], {
+ type: 'application/pdf',
});
- } else {
- document = await trpcClient.document.getDocumentByToken.query({
- token: recipient.token,
+
+ const link = window.document.createElement('a');
+ const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
+
+ link.href = window.URL.createObjectURL(blob);
+ link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
+
+ link.click();
+
+ window.URL.revokeObjectURL(link.href);
+ } catch (error) {
+ toast({
+ title: 'Something went wrong',
+ description: 'An error occurred while trying to download file.',
+ variant: 'destructive',
});
}
-
- const documentData = document?.documentData;
-
- if (!documentData) {
- return;
- }
-
- const documentBytes = await getFile(documentData);
-
- const blob = new Blob([documentBytes], {
- type: 'application/pdf',
- });
-
- const link = window.document.createElement('a');
- const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
-
- link.href = window.URL.createObjectURL(blob);
- link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
-
- link.click();
-
- window.URL.revokeObjectURL(link.href);
};
return match({
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx
index c3e1f971c..9c3532f88 100644
--- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx
+++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx
@@ -32,7 +32,7 @@ import {
} from '@documenso/ui/primitives/dropdown-menu';
import { ResendDocumentActionItem } from './_action-items/resend-document';
-import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
+import { DeleteDocumentDialog } from './delete-document-dialog';
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
export type DataTableActionDropdownProps = {
@@ -60,7 +60,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
// const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
- const isDocumentDeletable = isOwner && row.status === DocumentStatus.DRAFT;
+ const isDocumentDeletable = isOwner;
const onDownloadClick = async () => {
let document: DocumentWithData | null = null;
@@ -161,8 +161,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
{isDocumentDeletable && (
-
diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx
index 9d07b8278..c8adb1422 100644
--- a/apps/web/src/app/(dashboard)/documents/data-table.tsx
+++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx
@@ -8,6 +8,7 @@ import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import type { Document, Recipient, User } from '@documenso/prisma/client';
+import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -74,12 +75,14 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
},
{
header: 'Actions',
- cell: ({ row }) => (
-
-
-
-
- ),
+ cell: ({ row }) =>
+ (!row.original.deletedAt ||
+ row.original.status === ExtendedDocumentStatus.COMPLETED) && (
+
+
+
+
+ ),
},
]}
data={results.data}
diff --git a/apps/web/src/app/(dashboard)/documents/delete-draft-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx
similarity index 53%
rename from apps/web/src/app/(dashboard)/documents/delete-draft-document-dialog.tsx
rename to apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx
index 1a458a13d..5b4a84286 100644
--- a/apps/web/src/app/(dashboard)/documents/delete-draft-document-dialog.tsx
+++ b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx
@@ -1,5 +1,8 @@
+import { useState } from 'react';
+
import { useRouter } from 'next/navigation';
+import { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -10,41 +13,46 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
+import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteDraftDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
+ status: DocumentStatus;
};
-export const DeleteDraftDocumentDialog = ({
+export const DeleteDocumentDialog = ({
id,
open,
onOpenChange,
+ status,
}: DeleteDraftDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
- const { mutateAsync: deleteDocument, isLoading } =
- trpcReact.document.deleteDraftDocument.useMutation({
- onSuccess: () => {
- router.refresh();
+ const [inputValue, setInputValue] = useState('');
+ const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
- toast({
- title: 'Document deleted',
- description: 'Your document has been successfully deleted.',
- duration: 5000,
- });
+ const { mutateAsync: deleteDocument, isLoading } = trpcReact.document.deleteDocument.useMutation({
+ onSuccess: () => {
+ router.refresh();
- onOpenChange(false);
- },
- });
+ toast({
+ title: 'Document deleted',
+ description: 'Your document has been successfully deleted.',
+ duration: 5000,
+ });
- const onDraftDelete = async () => {
+ onOpenChange(false);
+ },
+ });
+
+ const onDelete = async () => {
try {
- await deleteDocument({ id });
+ await deleteDocument({ id, status });
} catch {
toast({
title: 'Something went wrong',
@@ -55,6 +63,11 @@ export const DeleteDraftDocumentDialog = ({
}
};
+ const onInputChange = (event: React.ChangeEvent) => {
+ setInputValue(event.target.value);
+ setIsDeleteEnabled(event.target.value === 'delete');
+ };
+
return (
!isLoading && onOpenChange(value)}>
@@ -67,6 +80,17 @@ export const DeleteDraftDocumentDialog = ({
+ {status !== DocumentStatus.DRAFT && (
+
+
+
+ )}
+
-
+
Delete
diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx
index 9963d072a..5e93495e3 100644
--- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx
+++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx
@@ -9,6 +9,7 @@ import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
+import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { TRPCClientError } from '@documenso/trpc/client';
@@ -23,6 +24,7 @@ export type UploadDocumentProps = {
export const UploadDocument = ({ className }: UploadDocumentProps) => {
const router = useRouter();
+ const analytics = useAnalytics();
const { data: session } = useSession();
const { toast } = useToast();
@@ -55,6 +57,12 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
duration: 5000,
});
+ analytics.capture('App: Document Uploaded', {
+ userId: session?.user.id,
+ documentId: id,
+ timestamp: new Date().toISOString(),
+ });
+
router.push(`/documents/${id}`);
} catch (error) {
console.error(error);
diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts
index 0552c55ec..465d662a1 100644
--- a/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts
+++ b/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts
@@ -23,7 +23,7 @@ export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
let stripeCustomer: Stripe.Customer | null = null;
// Find the Stripe customer for the current user subscription.
- if (existingSubscription) {
+ if (existingSubscription?.periodEnd && existingSubscription.periodEnd >= new Date()) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {
diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
index b9a8ba6d7..54757667a 100644
--- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
@@ -67,18 +67,24 @@ export default async function CompletedSigningPage({
/>
- {match(document.status)
- .with(DocumentStatus.COMPLETED, () => (
+ {match({ status: document.status, deletedAt: document.deletedAt })
+ .with({ status: DocumentStatus.COMPLETED }, () => (
Everyone has signed
))
- .otherwise(() => (
+ .with({ deletedAt: null }, () => (
Waiting for others to sign
+ ))
+ .otherwise(() => (
+
+
+ Document no longer available to sign
+
))}
@@ -86,16 +92,22 @@ export default async function CompletedSigningPage({
"{document.title}"
- {match(document.status)
- .with(DocumentStatus.COMPLETED, () => (
+ {match({ status: document.status, deletedAt: document.deletedAt })
+ .with({ status: DocumentStatus.COMPLETED }, () => (
Everyone has signed! You will receive an Email copy of the signed document.
))
- .otherwise(() => (
+ .with({ deletedAt: null }, () => (
You will receive an Email copy of the signed document once everyone has signed.
+ ))
+ .otherwise(() => (
+
+ This document has been cancelled by the owner and is no longer available for others to
+ sign.
+
))}
diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx
index 6589572a3..29cd77995 100644
--- a/apps/web/src/app/(signing)/sign/[token]/form.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx
@@ -7,6 +7,7 @@ import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
+import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Document, Field, Recipient } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@@ -29,6 +30,7 @@ export type SigningFormProps = {
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
const router = useRouter();
+ const analytics = useAnalytics();
const { data: session } = useSession();
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
@@ -60,6 +62,12 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
documentId: document.id,
});
+ analytics.capture('App: Recipient has completed signing', {
+ signerId: recipient.id,
+ documentId: document.id,
+ timestamp: new Date().toISOString(),
+ });
+
router.push(`/sign/${recipient.token}/complete`);
};
diff --git a/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx b/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx
new file mode 100644
index 000000000..8c7051caa
--- /dev/null
+++ b/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+
+import Link from 'next/link';
+
+import { Clock8 } from 'lucide-react';
+import { useSession } from 'next-auth/react';
+
+import signingCelebration from '@documenso/assets/images/signing-celebration.png';
+import type { Document, Signature } from '@documenso/prisma/client';
+import { SigningCard3D } from '@documenso/ui/components/signing-card';
+
+type NoLongerAvailableProps = {
+ document: Document;
+ recipientName: string;
+ recipientSignature: Signature;
+};
+
+export const NoLongerAvailable = ({
+ document,
+ recipientName,
+ recipientSignature,
+}: NoLongerAvailableProps) => {
+ const { data: session } = useSession();
+
+ return (
+
+
+
+
+
+
+ Document Cancelled
+
+
+
+ "{document.title}"
+ is no longer available to sign
+
+
+
+ This document has been cancelled by the owner.
+
+
+ {session?.user ? (
+
+ Go Back Home
+
+ ) : (
+
+ Want to send slick signing links like this one?{' '}
+
+ Check out Documenso.
+
+
+ )}
+
+
+ );
+};
diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx
index 67e679412..97babb82f 100644
--- a/apps/web/src/app/(signing)/sign/[token]/page.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx
@@ -8,6 +8,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
+import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@@ -17,6 +18,7 @@ import { DateField } from './date-field';
import { EmailField } from './email-field';
import { SigningForm } from './form';
import { NameField } from './name-field';
+import { NoLongerAvailable } from './no-longer-available';
import { SigningProvider } from './provider';
import { SignatureField } from './signature-field';
@@ -55,6 +57,18 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
redirect(`/sign/${token}/complete`);
}
+ const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
+
+ if (document.deletedAt) {
+ return (
+
+ );
+ }
+
return (
([]);
+ const { data: searchDocumentsData, isLoading: isSearchingDocuments } =
+ trpcReact.document.searchDocuments.useQuery(
+ {
+ query: search,
+ },
+ {
+ keepPreviousData: true,
+ },
+ );
+
+ const searchResults = useMemo(() => {
+ if (!searchDocumentsData) {
+ return [];
+ }
+
+ return searchDocumentsData.map((document) => ({
+ label: document.title,
+ path: `/documents/${document.id}`,
+ value:
+ document.title + ' ' + document.Recipient.map((recipient) => recipient.email).join(' '),
+ }));
+ }, [searchDocumentsData]);
+
const currentPage = pages[pages.length - 1];
const toggleOpen = () => {
@@ -113,7 +144,13 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
};
return (
-
+
- No results found.
+ {isSearchingDocuments ? (
+
+
+
+
+
+
+
+ ) : (
+ No results found.
+ )}
{!currentPage && (
<>
@@ -133,6 +180,11 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
addPage('theme')}>Change theme
+ {searchResults.length > 0 && (
+
+
+
+ )}
>
)}
{currentPage === 'theme' && }
@@ -146,10 +198,14 @@ const Commands = ({
pages,
}: {
push: (_path: string) => void;
- pages: { label: string; path: string; shortcut?: string }[];
+ pages: { label: string; path: string; shortcut?: string; value?: string }[];
}) => {
- return pages.map((page) => (
- push(page.path)}>
+ return pages.map((page, idx) => (
+ push(page.path)}
+ >
{page.label}
{page.shortcut && {page.shortcut} }
diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
index 99761a0d3..e488ba6e9 100644
--- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
+++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
@@ -20,7 +20,7 @@ import { LuGithub } from 'react-icons/lu';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
-import { User } from '@documenso/prisma/client';
+import type { User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -56,7 +56,11 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
return (
-
+
{avatarFallback}
diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx
index ad803d9c1..d67449b17 100644
--- a/apps/web/src/components/forms/signup.tsx
+++ b/apps/web/src/components/forms/signup.tsx
@@ -5,6 +5,7 @@ import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
+import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -64,6 +65,11 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
password,
callbackUrl: '/',
});
+
+ analytics.capture('App: User Sign Up', {
+ email,
+ timestamp: new Date().toISOString(),
+ });
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
diff --git a/apps/web/src/pages/api/trpc/[trpc].ts b/apps/web/src/pages/api/trpc/[trpc].ts
index 0bc991a98..c43291ea1 100644
--- a/apps/web/src/pages/api/trpc/[trpc].ts
+++ b/apps/web/src/pages/api/trpc/[trpc].ts
@@ -4,6 +4,11 @@ import { appRouter } from '@documenso/trpc/server/router';
export const config = {
maxDuration: 60,
+ api: {
+ bodyParser: {
+ sizeLimit: '50mb',
+ },
+ },
};
export default trpcNext.createNextApiHandler({
diff --git a/lint-staged.config.cjs b/lint-staged.config.cjs
index 802b44c31..a975cb594 100644
--- a/lint-staged.config.cjs
+++ b/lint-staged.config.cjs
@@ -1,3 +1,4 @@
module.exports = {
'**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}': ['prettier --write'],
+ '**/*.yml': ['prettier --write'],
};
diff --git a/package-lock.json b/package-lock.json
index 4d4be5be4..61c4749e6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,7 +29,7 @@
},
"apps/marketing": {
"name": "@documenso/marketing",
- "version": "1.2.1",
+ "version": "1.2.3",
"license": "AGPL-3.0",
"dependencies": {
"@documenso/assets": "*",
@@ -85,7 +85,7 @@
},
"apps/web": {
"name": "@documenso/web",
- "version": "1.2.1",
+ "version": "1.2.3",
"license": "AGPL-3.0",
"dependencies": {
"@documenso/assets": "*",
@@ -19227,6 +19227,7 @@
"start-server-and-test": "^2.0.1"
},
"devDependencies": {
+ "@documenso/prisma": "*",
"@documenso/web": "*",
"@playwright/test": "^1.18.1",
"@types/node": "^20.8.2"
diff --git a/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts b/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts
new file mode 100644
index 000000000..12a099bbf
--- /dev/null
+++ b/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts
@@ -0,0 +1,192 @@
+import { expect, test } from '@playwright/test';
+
+import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents';
+
+test.describe.configure({ mode: 'serial' });
+
+test('[PR-711]: seeded documents should be visible', async ({ page }) => {
+ const [sender, ...recipients] = TEST_USERS;
+
+ await page.goto('/signin');
+
+ await page.getByLabel('Email').fill(sender.email);
+ await page.getByLabel('Password', { exact: true }).fill(sender.password);
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ await page.waitForURL('/documents');
+
+ await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
+
+ await page.getByTitle('Profile Dropdown').click();
+ await page.getByRole('menuitem', { name: 'Sign Out' }).click();
+
+ await page.waitForURL('/signin');
+
+ for (const recipient of recipients) {
+ await page.goto('/signin');
+
+ await page.getByLabel('Email').fill(recipient.email);
+ await page.getByLabel('Password', { exact: true }).fill(recipient.password);
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ await page.waitForURL('/documents');
+
+ await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
+
+ await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
+
+ await page.getByTitle('Profile Dropdown').click();
+ await page.getByRole('menuitem', { name: 'Sign Out' }).click();
+
+ await page.waitForURL('/signin');
+ }
+});
+
+test('[PR-711]: deleting a completed document should not remove it from recipients', async ({
+ page,
+}) => {
+ const [sender, ...recipients] = TEST_USERS;
+
+ await page.goto('/signin');
+
+ // sign in
+ await page.getByLabel('Email').fill(sender.email);
+ await page.getByLabel('Password', { exact: true }).fill(sender.password);
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ await page.waitForURL('/documents');
+
+ // open actions menu
+ await page
+ .locator('tr', { hasText: 'Document 1 - Completed' })
+ .getByRole('cell', { name: 'Download' })
+ .getByRole('button')
+ .nth(1)
+ .click();
+
+ // delete document
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
+ await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
+ await page.getByRole('button', { name: 'Delete' }).click();
+
+ await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
+
+ // signout
+ await page.getByTitle('Profile Dropdown').click();
+ await page.getByRole('menuitem', { name: 'Sign Out' }).click();
+
+ await page.waitForURL('/signin');
+
+ for (const recipient of recipients) {
+ await page.goto('/signin');
+
+ // sign in
+ await page.getByLabel('Email').fill(recipient.email);
+ await page.getByLabel('Password', { exact: true }).fill(recipient.password);
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ await page.waitForURL('/documents');
+
+ await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
+
+ await page.goto(`/sign/completed-token-${recipients.indexOf(recipient)}`);
+ await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
+
+ await page.goto('/documents');
+
+ await page.getByTitle('Profile Dropdown').click();
+ await page.getByRole('menuitem', { name: 'Sign Out' }).click();
+
+ await page.waitForURL('/signin');
+ }
+});
+
+test('[PR-711]: deleting a pending document should remove it from recipients', async ({ page }) => {
+ const [sender, ...recipients] = TEST_USERS;
+
+ for (const recipient of recipients) {
+ await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`);
+
+ await expect(page.getByText('Waiting for others to sign').nth(0)).toBeVisible();
+ }
+
+ await page.goto('/signin');
+
+ // sign in
+ await page.getByLabel('Email').fill(sender.email);
+ await page.getByLabel('Password', { exact: true }).fill(sender.password);
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ await page.waitForURL('/documents');
+
+ // open actions menu
+ await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
+
+ // delete document
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
+ await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
+ await page.getByRole('button', { name: 'Delete' }).click();
+
+ await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
+
+ // signout
+ await page.getByTitle('Profile Dropdown').click();
+ await page.getByRole('menuitem', { name: 'Sign Out' }).click();
+
+ await page.waitForURL('/signin');
+
+ for (const recipient of recipients) {
+ await page.goto('/signin');
+
+ // sign in
+ await page.getByLabel('Email').fill(recipient.email);
+ await page.getByLabel('Password', { exact: true }).fill(recipient.password);
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ await page.waitForURL('/documents');
+
+ await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
+
+ await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`);
+ await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
+
+ await page.goto('/documents');
+
+ await page.getByTitle('Profile Dropdown').click();
+ await page.getByRole('menuitem', { name: 'Sign Out' }).click();
+
+ await page.waitForURL('/signin');
+ }
+});
+
+test('[PR-711]: deleting a draft document should remove it without additional prompting', async ({
+ page,
+}) => {
+ const [sender] = TEST_USERS;
+
+ await page.goto('/signin');
+
+ // sign in
+ await page.getByLabel('Email').fill(sender.email);
+ await page.getByLabel('Password', { exact: true }).fill(sender.password);
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ await page.waitForURL('/documents');
+
+ // open actions menu
+ await page
+ .locator('tr', { hasText: 'Document 1 - Draft' })
+ .getByRole('cell', { name: 'Edit' })
+ .getByRole('button')
+ .click();
+
+ // delete document
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
+ await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible();
+ await page.getByRole('button', { name: 'Delete' }).click();
+
+ await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
+});
diff --git a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts
new file mode 100644
index 000000000..e9ae60d0e
--- /dev/null
+++ b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts
@@ -0,0 +1,72 @@
+import { expect, test } from '@playwright/test';
+
+import { TEST_USERS } from '@documenso/prisma/seed/pr-713-add-document-search-to-command-menu';
+
+test('[PR-713]: should see sent documents', async ({ page }) => {
+ const [user] = TEST_USERS;
+
+ await page.goto('/signin');
+
+ await page.getByLabel('Email').fill(user.email);
+ await page.getByLabel('Password', { exact: true }).fill(user.password);
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ await page.waitForURL('/documents');
+
+ await page.keyboard.press('Meta+K');
+
+ await page.getByPlaceholder('Type a command or search...').fill('sent');
+ await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
+
+ await page.keyboard.press('Escape');
+
+ // signout
+ await page.getByTitle('Profile Dropdown').click();
+ await page.getByRole('menuitem', { name: 'Sign Out' }).click();
+});
+
+test('[PR-713]: should see received documents', async ({ page }) => {
+ const [user] = TEST_USERS;
+
+ await page.goto('/signin');
+
+ await page.getByLabel('Email').fill(user.email);
+ await page.getByLabel('Password', { exact: true }).fill(user.password);
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ await page.waitForURL('/documents');
+
+ await page.keyboard.press('Meta+K');
+
+ await page.getByPlaceholder('Type a command or search...').fill('received');
+ await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
+
+ await page.keyboard.press('Escape');
+
+ // signout
+ await page.getByTitle('Profile Dropdown').click();
+ await page.getByRole('menuitem', { name: 'Sign Out' }).click();
+});
+
+test('[PR-713]: should be able to search by recipient', async ({ page }) => {
+ const [user, recipient] = TEST_USERS;
+
+ await page.goto('/signin');
+
+ await page.getByLabel('Email').fill(user.email);
+ await page.getByLabel('Password', { exact: true }).fill(user.password);
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ await page.waitForURL('/documents');
+
+ await page.keyboard.press('Meta+K');
+
+ await page.getByPlaceholder('Type a command or search...').fill(recipient.email);
+ await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
+
+ await page.keyboard.press('Escape');
+
+ // signout
+ await page.getByTitle('Profile Dropdown').click();
+ await page.getByRole('menuitem', { name: 'Sign Out' }).click();
+});
diff --git a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts b/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts
new file mode 100644
index 000000000..6e03979c0
--- /dev/null
+++ b/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts
@@ -0,0 +1,75 @@
+import { expect, test } from '@playwright/test';
+import path from 'node:path';
+
+import { TEST_USER } from '@documenso/prisma/seed/pr-718-add-stepper-component';
+
+test(`[PR-718]: should be able to create a document`, async ({ page }) => {
+ await page.goto('/signin');
+
+ const documentTitle = `example-${Date.now()}.pdf`;
+
+ // Sign in
+ await page.getByLabel('Email').fill(TEST_USER.email);
+ await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ // Upload document
+ const [fileChooser] = await Promise.all([
+ page.waitForEvent('filechooser'),
+ page.locator('input[type=file]').evaluate((e) => {
+ if (e instanceof HTMLInputElement) {
+ e.click();
+ }
+ }),
+ ]);
+
+ await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
+
+ // Wait to be redirected to the edit page
+ await page.waitForURL(/\/documents\/\d+/);
+
+ // Set title
+ await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
+
+ await page.getByLabel('Title').fill(documentTitle);
+
+ await page.getByRole('button', { name: 'Continue' }).click();
+
+ // Add signers
+ await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
+
+ await page.getByLabel('Email*').fill('user1@example.com');
+ await page.getByLabel('Name').fill('User 1');
+
+ await page.getByRole('button', { name: 'Continue' }).click();
+
+ // Add fields
+ await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'User 1 Signature' }).click();
+ await page.locator('canvas').click({
+ position: {
+ x: 100,
+ y: 100,
+ },
+ });
+
+ await page.getByRole('button', { name: 'Email Email' }).click();
+ await page.locator('canvas').click({
+ position: {
+ x: 100,
+ y: 200,
+ },
+ });
+
+ await page.getByRole('button', { name: 'Continue' }).click();
+
+ // Add subject and send
+ await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
+ await page.getByRole('button', { name: 'Send' }).click();
+
+ await page.waitForURL('/documents');
+
+ // Assert document was created
+ await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
+});
diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/test-auth-flow.spec.ts
index 1221dbf83..9a07ec3c7 100644
--- a/packages/app-tests/e2e/test-auth-flow.spec.ts
+++ b/packages/app-tests/e2e/test-auth-flow.spec.ts
@@ -4,15 +4,15 @@ import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
test.use({ storageState: { cookies: [], origins: [] } });
-/*
+/*
Using them sequentially so the 2nd test
uses the details from the 1st (registration) test
*/
test.describe.configure({ mode: 'serial' });
-const username = process.env.E2E_TEST_AUTHENTICATE_USERNAME;
-const email = process.env.E2E_TEST_AUTHENTICATE_USER_EMAIL;
-const password = process.env.E2E_TEST_AUTHENTICATE_USER_PASSWORD;
+const username = 'Test User';
+const email = 'test-user@auth-flow.documenso.com';
+const password = 'Password123';
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
await page.goto('/signup');
diff --git a/packages/app-tests/package.json b/packages/app-tests/package.json
index 92cfd169d..9dcb32f7d 100644
--- a/packages/app-tests/package.json
+++ b/packages/app-tests/package.json
@@ -6,13 +6,14 @@
"main": "index.js",
"scripts": {
"test:dev": "playwright test",
- "test:e2e": "start-server-and-test \"(cd ../../apps/web && npm run start)\" http://localhost:3000 \"playwright test\""
+ "test:e2e": "start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
},
"keywords": [],
"author": "",
"devDependencies": {
"@playwright/test": "^1.18.1",
"@types/node": "^20.8.2",
+ "@documenso/prisma": "*",
"@documenso/web": "*"
},
"dependencies": {
diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts
index 463b6f97d..672c2f7ef 100644
--- a/packages/app-tests/playwright.config.ts
+++ b/packages/app-tests/playwright.config.ts
@@ -28,8 +28,12 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
+
+ video: 'retain-on-failure',
},
+ timeout: 30_000,
+
/* Configure projects for major browsers */
projects: [
{
diff --git a/packages/app-tests/tsconfig.json b/packages/app-tests/tsconfig.json
new file mode 100644
index 000000000..fdefbd544
--- /dev/null
+++ b/packages/app-tests/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@documenso/tsconfig/react-library.json",
+ "compilerOptions": {
+ "types": ["@documenso/tsconfig/process-env.d.ts"]
+ },
+ "include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
+ "exclude": ["dist", "build", "node_modules"]
+}
diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts
index dd2079122..3058ed261 100644
--- a/packages/ee/server-only/stripe/webhook/handler.ts
+++ b/packages/ee/server-only/stripe/webhook/handler.ts
@@ -1,9 +1,10 @@
-import { NextApiRequest, NextApiResponse } from 'next';
+import type { NextApiRequest, NextApiResponse } from 'next';
import { buffer } from 'micro';
import { match } from 'ts-pattern';
-import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
+import type { Stripe } from '@documenso/lib/server-only/stripe';
+import { stripe } from '@documenso/lib/server-only/stripe';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma';
@@ -174,6 +175,13 @@ export const stripeWebhookHandler = async (
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
+ if (subscription.status === 'incomplete_expired') {
+ return res.status(200).json({
+ success: true,
+ message: 'Webhook received',
+ });
+ }
+
const result = await prisma.subscription.findFirst({
select: {
userId: true,
@@ -218,6 +226,13 @@ export const stripeWebhookHandler = async (
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
+ if (subscription.status === 'incomplete_expired') {
+ return res.status(200).json({
+ success: true,
+ message: 'Webhook received',
+ });
+ }
+
const result = await prisma.subscription.findFirst({
select: {
userId: true,
diff --git a/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts b/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts
index a8403e05a..22f60069e 100644
--- a/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts
+++ b/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts
@@ -1,4 +1,4 @@
-import Stripe from 'stripe';
+import type Stripe from 'stripe';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
diff --git a/packages/email/template-components/template-document-cancel.tsx b/packages/email/template-components/template-document-cancel.tsx
new file mode 100644
index 000000000..885cb6c80
--- /dev/null
+++ b/packages/email/template-components/template-document-cancel.tsx
@@ -0,0 +1,34 @@
+import { Section, Text } from '../components';
+import { TemplateDocumentImage } from './template-document-image';
+
+export interface TemplateDocumentCancelProps {
+ inviterName: string;
+ inviterEmail: string;
+ documentName: string;
+ assetBaseUrl: string;
+}
+
+export const TemplateDocumentCancel = ({
+ inviterName,
+ documentName,
+ assetBaseUrl,
+}: TemplateDocumentCancelProps) => {
+ return (
+ <>
+
+
+
+
+ {inviterName} has cancelled the document
+ "{documentName}"
+
+
+
+ You don't need to sign it anymore.
+
+
+ >
+ );
+};
+
+export default TemplateDocumentCancel;
diff --git a/packages/email/templates/document-cancel.tsx b/packages/email/templates/document-cancel.tsx
new file mode 100644
index 000000000..66892bccc
--- /dev/null
+++ b/packages/email/templates/document-cancel.tsx
@@ -0,0 +1,66 @@
+import config from '@documenso/tailwind-config';
+
+import { Body, Container, Head, Hr, Html, Img, Preview, Section, Tailwind } from '../components';
+import type { TemplateDocumentCancelProps } from '../template-components/template-document-cancel';
+import { TemplateDocumentCancel } from '../template-components/template-document-cancel';
+import { TemplateFooter } from '../template-components/template-footer';
+
+export type DocumentCancelEmailTemplateProps = Partial;
+
+export const DocumentCancelTemplate = ({
+ inviterName = 'Lucas Smith',
+ inviterEmail = 'lucas@documenso.com',
+ documentName = 'Open Source Pledge.pdf',
+ assetBaseUrl = 'http://localhost:3002',
+}: DocumentCancelEmailTemplateProps) => {
+ const previewText = `${inviterName} has cancelled the document ${documentName}, you don't need to sign it anymore.`;
+
+ const getAssetUrl = (path: string) => {
+ return new URL(path, assetBaseUrl).toString();
+ };
+
+ return (
+
+
+ {previewText}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DocumentCancelTemplate;
diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts
new file mode 100644
index 000000000..22365a727
--- /dev/null
+++ b/packages/lib/server-only/document/delete-document.ts
@@ -0,0 +1,88 @@
+'use server';
+
+import { createElement } from 'react';
+
+import { mailer } from '@documenso/email/mailer';
+import { render } from '@documenso/email/render';
+import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
+import { prisma } from '@documenso/prisma';
+import { DocumentStatus } from '@documenso/prisma/client';
+
+import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
+
+export type DeleteDocumentOptions = {
+ id: number;
+ userId: number;
+ status: DocumentStatus;
+};
+
+export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => {
+ // if the document is a draft, hard-delete
+ if (status === DocumentStatus.DRAFT) {
+ return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
+ }
+
+ // if the document is pending, send cancellation emails to all recipients
+ if (status === DocumentStatus.PENDING) {
+ const user = await prisma.user.findFirstOrThrow({
+ where: {
+ id: userId,
+ },
+ });
+
+ const document = await prisma.document.findUnique({
+ where: {
+ id,
+ status,
+ userId,
+ },
+ include: {
+ Recipient: true,
+ documentMeta: true,
+ },
+ });
+
+ if (!document) {
+ throw new Error('Document not found');
+ }
+
+ if (document.Recipient.length > 0) {
+ await Promise.all(
+ document.Recipient.map(async (recipient) => {
+ const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
+
+ const template = createElement(DocumentCancelTemplate, {
+ documentName: document.title,
+ inviterName: user.name || undefined,
+ inviterEmail: user.email,
+ assetBaseUrl,
+ });
+
+ await mailer.sendMail({
+ to: {
+ address: recipient.email,
+ name: recipient.name,
+ },
+ from: {
+ name: FROM_NAME,
+ address: FROM_ADDRESS,
+ },
+ subject: 'Document Cancelled',
+ html: render(template),
+ text: render(template, { plainText: true }),
+ });
+ }),
+ );
+ }
+ }
+
+ // If the document is not a draft, only soft-delete.
+ return await prisma.document.update({
+ where: {
+ id,
+ },
+ data: {
+ deletedAt: new Date().toISOString(),
+ },
+ });
+};
diff --git a/packages/lib/server-only/document/delete-draft-document.ts b/packages/lib/server-only/document/delete-draft-document.ts
deleted file mode 100644
index 6b0bc3511..000000000
--- a/packages/lib/server-only/document/delete-draft-document.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-'use server';
-
-import { prisma } from '@documenso/prisma';
-import { DocumentStatus } from '@documenso/prisma/client';
-
-export type DeleteDraftDocumentOptions = {
- id: number;
- userId: number;
-};
-
-export const deleteDraftDocument = async ({ id, userId }: DeleteDraftDocumentOptions) => {
- return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
-};
diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts
index 20b7eb369..a27458a55 100644
--- a/packages/lib/server-only/document/find-documents.ts
+++ b/packages/lib/server-only/document/find-documents.ts
@@ -55,17 +55,25 @@ export const findDocuments = async ({
OR: [
{
userId,
+ deletedAt: null,
},
{
- status: {
- not: ExtendedDocumentStatus.DRAFT,
- },
+ status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
some: {
email: user.email,
},
},
},
+ {
+ status: ExtendedDocumentStatus.PENDING,
+ Recipient: {
+ some: {
+ email: user.email,
+ },
+ },
+ deletedAt: null,
+ },
],
}))
.with(ExtendedDocumentStatus.INBOX, () => ({
@@ -78,26 +86,29 @@ export const findDocuments = async ({
signingStatus: SigningStatus.NOT_SIGNED,
},
},
+ deletedAt: null,
}))
.with(ExtendedDocumentStatus.DRAFT, () => ({
userId,
status: ExtendedDocumentStatus.DRAFT,
+ deletedAt: null,
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
OR: [
{
userId,
status: ExtendedDocumentStatus.PENDING,
+ deletedAt: null,
},
{
status: ExtendedDocumentStatus.PENDING,
-
Recipient: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
},
},
+ deletedAt: null,
},
],
}))
@@ -106,6 +117,7 @@ export const findDocuments = async ({
{
userId,
status: ExtendedDocumentStatus.COMPLETED,
+ deletedAt: null,
},
{
status: ExtendedDocumentStatus.COMPLETED,
diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts
index 6e875f9be..a446b0007 100644
--- a/packages/lib/server-only/document/get-stats.ts
+++ b/packages/lib/server-only/document/get-stats.ts
@@ -1,5 +1,6 @@
import { prisma } from '@documenso/prisma';
-import { SigningStatus, User } from '@documenso/prisma/client';
+import type { User } from '@documenso/prisma/client';
+import { SigningStatus } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@@ -16,6 +17,7 @@ export const getStats = async ({ user }: GetStatsInput) => {
},
where: {
userId: user.id,
+ deletedAt: null,
},
}),
prisma.document.groupBy({
@@ -31,6 +33,7 @@ export const getStats = async ({ user }: GetStatsInput) => {
signingStatus: SigningStatus.NOT_SIGNED,
},
},
+ deletedAt: null,
},
}),
prisma.document.groupBy({
@@ -39,15 +42,27 @@ export const getStats = async ({ user }: GetStatsInput) => {
_all: true,
},
where: {
- status: {
- not: ExtendedDocumentStatus.DRAFT,
- },
- Recipient: {
- some: {
- email: user.email,
- signingStatus: SigningStatus.SIGNED,
+ OR: [
+ {
+ status: ExtendedDocumentStatus.PENDING,
+ Recipient: {
+ some: {
+ email: user.email,
+ signingStatus: SigningStatus.SIGNED,
+ },
+ },
+ deletedAt: null,
},
- },
+ {
+ status: ExtendedDocumentStatus.COMPLETED,
+ Recipient: {
+ some: {
+ email: user.email,
+ signingStatus: SigningStatus.SIGNED,
+ },
+ },
+ },
+ ],
},
}),
]);
diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts
index 318d540b8..5fa4b1a00 100644
--- a/packages/lib/server-only/document/seal-document.ts
+++ b/packages/lib/server-only/document/seal-document.ts
@@ -1,8 +1,10 @@
'use server';
+import { nanoid } from 'nanoid';
import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
+import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
@@ -83,6 +85,18 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
arrayBuffer: async () => Promise.resolve(pdfBuffer),
});
+ const postHog = PostHogServerClient();
+
+ if (postHog) {
+ postHog.capture({
+ distinctId: nanoid(),
+ event: 'App: Document Sealed',
+ properties: {
+ documentId: document.id,
+ },
+ });
+ }
+
await prisma.documentData.update({
where: {
id: documentData.id,
diff --git a/packages/lib/server-only/document/search-documents-with-keyword.ts b/packages/lib/server-only/document/search-documents-with-keyword.ts
new file mode 100644
index 000000000..c4014d37f
--- /dev/null
+++ b/packages/lib/server-only/document/search-documents-with-keyword.ts
@@ -0,0 +1,81 @@
+import { prisma } from '@documenso/prisma';
+import { DocumentStatus } from '@documenso/prisma/client';
+
+export type SearchDocumentsWithKeywordOptions = {
+ query: string;
+ userId: number;
+ limit?: number;
+};
+
+export const searchDocumentsWithKeyword = async ({
+ query,
+ userId,
+ limit = 5,
+}: SearchDocumentsWithKeywordOptions) => {
+ const user = await prisma.user.findFirstOrThrow({
+ where: {
+ id: userId,
+ },
+ });
+
+ const documents = await prisma.document.findMany({
+ where: {
+ OR: [
+ {
+ title: {
+ contains: query,
+ mode: 'insensitive',
+ },
+ userId: userId,
+ deletedAt: null,
+ },
+ {
+ Recipient: {
+ some: {
+ email: {
+ contains: query,
+ mode: 'insensitive',
+ },
+ },
+ },
+ userId: userId,
+ deletedAt: null,
+ },
+ {
+ status: DocumentStatus.COMPLETED,
+ Recipient: {
+ some: {
+ email: user.email,
+ },
+ },
+ title: {
+ contains: query,
+ mode: 'insensitive',
+ },
+ },
+ {
+ status: DocumentStatus.PENDING,
+ Recipient: {
+ some: {
+ email: user.email,
+ },
+ },
+ title: {
+ contains: query,
+ mode: 'insensitive',
+ },
+ deletedAt: null,
+ },
+ ],
+ },
+ include: {
+ Recipient: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ take: limit,
+ });
+
+ return documents;
+};
diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts
index 3087d64a9..6640a6a07 100644
--- a/packages/lib/server-only/field/sign-field-with-token.ts
+++ b/packages/lib/server-only/field/sign-field-with-token.ts
@@ -37,6 +37,10 @@ export const signFieldWithToken = async ({
throw new Error(`Document ${document.id} has already been completed`);
}
+ if (document.deletedAt) {
+ throw new Error(`Document ${document.id} has been deleted`);
+ }
+
if (recipient?.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
diff --git a/packages/lib/universal/get-feature-flag.ts b/packages/lib/universal/get-feature-flag.ts
index 38707d41b..0fc0aa131 100644
--- a/packages/lib/universal/get-feature-flag.ts
+++ b/packages/lib/universal/get-feature-flag.ts
@@ -1,9 +1,7 @@
import { z } from 'zod';
-import {
- TFeatureFlagValue,
- ZFeatureFlagValueSchema,
-} from '@documenso/lib/client-only/providers/feature-flag.types';
+import type { TFeatureFlagValue } from '@documenso/lib/client-only/providers/feature-flag.types';
+import { ZFeatureFlagValueSchema } from '@documenso/lib/client-only/providers/feature-flag.types';
import { APP_BASE_URL } from '@documenso/lib/constants/app';
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
diff --git a/packages/prisma/migrations/20231202134005_deletedocuments/migration.sql b/packages/prisma/migrations/20231202134005_deletedocuments/migration.sql
new file mode 100644
index 000000000..37b5e5ba1
--- /dev/null
+++ b/packages/prisma/migrations/20231202134005_deletedocuments/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Document" ADD COLUMN "deletedAt" TIMESTAMP(3);
diff --git a/packages/prisma/migrations/20231205000309_add_cascade_delete_for_verification_tokens/migration.sql b/packages/prisma/migrations/20231205000309_add_cascade_delete_for_verification_tokens/migration.sql
new file mode 100644
index 000000000..26d7cce51
--- /dev/null
+++ b/packages/prisma/migrations/20231205000309_add_cascade_delete_for_verification_tokens/migration.sql
@@ -0,0 +1,5 @@
+-- DropForeignKey
+ALTER TABLE "VerificationToken" DROP CONSTRAINT "VerificationToken_userId_fkey";
+
+-- AddForeignKey
+ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 7407bc5c0..75c175adc 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -60,7 +60,7 @@ model VerificationToken {
expires DateTime
createdAt DateTime @default(now())
userId Int
- user User @relation(fields: [userId], references: [id])
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum SubscriptionStatus {
@@ -135,6 +135,7 @@ model Document {
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
completedAt DateTime?
+ deletedAt DateTime?
@@unique([documentDataId])
@@index([userId])
diff --git a/packages/prisma/seed-database.ts b/packages/prisma/seed-database.ts
index 65daa357e..e9db2a30c 100644
--- a/packages/prisma/seed-database.ts
+++ b/packages/prisma/seed-database.ts
@@ -1,74 +1,22 @@
-import { DocumentDataType, Role } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
-import { hashSync } from '@documenso/lib/server-only/auth/hash';
-
-import { prisma } from './index';
-
const seedDatabase = async () => {
- const examplePdf = fs
- .readFileSync(path.join(__dirname, '../../assets/example.pdf'))
- .toString('base64');
+ const files = fs.readdirSync(path.join(__dirname, './seed'));
- const exampleUser = await prisma.user.upsert({
- where: {
- email: 'example@documenso.com',
- },
- create: {
- name: 'Example User',
- email: 'example@documenso.com',
- password: hashSync('password'),
- roles: [Role.USER],
- },
- update: {},
- });
+ for (const file of files) {
+ const stat = fs.statSync(path.join(__dirname, './seed', file));
- const adminUser = await prisma.user.upsert({
- where: {
- email: 'admin@documenso.com',
- },
- create: {
- name: 'Admin User',
- email: 'admin@documenso.com',
- password: hashSync('password'),
- roles: [Role.USER, Role.ADMIN],
- },
- update: {},
- });
+ if (stat.isFile()) {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const mod = require(path.join(__dirname, './seed', file));
- const examplePdfData = await prisma.documentData.upsert({
- where: {
- id: 'clmn0kv5k0000pe04vcqg5zla',
- },
- create: {
- id: 'clmn0kv5k0000pe04vcqg5zla',
- type: DocumentDataType.BYTES_64,
- data: examplePdf,
- initialData: examplePdf,
- },
- update: {},
- });
-
- await prisma.document.upsert({
- where: {
- id: 1,
- },
- create: {
- id: 1,
- title: 'Example Document',
- documentDataId: examplePdfData.id,
- userId: exampleUser.id,
- Recipient: {
- create: {
- name: String(adminUser.name),
- email: adminUser.email,
- token: Math.random().toString(36).slice(2, 9),
- },
- },
- },
- update: {},
- });
+ if ('seedDatabase' in mod && typeof mod.seedDatabase === 'function') {
+ console.log(`[SEEDING]: ${file}`);
+ await mod.seedDatabase();
+ }
+ }
+ }
};
seedDatabase()
diff --git a/packages/prisma/seed/initial-seed.ts b/packages/prisma/seed/initial-seed.ts
new file mode 100644
index 000000000..b01c2d434
--- /dev/null
+++ b/packages/prisma/seed/initial-seed.ts
@@ -0,0 +1,67 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { hashSync } from '@documenso/lib/server-only/auth/hash';
+
+import { prisma } from '..';
+import { DocumentDataType, Role } from '../client';
+
+export const seedDatabase = async () => {
+ const examplePdf = fs
+ .readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
+ .toString('base64');
+
+ const exampleUser = await prisma.user.upsert({
+ where: {
+ email: 'example@documenso.com',
+ },
+ create: {
+ name: 'Example User',
+ email: 'example@documenso.com',
+ password: hashSync('password'),
+ roles: [Role.USER],
+ },
+ update: {},
+ });
+
+ const adminUser = await prisma.user.upsert({
+ where: {
+ email: 'admin@documenso.com',
+ },
+ create: {
+ name: 'Admin User',
+ email: 'admin@documenso.com',
+ password: hashSync('password'),
+ roles: [Role.USER, Role.ADMIN],
+ },
+ update: {},
+ });
+
+ const examplePdfData = await prisma.documentData.upsert({
+ where: {
+ id: 'clmn0kv5k0000pe04vcqg5zla',
+ },
+ create: {
+ id: 'clmn0kv5k0000pe04vcqg5zla',
+ type: DocumentDataType.BYTES_64,
+ data: examplePdf,
+ initialData: examplePdf,
+ },
+ update: {},
+ });
+
+ await prisma.document.create({
+ data: {
+ title: 'Example Document',
+ documentDataId: examplePdfData.id,
+ userId: exampleUser.id,
+ Recipient: {
+ create: {
+ name: String(adminUser.name),
+ email: adminUser.email,
+ token: Math.random().toString(36).slice(2, 9),
+ },
+ },
+ },
+ });
+};
diff --git a/packages/prisma/seed/pr-711-deletion-of-documents.ts b/packages/prisma/seed/pr-711-deletion-of-documents.ts
new file mode 100644
index 000000000..7542cdb84
--- /dev/null
+++ b/packages/prisma/seed/pr-711-deletion-of-documents.ts
@@ -0,0 +1,221 @@
+import type { User } from '@prisma/client';
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { hashSync } from '@documenso/lib/server-only/auth/hash';
+
+import { prisma } from '..';
+import {
+ DocumentDataType,
+ DocumentStatus,
+ FieldType,
+ Prisma,
+ ReadStatus,
+ SendStatus,
+ SigningStatus,
+} from '../client';
+
+const PULL_REQUEST_NUMBER = 711;
+const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`;
+
+export const TEST_USERS = [
+ {
+ name: 'Sender 1',
+ email: `sender1@${EMAIL_DOMAIN}`,
+ password: 'Password123',
+ },
+ {
+ name: 'Sender 2',
+ email: `sender2@${EMAIL_DOMAIN}`,
+ password: 'Password123',
+ },
+ {
+ name: 'Sender 3',
+ email: `sender3@${EMAIL_DOMAIN}`,
+ password: 'Password123',
+ },
+] as const;
+
+const examplePdf = fs
+ .readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
+ .toString('base64');
+
+export const seedDatabase = async () => {
+ const users = await Promise.all(
+ TEST_USERS.map(async (u) =>
+ prisma.user.create({
+ data: {
+ name: u.name,
+ email: u.email,
+ password: hashSync(u.password),
+ emailVerified: new Date(),
+ },
+ }),
+ ),
+ );
+
+ const [user1, user2, user3] = users;
+
+ await createDraftDocument(user1, [user2, user3]);
+ await createPendingDocument(user1, [user2, user3]);
+ await createCompletedDocument(user1, [user2, user3]);
+};
+
+const createDraftDocument = async (sender: User, recipients: User[]) => {
+ const documentData = await prisma.documentData.create({
+ data: {
+ type: DocumentDataType.BYTES_64,
+ data: examplePdf,
+ initialData: examplePdf,
+ },
+ });
+
+ const document = await prisma.document.create({
+ data: {
+ title: `[${PULL_REQUEST_NUMBER}] Document 1 - Draft`,
+ status: DocumentStatus.DRAFT,
+ documentDataId: documentData.id,
+ userId: sender.id,
+ },
+ });
+
+ for (const recipient of recipients) {
+ const index = recipients.indexOf(recipient);
+
+ await prisma.recipient.create({
+ data: {
+ email: String(recipient.email),
+ name: String(recipient.name),
+ token: `draft-token-${index}`,
+ readStatus: ReadStatus.NOT_OPENED,
+ sendStatus: SendStatus.NOT_SENT,
+ signingStatus: SigningStatus.NOT_SIGNED,
+ signedAt: new Date(),
+ Document: {
+ connect: {
+ id: document.id,
+ },
+ },
+ Field: {
+ create: {
+ page: 1,
+ type: FieldType.NAME,
+ inserted: true,
+ customText: String(recipient.name),
+ positionX: new Prisma.Decimal(1),
+ positionY: new Prisma.Decimal(1),
+ width: new Prisma.Decimal(1),
+ height: new Prisma.Decimal(1),
+ documentId: document.id,
+ },
+ },
+ },
+ });
+ }
+};
+
+const createPendingDocument = async (sender: User, recipients: User[]) => {
+ const documentData = await prisma.documentData.create({
+ data: {
+ type: DocumentDataType.BYTES_64,
+ data: examplePdf,
+ initialData: examplePdf,
+ },
+ });
+
+ const document = await prisma.document.create({
+ data: {
+ title: `[${PULL_REQUEST_NUMBER}] Document 1 - Pending`,
+ status: DocumentStatus.PENDING,
+ documentDataId: documentData.id,
+ userId: sender.id,
+ },
+ });
+
+ for (const recipient of recipients) {
+ const index = recipients.indexOf(recipient);
+
+ await prisma.recipient.create({
+ data: {
+ email: String(recipient.email),
+ name: String(recipient.name),
+ token: `pending-token-${index}`,
+ readStatus: ReadStatus.OPENED,
+ sendStatus: SendStatus.SENT,
+ signingStatus: SigningStatus.SIGNED,
+ signedAt: new Date(),
+ Document: {
+ connect: {
+ id: document.id,
+ },
+ },
+ Field: {
+ create: {
+ page: 1,
+ type: FieldType.NAME,
+ inserted: true,
+ customText: String(recipient.name),
+ positionX: new Prisma.Decimal(1),
+ positionY: new Prisma.Decimal(1),
+ width: new Prisma.Decimal(1),
+ height: new Prisma.Decimal(1),
+ documentId: document.id,
+ },
+ },
+ },
+ });
+ }
+};
+
+const createCompletedDocument = async (sender: User, recipients: User[]) => {
+ const documentData = await prisma.documentData.create({
+ data: {
+ type: DocumentDataType.BYTES_64,
+ data: examplePdf,
+ initialData: examplePdf,
+ },
+ });
+
+ const document = await prisma.document.create({
+ data: {
+ title: `[${PULL_REQUEST_NUMBER}] Document 1 - Completed`,
+ status: DocumentStatus.COMPLETED,
+ documentDataId: documentData.id,
+ userId: sender.id,
+ },
+ });
+
+ for (const recipient of recipients) {
+ const index = recipients.indexOf(recipient);
+
+ await prisma.recipient.create({
+ data: {
+ email: String(recipient.email),
+ name: String(recipient.name),
+ token: `completed-token-${index}`,
+ readStatus: ReadStatus.OPENED,
+ sendStatus: SendStatus.SENT,
+ signingStatus: SigningStatus.SIGNED,
+ signedAt: new Date(),
+ Document: {
+ connect: {
+ id: document.id,
+ },
+ },
+ Field: {
+ create: {
+ page: 1,
+ type: FieldType.NAME,
+ inserted: true,
+ customText: String(recipient.name),
+ positionX: new Prisma.Decimal(1),
+ positionY: new Prisma.Decimal(1),
+ width: new Prisma.Decimal(1),
+ height: new Prisma.Decimal(1),
+ documentId: document.id,
+ },
+ },
+ },
+ });
+ }
+};
diff --git a/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts b/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts
new file mode 100644
index 000000000..22e8897a9
--- /dev/null
+++ b/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts
@@ -0,0 +1,167 @@
+import type { User } from '@prisma/client';
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { hashSync } from '@documenso/lib/server-only/auth/hash';
+
+import { prisma } from '..';
+import {
+ DocumentDataType,
+ DocumentStatus,
+ FieldType,
+ Prisma,
+ ReadStatus,
+ SendStatus,
+ SigningStatus,
+} from '../client';
+
+//
+// https://github.com/documenso/documenso/pull/713
+//
+
+const PULL_REQUEST_NUMBER = 713;
+
+const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`;
+
+export const TEST_USERS = [
+ {
+ name: 'User 1',
+ email: `user1@${EMAIL_DOMAIN}`,
+ password: 'Password123',
+ },
+ {
+ name: 'User 2',
+ email: `user2@${EMAIL_DOMAIN}`,
+ password: 'Password123',
+ },
+] as const;
+
+const examplePdf = fs
+ .readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
+ .toString('base64');
+
+export const seedDatabase = async () => {
+ const users = await Promise.all(
+ TEST_USERS.map(async (u) =>
+ prisma.user.create({
+ data: {
+ name: u.name,
+ email: u.email,
+ password: hashSync(u.password),
+ emailVerified: new Date(),
+ },
+ }),
+ ),
+ );
+
+ const [user1, user2] = users;
+
+ await createSentDocument(user1, [user2]);
+ await createReceivedDocument(user2, [user1]);
+};
+
+const createSentDocument = async (sender: User, recipients: User[]) => {
+ const documentData = await prisma.documentData.create({
+ data: {
+ type: DocumentDataType.BYTES_64,
+ data: examplePdf,
+ initialData: examplePdf,
+ },
+ });
+
+ const document = await prisma.document.create({
+ data: {
+ title: `[${PULL_REQUEST_NUMBER}] Document - Sent`,
+ status: DocumentStatus.PENDING,
+ documentDataId: documentData.id,
+ userId: sender.id,
+ },
+ });
+
+ for (const recipient of recipients) {
+ const index = recipients.indexOf(recipient);
+
+ await prisma.recipient.create({
+ data: {
+ email: String(recipient.email),
+ name: String(recipient.name),
+ token: `sent-token-${index}`,
+ readStatus: ReadStatus.NOT_OPENED,
+ sendStatus: SendStatus.SENT,
+ signingStatus: SigningStatus.NOT_SIGNED,
+ signedAt: new Date(),
+ Document: {
+ connect: {
+ id: document.id,
+ },
+ },
+ Field: {
+ create: {
+ page: 1,
+ type: FieldType.NAME,
+ inserted: true,
+ customText: String(recipient.name),
+ positionX: new Prisma.Decimal(1),
+ positionY: new Prisma.Decimal(1),
+ width: new Prisma.Decimal(1),
+ height: new Prisma.Decimal(1),
+ documentId: document.id,
+ },
+ },
+ },
+ });
+ }
+};
+
+const createReceivedDocument = async (sender: User, recipients: User[]) => {
+ const documentData = await prisma.documentData.create({
+ data: {
+ type: DocumentDataType.BYTES_64,
+ data: examplePdf,
+ initialData: examplePdf,
+ },
+ });
+
+ const document = await prisma.document.create({
+ data: {
+ title: `[${PULL_REQUEST_NUMBER}] Document - Received`,
+ status: DocumentStatus.PENDING,
+ documentDataId: documentData.id,
+ userId: sender.id,
+ },
+ });
+
+ for (const recipient of recipients) {
+ const index = recipients.indexOf(recipient);
+
+ await prisma.recipient.create({
+ data: {
+ email: String(recipient.email),
+ name: String(recipient.name),
+ token: `received-token-${index}`,
+ readStatus: ReadStatus.NOT_OPENED,
+ sendStatus: SendStatus.SENT,
+ signingStatus: SigningStatus.NOT_SIGNED,
+ signedAt: new Date(),
+ Document: {
+ connect: {
+ id: document.id,
+ },
+ },
+ Field: {
+ create: {
+ page: 1,
+ type: FieldType.NAME,
+ inserted: true,
+ customText: String(recipient.name),
+ positionX: new Prisma.Decimal(1),
+ positionY: new Prisma.Decimal(1),
+ width: new Prisma.Decimal(1),
+ height: new Prisma.Decimal(1),
+ documentId: document.id,
+ },
+ },
+ },
+ });
+ }
+};
diff --git a/packages/prisma/seed/pr-718-add-stepper-component.ts b/packages/prisma/seed/pr-718-add-stepper-component.ts
new file mode 100644
index 000000000..57a0ddc61
--- /dev/null
+++ b/packages/prisma/seed/pr-718-add-stepper-component.ts
@@ -0,0 +1,28 @@
+import { hashSync } from '@documenso/lib/server-only/auth/hash';
+
+import { prisma } from '..';
+
+//
+// https://github.com/documenso/documenso/pull/713
+//
+
+const PULL_REQUEST_NUMBER = 718;
+
+const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`;
+
+export const TEST_USER = {
+ name: 'User 1',
+ email: `user1@${EMAIL_DOMAIN}`,
+ password: 'Password123',
+} as const;
+
+export const seedDatabase = async () => {
+ await prisma.user.create({
+ data: {
+ name: TEST_USER.name,
+ email: TEST_USER.email,
+ password: hashSync(TEST_USER.password),
+ emailVerified: new Date(),
+ },
+ });
+};
diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts
index 9a3588259..fc6ea2377 100644
--- a/packages/trpc/server/document-router/router.ts
+++ b/packages/trpc/server/document-router/router.ts
@@ -3,11 +3,12 @@ import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
-import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document';
+import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
+import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateTitle } from '@documenso/lib/server-only/document/update-title';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
@@ -20,6 +21,7 @@ import {
ZGetDocumentByIdQuerySchema,
ZGetDocumentByTokenQuerySchema,
ZResendDocumentMutationSchema,
+ ZSearchDocumentsMutationSchema,
ZSendDocumentMutationSchema,
ZSetFieldsForDocumentMutationSchema,
ZSetRecipientsForDocumentMutationSchema,
@@ -97,15 +99,15 @@ export const documentRouter = router({
}
}),
- deleteDraftDocument: authenticatedProcedure
+ deleteDocument: authenticatedProcedure
.input(ZDeleteDraftDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
- const { id } = input;
+ const { id, status } = input;
const userId = ctx.user.id;
- return await deleteDraftDocument({ id, userId });
+ return await deleteDocument({ id, userId, status });
} catch (err) {
console.error(err);
@@ -240,4 +242,23 @@ export const documentRouter = router({
});
}
}),
+
+ searchDocuments: authenticatedProcedure
+ .input(ZSearchDocumentsMutationSchema)
+ .query(async ({ input, ctx }) => {
+ const { query } = input;
+
+ try {
+ const documents = await searchDocumentsWithKeyword({
+ query,
+ userId: ctx.user.id,
+ });
+ return documents;
+ } catch (error) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'We are unable to search for documents. Please try again later.',
+ });
+ }
+ }),
});
diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts
index 53288436a..71ee9766d 100644
--- a/packages/trpc/server/document-router/schema.ts
+++ b/packages/trpc/server/document-router/schema.ts
@@ -1,6 +1,6 @@
import { z } from 'zod';
-import { FieldType } from '@documenso/prisma/client';
+import { DocumentStatus, FieldType } from '@documenso/prisma/client';
export const ZGetDocumentByIdQuerySchema = z.object({
id: z.number().min(1),
@@ -80,6 +80,11 @@ export type TSendDocumentMutationSchema = z.infer;
+
+export const ZSearchDocumentsMutationSchema = z.object({
+ query: z.string(),
+});
diff --git a/packages/ui/components/document/document-download-button.tsx b/packages/ui/components/document/document-download-button.tsx
index e16d236f0..a2a35e490 100644
--- a/packages/ui/components/document/document-download-button.tsx
+++ b/packages/ui/components/document/document-download-button.tsx
@@ -7,8 +7,9 @@ import { Download } from 'lucide-react';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import type { DocumentData } from '@documenso/prisma/client';
-import { Button } from '@documenso/ui/primitives/button';
-import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { Button } from '../../primitives/button';
+import { useToast } from '../../primitives/use-toast';
export type DownloadButtonProps = HTMLAttributes & {
disabled?: boolean;
diff --git a/packages/ui/components/document/document-share-button.tsx b/packages/ui/components/document/document-share-button.tsx
index 5b6e9006a..b366123fb 100644
--- a/packages/ui/components/document/document-share-button.tsx
+++ b/packages/ui/components/document/document-share-button.tsx
@@ -13,8 +13,9 @@ import {
} from '@documenso/lib/constants/toast';
import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent';
import { trpc } from '@documenso/trpc/react';
-import { cn } from '@documenso/ui/lib/utils';
-import { Button } from '@documenso/ui/primitives/button';
+
+import { cn } from '../../lib/utils';
+import { Button } from '../../primitives/button';
import {
Dialog,
DialogContent,
@@ -22,8 +23,8 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
-} from '@documenso/ui/primitives/dialog';
-import { useToast } from '@documenso/ui/primitives/use-toast';
+} from '../../primitives/dialog';
+import { useToast } from '../../primitives/use-toast';
export type DocumentShareButtonProps = HTMLAttributes & {
token?: string;
diff --git a/packages/ui/components/field/field-tooltip.tsx b/packages/ui/components/field/field-tooltip.tsx
index 446b14d2d..3966e9c0c 100644
--- a/packages/ui/components/field/field-tooltip.tsx
+++ b/packages/ui/components/field/field-tooltip.tsx
@@ -1,17 +1,18 @@
import { TooltipArrow } from '@radix-ui/react-tooltip';
-import { VariantProps, cva } from 'class-variance-authority';
+import type { VariantProps } from 'class-variance-authority';
+import { cva } from 'class-variance-authority';
import { createPortal } from 'react-dom';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
-import { cn } from '@documenso/ui/lib/utils';
+
+import { cn } from '../..//lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
-} from '@documenso/ui/primitives/tooltip';
-
-import { Field } from '.prisma/client';
+} from '../..//primitives/tooltip';
+import type { Field } from '.prisma/client';
const tooltipVariants = cva('font-semibold', {
variants: {
diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx
index 054cc6376..e40b2e3d9 100644
--- a/packages/ui/components/field/field.tsx
+++ b/packages/ui/components/field/field.tsx
@@ -5,9 +5,10 @@ import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
-import { Field } from '@documenso/prisma/client';
-import { cn } from '@documenso/ui/lib/utils';
-import { Card, CardContent } from '@documenso/ui/primitives/card';
+import type { Field } from '@documenso/prisma/client';
+
+import { cn } from '../../lib/utils';
+import { Card, CardContent } from '../../primitives/card';
export type FieldRootContainerProps = {
field: Field;
diff --git a/packages/ui/components/signing-card.tsx b/packages/ui/components/signing-card.tsx
index ab057c4e5..cda0c31c3 100644
--- a/packages/ui/components/signing-card.tsx
+++ b/packages/ui/components/signing-card.tsx
@@ -2,14 +2,16 @@
import { useCallback, useEffect, useRef, useState } from 'react';
-import Image, { StaticImageData } from 'next/image';
+import type { StaticImageData } from 'next/image';
+import Image from 'next/image';
import { animate, motion, useMotionTemplate, useMotionValue, useTransform } from 'framer-motion';
import { P, match } from 'ts-pattern';
-import { Signature } from '@documenso/prisma/client';
-import { cn } from '@documenso/ui/lib/utils';
-import { Card, CardContent } from '@documenso/ui/primitives/card';
+import type { Signature } from '@documenso/prisma/client';
+
+import { cn } from '../lib/utils';
+import { Card, CardContent } from '../primitives/card';
export type SigningCardProps = {
className?: string;
diff --git a/packages/ui/primitives/combobox.tsx b/packages/ui/primitives/combobox.tsx
index 899ccd61d..85f86056d 100644
--- a/packages/ui/primitives/combobox.tsx
+++ b/packages/ui/primitives/combobox.tsx
@@ -3,16 +3,11 @@ import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Role } from '@documenso/prisma/client';
-import { cn } from '@documenso/ui/lib/utils';
-import { Button } from '@documenso/ui/primitives/button';
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
-} from '@documenso/ui/primitives/command';
-import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
+
+import { cn } from '../lib/utils';
+import { Button } from './button';
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from './command';
+import { Popover, PopoverContent, PopoverTrigger } from './popover';
type ComboboxProps = {
listValues: string[];
diff --git a/packages/ui/primitives/document-dropzone.tsx b/packages/ui/primitives/document-dropzone.tsx
index 6987e9872..d81a3a7de 100644
--- a/packages/ui/primitives/document-dropzone.tsx
+++ b/packages/ui/primitives/document-dropzone.tsx
@@ -1,12 +1,14 @@
'use client';
-import { Variants, motion } from 'framer-motion';
+import type { Variants } from 'framer-motion';
+import { motion } from 'framer-motion';
import { Plus } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
-import { cn } from '@documenso/ui/lib/utils';
-import { Card, CardContent } from '@documenso/ui/primitives/card';
+
+import { cn } from '../lib/utils';
+import { Card, CardContent } from './card';
const DocumentDropzoneContainerVariants: Variants = {
initial: {
diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx
index f662dca8b..a8ae9f0e3 100644
--- a/packages/ui/primitives/document-flow/add-fields.tsx
+++ b/packages/ui/primitives/document-flow/add-fields.tsx
@@ -11,29 +11,27 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { nanoid } from '@documenso/lib/universal/id';
-import { Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client';
-import { cn } from '@documenso/ui/lib/utils';
-import { Button } from '@documenso/ui/primitives/button';
-import { Card, CardContent } from '@documenso/ui/primitives/card';
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
-} from '@documenso/ui/primitives/command';
-import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
-import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
+import type { Field, Recipient } from '@documenso/prisma/client';
+import { FieldType, SendStatus } from '@documenso/prisma/client';
-import { TAddFieldsFormSchema } from './add-fields.types';
+import { cn } from '../../lib/utils';
+import { Button } from '../button';
+import { Card, CardContent } from '../card';
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../command';
+import { Popover, PopoverContent, PopoverTrigger } from '../popover';
+import { useStep } from '../stepper';
+import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
+import type { TAddFieldsFormSchema } from './add-fields.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
+ DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { FieldItem } from './field-item';
-import { DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types';
+import type { DocumentFlowStep } from './types';
+import { FRIENDLY_FIELD_TYPE } from './types';
const fontCaveat = Caveat({
weight: ['500'],
@@ -53,7 +51,6 @@ export type AddFieldsFormProps = {
hideRecipients?: boolean;
recipients: Recipient[];
fields: Field[];
- numberOfSteps: number;
onSubmit: (_data: TAddFieldsFormSchema) => void;
};
@@ -62,10 +59,10 @@ export const AddFieldsFormPartial = ({
hideRecipients = false,
recipients,
fields,
- numberOfSteps,
onSubmit,
}: AddFieldsFormProps) => {
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
+ const { currentStep, totalSteps, previousStep } = useStep();
const {
control,
@@ -287,6 +284,10 @@ export const AddFieldsFormPartial = ({
return (
<>
+
{selectedField && (
@@ -513,15 +514,15 @@ export const AddFieldsFormPartial = ({
{
- documentFlow.onBackStep?.();
+ previousStep();
remove();
}}
onGoNextClick={() => void onFormSubmit()}
diff --git a/packages/ui/primitives/document-flow/add-signature.tsx b/packages/ui/primitives/document-flow/add-signature.tsx
index aed252083..e4e5d9253 100644
--- a/packages/ui/primitives/document-flow/add-signature.tsx
+++ b/packages/ui/primitives/document-flow/add-signature.tsx
@@ -9,35 +9,38 @@ import { match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
-import { Field, FieldType } from '@documenso/prisma/client';
-import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
-import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
-import { cn } from '@documenso/ui/lib/utils';
-import { Card, CardContent } from '@documenso/ui/primitives/card';
-import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
+import type { Field } from '@documenso/prisma/client';
+import { FieldType } from '@documenso/prisma/client';
+import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
+
+import { FieldToolTip } from '../../components/field/field-tooltip';
+import { cn } from '../../lib/utils';
+import { Card, CardContent } from '../card';
+import { ElementVisible } from '../element-visible';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
+import { Input } from '../input';
+import { SignaturePad } from '../signature-pad';
+import { useStep } from '../stepper';
+import type { TAddSignatureFormSchema } from './add-signature.types';
+import { ZAddSignatureFormSchema } from './add-signature.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
+ DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
-} from '@documenso/ui/primitives/document-flow/document-flow-root';
-import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
-import { ElementVisible } from '@documenso/ui/primitives/element-visible';
-import { Input } from '@documenso/ui/primitives/input';
-import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
-
-import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
-import { ZAddSignatureFormSchema } from './add-signature.types';
+} from './document-flow-root';
import {
SinglePlayerModeCustomTextField,
SinglePlayerModeSignatureField,
} from './single-player-mode-fields';
+import type { DocumentFlowStep } from './types';
export type AddSignatureFormProps = {
defaultValues?: TAddSignatureFormSchema;
documentFlow: DocumentFlowStep;
fields: FieldWithSignature[];
- numberOfSteps: number;
+
onSubmit: (_data: TAddSignatureFormSchema) => Promise | void;
requireName?: boolean;
requireSignature?: boolean;
@@ -47,11 +50,13 @@ export const AddSignatureFormPartial = ({
defaultValues,
documentFlow,
fields,
- numberOfSteps,
+
onSubmit,
requireName = false,
requireSignature = true,
}: AddSignatureFormProps) => {
+ const { currentStep, totalSteps } = useStep();
+
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
// Refined schema which takes into account whether to allow an empty name or signature.
@@ -206,46 +211,30 @@ export const AddSignatureFormPartial = ({
};
return (
-
-
+
+
- {validateUninsertedFields && uninsertedFields[0] && (
-
- Click to insert field
-
- )}
+
+
+
-
- {localFields.map((field) =>
- match(field.type)
- .with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, () => {
- return (
-
+ Click to insert field
+
+ )}
+
+
+ {localFields.map((field) =>
+ match(field.type)
+ .with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, () => {
+ return (
+
+ );
+ })
+ .with(FieldType.SIGNATURE, () => (
+
- );
- })
- .with(FieldType.SIGNATURE, () => (
-
- ))
- .otherwise(() => {
- return null;
- }),
- )}
-
-
+ ))
+ .otherwise(() => {
+ return null;
+ }),
+ )}
+
+
+ >
);
};
diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx
index b623b0d4e..71be1c069 100644
--- a/packages/ui/primitives/document-flow/add-signers.tsx
+++ b/packages/ui/primitives/document-flow/add-signers.tsx
@@ -9,35 +9,37 @@ import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { nanoid } from '@documenso/lib/universal/id';
-import { DocumentStatus, Field, Recipient, SendStatus } from '@documenso/prisma/client';
-import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
-import { Button } from '@documenso/ui/primitives/button';
-import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
-import { Input } from '@documenso/ui/primitives/input';
-import { Label } from '@documenso/ui/primitives/label';
-import { useToast } from '@documenso/ui/primitives/use-toast';
+import type { Field, Recipient } from '@documenso/prisma/client';
+import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
+import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
-import { TAddSignersFormSchema, ZAddSignersFormSchema } from './add-signers.types';
+import { Button } from '../button';
+import { FormErrorMessage } from '../form/form-error-message';
+import { Input } from '../input';
+import { Label } from '../label';
+import { useStep } from '../stepper';
+import { useToast } from '../use-toast';
+import type { TAddSignersFormSchema } from './add-signers.types';
+import { ZAddSignersFormSchema } from './add-signers.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
+ DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
-import { DocumentFlowStep } from './types';
+import type { DocumentFlowStep } from './types';
export type AddSignersFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
document: DocumentWithData;
- numberOfSteps: number;
onSubmit: (_data: TAddSignersFormSchema) => void;
};
export const AddSignersFormPartial = ({
documentFlow,
- numberOfSteps,
recipients,
document,
fields: _fields,
@@ -48,6 +50,8 @@ export const AddSignersFormPartial = ({
const initialId = useId();
+ const { currentStep, totalSteps, previousStep } = useStep();
+
const {
control,
handleSubmit,
@@ -126,6 +130,10 @@ export const AddSignersFormPartial = ({
return (
<>
+
@@ -221,15 +229,15 @@ export const AddSignersFormPartial = ({
void onFormSubmit()}
/>
diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx
index 1bf3b2cb4..881d59c74 100644
--- a/packages/ui/primitives/document-flow/add-subject.tsx
+++ b/packages/ui/primitives/document-flow/add-subject.tsx
@@ -2,28 +2,30 @@
import { useForm } from 'react-hook-form';
-import { DocumentStatus, Field, Recipient } from '@documenso/prisma/client';
-import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
-import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
-import { Input } from '@documenso/ui/primitives/input';
-import { Label } from '@documenso/ui/primitives/label';
-import { Textarea } from '@documenso/ui/primitives/textarea';
+import type { Field, Recipient } from '@documenso/prisma/client';
+import { DocumentStatus } from '@documenso/prisma/client';
+import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
-import { TAddSubjectFormSchema } from './add-subject.types';
+import { FormErrorMessage } from '../form/form-error-message';
+import { Input } from '../input';
+import { Label } from '../label';
+import { useStep } from '../stepper';
+import { Textarea } from '../textarea';
+import type { TAddSubjectFormSchema } from './add-subject.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
+ DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
-import { DocumentFlowStep } from './types';
+import type { DocumentFlowStep } from './types';
export type AddSubjectFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
document: DocumentWithData;
- numberOfSteps: number;
onSubmit: (_data: TAddSubjectFormSchema) => void;
};
@@ -32,7 +34,6 @@ export const AddSubjectFormPartial = ({
recipients: _recipients,
fields: _fields,
document,
- numberOfSteps,
onSubmit,
}: AddSubjectFormProps) => {
const {
@@ -49,9 +50,14 @@ export const AddSubjectFormPartial = ({
});
const onFormSubmit = handleSubmit(onSubmit);
+ const { currentStep, totalSteps, previousStep } = useStep();
return (
<>
+
@@ -124,15 +130,15 @@ export const AddSubjectFormPartial = ({
void onFormSubmit()}
/>
diff --git a/packages/ui/primitives/document-flow/add-title.tsx b/packages/ui/primitives/document-flow/add-title.tsx
index 3ec44b17d..8c2a9dc7a 100644
--- a/packages/ui/primitives/document-flow/add-title.tsx
+++ b/packages/ui/primitives/document-flow/add-title.tsx
@@ -4,15 +4,17 @@ import { useForm } from 'react-hook-form';
import type { Field, Recipient } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
-import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
-import { Input } from '@documenso/ui/primitives/input';
-import { Label } from '@documenso/ui/primitives/label';
+import { FormErrorMessage } from '../form/form-error-message';
+import { Input } from '../input';
+import { Label } from '../label';
+import { useStep } from '../stepper';
import type { TAddTitleFormSchema } from './add-title.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
+ DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import type { DocumentFlowStep } from './types';
@@ -22,7 +24,6 @@ export type AddTitleFormProps = {
recipients: Recipient[];
fields: Field[];
document: DocumentWithData;
- numberOfSteps: number;
onSubmit: (_data: TAddTitleFormSchema) => void;
};
@@ -31,7 +32,6 @@ export const AddTitleFormPartial = ({
recipients: _recipients,
fields: _fields,
document,
- numberOfSteps,
onSubmit,
}: AddTitleFormProps) => {
const {
@@ -46,8 +46,14 @@ export const AddTitleFormPartial = ({
const onFormSubmit = handleSubmit(onSubmit);
+ const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
+
return (
<>
+
@@ -72,14 +78,15 @@ export const AddTitleFormPartial = ({
void onFormSubmit()}
/>
diff --git a/packages/ui/primitives/document-flow/document-flow-root.tsx b/packages/ui/primitives/document-flow/document-flow-root.tsx
index aec74dd6c..42b70c58a 100644
--- a/packages/ui/primitives/document-flow/document-flow-root.tsx
+++ b/packages/ui/primitives/document-flow/document-flow-root.tsx
@@ -1,11 +1,12 @@
'use client';
-import React, { HTMLAttributes } from 'react';
+import type { HTMLAttributes } from 'react';
+import React from 'react';
import { motion } from 'framer-motion';
-import { cn } from '@documenso/ui/lib/utils';
-import { Button } from '@documenso/ui/primitives/button';
+import { cn } from '../../lib/utils';
+import { Button } from '../button';
export type DocumentFlowFormContainerProps = HTMLAttributes & {
children?: React.ReactNode;
diff --git a/packages/ui/primitives/document-flow/field-item.tsx b/packages/ui/primitives/document-flow/field-item.tsx
index 48e52b9a7..7583bd4b9 100644
--- a/packages/ui/primitives/document-flow/field-item.tsx
+++ b/packages/ui/primitives/document-flow/field-item.tsx
@@ -7,10 +7,11 @@ import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
-import { cn } from '@documenso/ui/lib/utils';
-import { Card, CardContent } from '@documenso/ui/primitives/card';
-import { FRIENDLY_FIELD_TYPE, TDocumentFlowFormSchema } from './types';
+import { cn } from '../../lib/utils';
+import { Card, CardContent } from '../card';
+import type { TDocumentFlowFormSchema } from './types';
+import { FRIENDLY_FIELD_TYPE } from './types';
type Field = TDocumentFlowFormSchema['fields'][0];
diff --git a/packages/ui/primitives/document-flow/send-document-action-dialog.tsx b/packages/ui/primitives/document-flow/send-document-action-dialog.tsx
index f295dadfc..a70282800 100644
--- a/packages/ui/primitives/document-flow/send-document-action-dialog.tsx
+++ b/packages/ui/primitives/document-flow/send-document-action-dialog.tsx
@@ -2,7 +2,8 @@ import { useState } from 'react';
import { Loader } from 'lucide-react';
-import { Button, ButtonProps } from '@documenso/ui/primitives/button';
+import type { ButtonProps } from '../button';
+import { Button } from '../button';
import {
Dialog,
DialogContent,
@@ -11,7 +12,7 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
-} from '@documenso/ui/primitives/dialog';
+} from '../dialog';
export type SendDocumentActionDialogProps = ButtonProps & {
loading?: boolean;
diff --git a/packages/ui/primitives/document-flow/single-player-mode-fields.tsx b/packages/ui/primitives/document-flow/single-player-mode-fields.tsx
index 04c093efc..7cecd7131 100644
--- a/packages/ui/primitives/document-flow/single-player-mode-fields.tsx
+++ b/packages/ui/primitives/document-flow/single-player-mode-fields.tsx
@@ -13,9 +13,11 @@ import {
MIN_HANDWRITING_FONT_SIZE,
MIN_STANDARD_FONT_SIZE,
} from '@documenso/lib/constants/pdf';
-import { Field, FieldType } from '@documenso/prisma/client';
-import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
-import { FieldRootContainer } from '@documenso/ui/components/field/field';
+import type { Field } from '@documenso/prisma/client';
+import { FieldType } from '@documenso/prisma/client';
+import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
+
+import { FieldRootContainer } from '../../components/field/field';
export type SinglePlayerModeFieldContainerProps = {
field: FieldWithSignature;
diff --git a/packages/ui/primitives/document-flow/types.ts b/packages/ui/primitives/document-flow/types.ts
index c9244ad05..677dc931b 100644
--- a/packages/ui/primitives/document-flow/types.ts
+++ b/packages/ui/primitives/document-flow/types.ts
@@ -53,7 +53,7 @@ export const FRIENDLY_FIELD_TYPE: Record = {
export interface DocumentFlowStep {
title: string;
description: string;
- stepIndex: number;
+ stepIndex?: number;
onBackStep?: () => unknown;
onNextStep?: () => unknown;
}
diff --git a/packages/ui/primitives/form/form-error-message.tsx b/packages/ui/primitives/form/form-error-message.tsx
index bb555b7f7..e429799da 100644
--- a/packages/ui/primitives/form/form-error-message.tsx
+++ b/packages/ui/primitives/form/form-error-message.tsx
@@ -1,6 +1,6 @@
import { AnimatePresence, motion } from 'framer-motion';
-import { cn } from '@documenso/ui/lib/utils';
+import { cn } from '../../lib/utils';
export type FormErrorMessageProps = {
className?: string;
diff --git a/packages/ui/primitives/form/form.tsx b/packages/ui/primitives/form/form.tsx
index 9467de3af..f500accae 100644
--- a/packages/ui/primitives/form/form.tsx
+++ b/packages/ui/primitives/form/form.tsx
@@ -1,19 +1,12 @@
import * as React from 'react';
-import * as LabelPrimitive from '@radix-ui/react-label';
+import type * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import { AnimatePresence, motion } from 'framer-motion';
-import {
- Controller,
- ControllerProps,
- FieldPath,
- FieldValues,
- FormProvider,
- useFormContext,
-} from 'react-hook-form';
-
-import { cn } from '@documenso/ui/lib/utils';
+import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
+import { Controller, FormProvider, useFormContext } from 'react-hook-form';
+import { cn } from '../../lib/utils';
import { Label } from '../label';
const Form = FormProvider;
diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx
index 62b08d2f9..07cdaf1e2 100644
--- a/packages/ui/primitives/pdf-viewer.tsx
+++ b/packages/ui/primitives/pdf-viewer.tsx
@@ -3,16 +3,16 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Loader } from 'lucide-react';
-import { PDFDocumentProxy } from 'pdfjs-dist';
+import type { PDFDocumentProxy } from 'pdfjs-dist';
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getFile } from '@documenso/lib/universal/upload/get-file';
-import { DocumentData } from '@documenso/prisma/client';
-import { cn } from '@documenso/ui/lib/utils';
+import type { DocumentData } from '@documenso/prisma/client';
+import { cn } from '../lib/utils';
import { useToast } from './use-toast';
export type LoadedPDFDocument = PDFDocumentProxy;
diff --git a/packages/ui/primitives/signature-pad/signature-pad.tsx b/packages/ui/primitives/signature-pad/signature-pad.tsx
index 107627240..3497418d7 100644
--- a/packages/ui/primitives/signature-pad/signature-pad.tsx
+++ b/packages/ui/primitives/signature-pad/signature-pad.tsx
@@ -1,20 +1,12 @@
'use client';
-import {
- HTMLAttributes,
- MouseEvent,
- PointerEvent,
- TouchEvent,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
+import type { HTMLAttributes, MouseEvent, PointerEvent, TouchEvent } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
-import { StrokeOptions, getStroke } from 'perfect-freehand';
-
-import { cn } from '@documenso/ui/lib/utils';
+import type { StrokeOptions } from 'perfect-freehand';
+import { getStroke } from 'perfect-freehand';
+import { cn } from '../../lib/utils';
import { getSvgPathFromStroke } from './helper';
import { Point } from './point';
diff --git a/packages/ui/primitives/stepper.tsx b/packages/ui/primitives/stepper.tsx
new file mode 100644
index 000000000..d38de2eb1
--- /dev/null
+++ b/packages/ui/primitives/stepper.tsx
@@ -0,0 +1,109 @@
+import React, { createContext, useContext, useState } from 'react';
+import type { FC } from 'react';
+
+type StepContextValue = {
+ isCompleting: boolean;
+ stepIndex: number;
+ currentStep: number;
+ totalSteps: number;
+ isFirst: boolean;
+ isLast: boolean;
+ nextStep: () => void;
+ previousStep: () => void;
+};
+
+const StepContext = createContext(null);
+
+type StepperProps = {
+ children: React.ReactNode;
+ onComplete?: () => void | Promise;
+ onStepChanged?: (currentStep: number) => void;
+ currentStep?: number; // external control prop
+ setCurrentStep?: (step: number) => void; // external control function
+};
+
+export const Stepper: FC = ({
+ children,
+ onComplete,
+ onStepChanged,
+ currentStep: propCurrentStep,
+ setCurrentStep: propSetCurrentStep,
+}) => {
+ const [stateCurrentStep, stateSetCurrentStep] = useState(1);
+ const [isCompleting, setIsCompleting] = useState(false);
+
+ // Determine if props are provided, otherwise use state
+ const isControlled = propCurrentStep !== undefined && propSetCurrentStep !== undefined;
+ const currentStep = isControlled ? propCurrentStep : stateCurrentStep;
+ const setCurrentStep = isControlled ? propSetCurrentStep : stateSetCurrentStep;
+
+ const totalSteps = React.Children.count(children);
+
+ const handleComplete = async () => {
+ try {
+ if (!onComplete) {
+ return;
+ }
+
+ setIsCompleting(true);
+
+ await onComplete();
+
+ setIsCompleting(false);
+ } catch (error) {
+ setIsCompleting(false);
+
+ throw error;
+ }
+ };
+
+ const handleStepChange = (nextStep: number) => {
+ setCurrentStep(nextStep);
+ onStepChanged?.(nextStep);
+ };
+
+ const nextStep = () => {
+ if (currentStep < totalSteps) {
+ void handleStepChange(currentStep + 1);
+ } else {
+ void handleComplete();
+ }
+ };
+
+ const previousStep = () => {
+ if (currentStep > 1) {
+ void handleStepChange(currentStep - 1);
+ }
+ };
+
+ // Empty stepper
+ if (totalSteps === 0) {
+ return null;
+ }
+
+ const currentChild = React.Children.toArray(children)[currentStep - 1];
+
+ const stepContextValue: StepContextValue = {
+ isCompleting,
+ stepIndex: currentStep - 1,
+ currentStep,
+ totalSteps,
+ isFirst: currentStep === 1,
+ isLast: currentStep === totalSteps,
+ nextStep,
+ previousStep,
+ };
+
+ return {currentChild} ;
+};
+
+/** Hook for children to use the step context */
+export const useStep = (): StepContextValue => {
+ const context = useContext(StepContext);
+
+ if (!context) {
+ throw new Error('useStep must be used within a Stepper');
+ }
+
+ return context;
+};