mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82da337a56 | |||
| 1eeb5fb103 | |||
| 31a9127c9e | |||
| 6d34ebd91b | |||
| e4b7747f66 | |||
| 0697e7f817 | |||
| e1d3874e79 | |||
| 497d9140d2 | |||
| 7d22957404 | |||
| 38e5b1d3ce | |||
| 09dcc2cac0 | |||
| d8d36ae8e2 | |||
| dfec8df31e | |||
| 5e9bc56329 | |||
| 48f6765e76 | |||
| 7feba02e08 | |||
| 4e799e68ef | |||
| 1831084970 | |||
| 935601ad16 | |||
| 7b4e38a032 | |||
| 2c5d547cdf | |||
| c313da5028 | |||
| 5b98bac53b | |||
| d7e44fc068 | |||
| e03db5c6b3 | |||
| d58433c8a0 | |||
| 419f27536b | |||
| 9a7e5d333d | |||
| 39b97a97fe | |||
| 328b2e7604 | |||
| 43b1a89c76 | |||
| cd6184406d | |||
| 1a34f9fa7a | |||
| 3ff7b188d7 | |||
| 684e5272d2 | |||
| b39a42ecd2 | |||
| e81183f324 | |||
| bfc630aa6a | |||
| 2068d980ff | |||
| 0baa2696b4 | |||
| 741201822a | |||
| 520522bef7 | |||
| 8ab1b0cf6b | |||
| bfedabdc10 | |||
| 52e696c90e | |||
| 02d91d9cd4 | |||
| ac529a89fc | |||
| f181099e74 | |||
| 02e96bbd0a | |||
| 36e48e67ee | |||
| d8588b780a | |||
| ef84f5ba98 | |||
| 68120794f8 | |||
| 88dc797423 | |||
| a43be0432b | |||
| 0f11cc0b4b | |||
| f310139a13 | |||
| 859b789018 | |||
| 340c929806 | |||
| 43b1a14415 | |||
| 40a4ec4436 | |||
| eccf63dcfd | |||
| a98b429052 | |||
| c46a69f865 | |||
| 0e40658201 | |||
| d347359d2f | |||
| fdf5b3908d | |||
| b4f1a5abce |
10
.env.example
10
.env.example
@ -29,15 +29,15 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
|
||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
||||
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
|
||||
NEXT_PRIVATE_UPLOAD_ENDPOINT=
|
||||
NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
|
||||
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
|
||||
NEXT_PRIVATE_UPLOAD_REGION=
|
||||
NEXT_PRIVATE_UPLOAD_REGION="unknown"
|
||||
# REQUIRED: Defines the bucket to use for the S3 storage transport.
|
||||
NEXT_PRIVATE_UPLOAD_BUCKET=
|
||||
NEXT_PRIVATE_UPLOAD_BUCKET="documenso"
|
||||
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
|
||||
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=
|
||||
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID="documenso"
|
||||
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
|
||||
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
|
||||
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password"
|
||||
|
||||
# [[SMTP]]
|
||||
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
||||
|
||||
7
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
7
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@ -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:
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
6
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@ -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.
|
||||
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/improvement.yml
vendored
4
.github/ISSUE_TEMPLATE/improvement.yml
vendored
@ -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.
|
||||
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
|
||||
|
||||
32
.github/dependabot.yml
vendored
32
.github/dependabot.yml
vendored
@ -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
|
||||
|
||||
21
.github/labeler.yml
vendored
Normal file
21
.github/labeler.yml
vendored
Normal file
@ -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/**
|
||||
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
|
||||
44
.github/workflows/codeql-analysis.yml
vendored
44
.github/workflows/codeql-analysis.yml
vendored
@ -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
|
||||
|
||||
24
.github/workflows/deploy.yml
vendored
Normal file
24
.github/workflows/deploy.yml
vendored
Normal file
@ -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
|
||||
41
.github/workflows/e2e-tests.yml
vendored
41
.github/workflows/e2e-tests.yml
vendored
@ -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 }}
|
||||
|
||||
29
.github/workflows/first-interaction.yml
vendored
Normal file
29
.github/workflows/first-interaction.yml
vendored
Normal file
@ -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! 💚🚀
|
||||
<br /> 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!
|
||||
<br /> One of our team members will review it and get back to you as soon as it possible 💚
|
||||
<br /> Meanwhile, please feel free to hop into our community in [Discord](https://documen.so/discord)
|
||||
59
.github/workflows/issue-assignee-check.yml
vendored
Normal file
59
.github/workflows/issue-assignee-check.yml
vendored
Normal file
@ -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 }}`,
|
||||
}
|
||||
});
|
||||
}
|
||||
21
.github/workflows/issue-opened.yml
vendored
Normal file
21
.github/workflows/issue-opened.yml
vendored
Normal file
@ -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"]
|
||||
})
|
||||
20
.github/workflows/pr-labeler.yml
vendored
Normal file
20
.github/workflows/pr-labeler.yml
vendored
Normal file
@ -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: ''
|
||||
60
.github/workflows/pr-review-reminder.yml
vendored
Normal file
60
.github/workflows/pr-review-reminder.yml
vendored
Normal file
@ -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 }}`,
|
||||
}
|
||||
});
|
||||
}
|
||||
27
.github/workflows/semantic-pull-requests.yml
vendored
27
.github/workflows/semantic-pull-requests.yml
vendored
@ -1,4 +1,4 @@
|
||||
name: "Validate PR Name"
|
||||
name: 'Validate PR Name'
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
@ -9,7 +9,7 @@ on:
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
validate-pr:
|
||||
@ -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! 💚🚀
|
||||
|
||||
25
.github/workflows/stale.yml
vendored
Normal file
25
.github/workflows/stale.yml
vendored
Normal file
@ -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,assigned'
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@ -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
|
||||
}
|
||||
|
||||
@ -139,11 +139,13 @@ npm run d
|
||||
|
||||
1. **App** - http://localhost:3000
|
||||
2. **Incoming Mail Access** - http://localhost:9000
|
||||
|
||||
3. **Database Connection Details**
|
||||
|
||||
- **Port**: 54320
|
||||
- **Connection**: Use your favorite database client to connect using the provided port.
|
||||
|
||||
4. **S3 Storage Dashboard** - http://localhost:9001
|
||||
|
||||
## Developer Setup
|
||||
|
||||
### Manual Setup
|
||||
|
||||
13
apps/marketing/content/careers.mdx
Normal file
13
apps/marketing/content/careers.mdx
Normal file
@ -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.
|
||||
@ -29,7 +29,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative max-w-[100vw] pt-20 md:pt-28', {
|
||||
className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', {
|
||||
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
|
||||
})}
|
||||
>
|
||||
@ -41,7 +41,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-screen-xl px-4 lg:px-8">{children}</div>
|
||||
<div className="relative mx-auto max-w-screen-xl flex-1 px-4 lg:px-8">{children}</div>
|
||||
|
||||
<Footer className="bg-background border-muted mt-24 border-t" />
|
||||
</div>
|
||||
|
||||
@ -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
|
||||
@ -87,6 +86,7 @@ export const SinglePlayerClient = () => {
|
||||
data.fields.map((field, i) => ({
|
||||
id: i,
|
||||
documentId: -1,
|
||||
templateId: null,
|
||||
recipientId: -1,
|
||||
type: field.type,
|
||||
page: field.pageNumber,
|
||||
@ -149,6 +149,7 @@ export const SinglePlayerClient = () => {
|
||||
const placeholderRecipient: Recipient = {
|
||||
id: -1,
|
||||
documentId: -1,
|
||||
templateId: null,
|
||||
email: '',
|
||||
name: '',
|
||||
token: '',
|
||||
@ -226,37 +227,35 @@ export const SinglePlayerClient = () => {
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={currentDocumentFlow.title}
|
||||
description={currentDocumentFlow.description}
|
||||
/>
|
||||
|
||||
{/* Add fields to PDF page. */}
|
||||
{step === 'fields' && (
|
||||
<DocumentFlowFormContainer
|
||||
className="top-24 lg:h-[calc(100vh-7rem)]"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<Stepper
|
||||
currentStep={currentDocumentFlow.stepIndex}
|
||||
setCurrentStep={(step) => setStep(SinglePlayerModeSteps[step - 1])}
|
||||
>
|
||||
{/* Add fields to PDF page. */}
|
||||
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
|
||||
<AddFieldsFormPartial
|
||||
documentFlow={documentFlow.fields}
|
||||
hideRecipients={true}
|
||||
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
fields={fields}
|
||||
onSubmit={onFieldsSubmit}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* Enter user details and signature. */}
|
||||
{step === 'sign' && (
|
||||
{/* Enter user details and signature. */}
|
||||
|
||||
<AddSignatureFormPartial
|
||||
documentFlow={documentFlow.sign}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
fields={fields}
|
||||
onSubmit={onSignSubmit}
|
||||
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
||||
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
||||
/>
|
||||
)}
|
||||
</Stepper>
|
||||
</DocumentFlowFormContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@ -6,8 +6,9 @@ import Link from 'next/link';
|
||||
|
||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { DocumentStatus, Signature } from '@documenso/prisma/client';
|
||||
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
import type { Signature } from '@documenso/prisma/client';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
|
||||
@ -4,6 +4,11 @@ import { appRouter } from '@documenso/trpc/server/router';
|
||||
|
||||
export const config = {
|
||||
maxDuration: 60,
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: '50mb',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default trpcNext.createNextApiHandler({
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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<EditDocumentStep>(
|
||||
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 (
|
||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||
<Card
|
||||
@ -197,56 +192,43 @@ export const EditDocumentForm = ({
|
||||
className="lg:h-[calc(100vh-6rem)]"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={currentDocumentFlow.title}
|
||||
description={currentDocumentFlow.description}
|
||||
/>
|
||||
|
||||
{step === 'title' && (
|
||||
<Stepper
|
||||
currentStep={currentDocumentFlow.stepIndex}
|
||||
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
|
||||
>
|
||||
<AddTitleFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.title}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
document={document}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
onSubmit={onAddTitleFormSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'signers' && (
|
||||
<AddSignersFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.signers}
|
||||
document={document}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
onSubmit={onAddSignersFormSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'fields' && (
|
||||
<AddFieldsFormPartial
|
||||
key={fields.length}
|
||||
documentFlow={documentFlow.fields}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'subject' && (
|
||||
<AddSubjectFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.subject}
|
||||
document={document}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
onSubmit={onAddSubjectFormSubmit}
|
||||
/>
|
||||
)}
|
||||
</Stepper>
|
||||
</DocumentFlowFormContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
@ -99,6 +99,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
};
|
||||
|
||||
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
@ -127,7 +128,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<DropdownMenuItem disabled>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
@ -161,8 +162,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
</DropdownMenuContent>
|
||||
|
||||
{isDocumentDeletable && (
|
||||
<DeleteDraftDocumentDialog
|
||||
<DeleteDocumentDialog
|
||||
id={row.id}
|
||||
status={row.status}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
/>
|
||||
|
||||
@ -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 }) => (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DataTableActionButton row={row.original} />
|
||||
<DataTableActionDropdown row={row.original} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
(!row.original.deletedAt ||
|
||||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DataTableActionButton row={row.original} />
|
||||
<DataTableActionDropdown row={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={results.data}
|
||||
|
||||
@ -0,0 +1,120 @@
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
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 DeleteDocumentDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
status,
|
||||
}: DeleteDraftDocumentDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||
|
||||
const { mutateAsync: deleteDocument, isLoading } = trpcReact.document.deleteDocument.useMutation({
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
|
||||
toast({
|
||||
title: 'Document deleted',
|
||||
description: 'Your document has been successfully deleted.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDelete = async () => {
|
||||
try {
|
||||
await deleteDocument({ id, status });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'This document could not be deleted at this time. Please try again.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
setIsDeleteEnabled(event.target.value === 'delete');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Do you want to delete this document?</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Please note that this action is irreversible. Once confirmed, your document will be
|
||||
permanently deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{status !== DocumentStatus.DRAFT && (
|
||||
<div className="mt-8">
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
placeholder="Type 'delete' to confirm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isLoading}
|
||||
onClick={onDelete}
|
||||
disabled={!isDeleteEnabled}
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
155
apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx
Normal file
155
apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
DocumentFlowFormContainer,
|
||||
DocumentFlowFormContainerHeader,
|
||||
} 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 { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
|
||||
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
||||
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
|
||||
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { addTemplateFields } from '~/components/forms/edit-template/add-template-fields.action';
|
||||
import { addTemplatePlaceholders } from '~/components/forms/edit-template/add-template-placeholders.action';
|
||||
|
||||
export type EditTemplateFormProps = {
|
||||
className?: string;
|
||||
user: User;
|
||||
template: Template;
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
documentData: DocumentData;
|
||||
};
|
||||
|
||||
type EditTemplateStep = 'signers' | 'fields';
|
||||
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
|
||||
|
||||
export const EditTemplateForm = ({
|
||||
className,
|
||||
template,
|
||||
recipients,
|
||||
fields,
|
||||
user: _user,
|
||||
documentData,
|
||||
}: EditTemplateFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState<EditTemplateStep>('signers');
|
||||
|
||||
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
|
||||
signers: {
|
||||
title: 'Add Placeholders',
|
||||
description: 'Add all relevant placeholders for each recipient.',
|
||||
stepIndex: 1,
|
||||
},
|
||||
fields: {
|
||||
title: 'Add Fields',
|
||||
description: 'Add all relevant fields for each recipient.',
|
||||
stepIndex: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const currentDocumentFlow = documentFlow[step];
|
||||
|
||||
const onAddTemplatePlaceholderFormSubmit = async (
|
||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||
) => {
|
||||
try {
|
||||
await addTemplatePlaceholders({
|
||||
templateId: template.id,
|
||||
signers: data.signers,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
setStep('fields');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while adding signers.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
|
||||
try {
|
||||
await addTemplateFields({
|
||||
templateId: template.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Template saved',
|
||||
description: 'Your templates has been saved successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push('/templates');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while adding signers.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<DocumentFlowFormContainer
|
||||
className="lg:h-[calc(100vh-6rem)]"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={currentDocumentFlow.title}
|
||||
description={currentDocumentFlow.description}
|
||||
/>
|
||||
|
||||
<Stepper
|
||||
currentStep={currentDocumentFlow.stepIndex}
|
||||
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
|
||||
>
|
||||
<AddTemplatePlaceholderRecipientsFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.signers}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||
/>
|
||||
|
||||
<AddTemplateFieldsFormPartial
|
||||
key={fields.length}
|
||||
documentFlow={documentFlow.fields}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
/>
|
||||
</Stepper>
|
||||
</DocumentFlowFormContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
81
apps/web/src/app/(dashboard)/templates/[id]/page.tsx
Normal file
81
apps/web/src/app/(dashboard)/templates/[id]/page.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
||||
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { EditTemplateForm } from './edit-template';
|
||||
|
||||
export type TemplatePageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
const { id } = params;
|
||||
|
||||
const templateId = Number(id);
|
||||
|
||||
if (!templateId || Number.isNaN(templateId)) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
userId: user.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template || !template.templateDocumentData) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
const { templateDocumentData } = template;
|
||||
|
||||
const [templateRecipients, templateFields] = await Promise.all([
|
||||
await getRecipientsForTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
}),
|
||||
await getFieldsForTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Templates
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<EditTemplateForm
|
||||
className="mt-8"
|
||||
template={template}
|
||||
user={user}
|
||||
recipients={templateRecipients}
|
||||
fields={templateFields}
|
||||
documentData={templateDocumentData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import type { Template } from '@documenso/prisma/client';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
import { DeleteTemplateDialog } from './delete-template-dialog';
|
||||
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
row: Template;
|
||||
};
|
||||
|
||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isOwner = row.userId === session.user.id;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner} asChild>
|
||||
<Link href={`/templates/${row.id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* <DropdownMenuItem disabled={!isOwner} onClick={async () => onDuplicateButtonClick(row.id)}> */}
|
||||
<DropdownMenuItem disabled={!isOwner} onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner} onClick={() => setDeleteDialogOpen(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<DuplicateTemplateDialog
|
||||
id={row.id}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
/>
|
||||
|
||||
<DeleteTemplateDialog
|
||||
id={row.id}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
136
apps/web/src/app/(dashboard)/templates/data-table-templates.tsx
Normal file
136
apps/web/src/app/(dashboard)/templates/data-table-templates.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader, Plus } from 'lucide-react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import type { Template } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||
import { DataTableTitle } from './data-table-title';
|
||||
|
||||
type TemplatesDataTableProps = {
|
||||
templates: Template[];
|
||||
perPage: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
export const TemplatesDataTable = ({
|
||||
templates,
|
||||
perPage,
|
||||
page,
|
||||
totalPages,
|
||||
}: TemplatesDataTableProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({});
|
||||
|
||||
const { mutateAsync: createDocumentFromTemplate } =
|
||||
trpc.template.createDocumentFromTemplate.useMutation();
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onUseButtonClick = async (templateId: number) => {
|
||||
try {
|
||||
const { id } = await createDocumentFromTemplate({
|
||||
templateId,
|
||||
});
|
||||
toast({
|
||||
title: 'Document created',
|
||||
description: 'Your document has been created from the template successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
router.push(`/documents/${id}`);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while creating document from template.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
header: 'Created',
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||
},
|
||||
{
|
||||
header: 'Title',
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||
},
|
||||
{
|
||||
header: 'Type',
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => <TemplateType type={row.original.type} />,
|
||||
},
|
||||
{
|
||||
header: 'Actions',
|
||||
accessorKey: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const isRowLoading = loadingStates[row.original.id];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Button
|
||||
disabled={isRowLoading}
|
||||
loading={isRowLoading}
|
||||
onClick={async () => {
|
||||
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
|
||||
await onUseButtonClick(row.original.id);
|
||||
setLoadingStates((prev) => ({ ...prev, [row.original.id]: false }));
|
||||
}}
|
||||
>
|
||||
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
|
||||
Use Template
|
||||
</Button>
|
||||
<DataTableActionDropdown row={row.original} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={templates}
|
||||
perPage={perPage}
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
26
apps/web/src/app/(dashboard)/templates/data-table-title.tsx
Normal file
26
apps/web/src/app/(dashboard)/templates/data-table-title.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { Template } from '@documenso/prisma/client';
|
||||
|
||||
export type DataTableTitleProps = {
|
||||
row: Template;
|
||||
};
|
||||
|
||||
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/templates/${row.id}`}
|
||||
className="block max-w-[10rem] cursor-pointer truncate font-medium hover:underline md:max-w-[20rem]"
|
||||
>
|
||||
{row.title}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@ -12,35 +12,30 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type DeleteDraftDocumentDialogProps = {
|
||||
type DeleteTemplateDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DeleteDraftDocumentDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DeleteDraftDocumentDialogProps) => {
|
||||
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: deleteDocument, isLoading } =
|
||||
trpcReact.document.deleteDraftDocument.useMutation({
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
const { mutateAsync: deleteDocument, isLoading } = trpcReact.template.deleteTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
|
||||
toast({
|
||||
title: 'Document deleted',
|
||||
description: 'Your document has been successfully deleted.',
|
||||
duration: 5000,
|
||||
});
|
||||
toast({
|
||||
title: 'Template deleted',
|
||||
description: 'Your document has been successfully deleted.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDraftDelete = async () => {
|
||||
try {
|
||||
@ -48,7 +43,7 @@ export const DeleteDraftDocumentDialog = ({
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'This document could not be deleted at this time. Please try again.',
|
||||
description: 'This template could not be deleted at this time. Please try again.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
@ -59,10 +54,10 @@ export const DeleteDraftDocumentDialog = ({
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Do you want to delete this document?</DialogTitle>
|
||||
<DialogTitle>Do you want to delete this template?</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Please note that this action is irreversible. Once confirmed, your document will be
|
||||
Please note that this action is irreversible. Once confirmed, your template will be
|
||||
permanently deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@ -0,0 +1,89 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type DuplicateTemplateDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DuplicateTemplateDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DuplicateTemplateDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: duplicateTemplate, isLoading } =
|
||||
trpcReact.template.duplicateTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
|
||||
toast({
|
||||
title: 'Template duplicated',
|
||||
description: 'Your template has been duplicated successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
await duplicateTemplate({
|
||||
templateId: id,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while duplicating template.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Do you want to duplicate this template?</DialogTitle>
|
||||
|
||||
<DialogDescription className="pt-2">Your template will be duplicated.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="button" loading={isLoading} onClick={onDuplicate} className="flex-1">
|
||||
Duplicate
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
17
apps/web/src/app/(dashboard)/templates/empty-state.tsx
Normal file
17
apps/web/src/app/(dashboard)/templates/empty-state.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Bird } from 'lucide-react';
|
||||
|
||||
export const EmptyTemplateState = () => {
|
||||
return (
|
||||
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
|
||||
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold">We're all empty</h3>
|
||||
|
||||
<p className="mt-2 max-w-[50ch]">
|
||||
You have not yet created any templates. To create a template please upload one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
228
apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx
Normal file
228
apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FilePlus, X } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { base64 } from '@documenso/lib/universal/base64';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZCreateTemplateFormSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
|
||||
|
||||
export const NewTemplateDialog = () => {
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<TCreateTemplateFormSchema>({
|
||||
resolver: zodResolver(ZCreateTemplateFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } =
|
||||
trpc.template.createTemplate.useMutation();
|
||||
|
||||
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
|
||||
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const base64String = base64.encode(new Uint8Array(arrayBuffer));
|
||||
|
||||
setUploadedFile({
|
||||
file,
|
||||
fileBase64: `data:application/pdf;base64,${base64String}`,
|
||||
});
|
||||
|
||||
if (!form.getValues('name')) {
|
||||
form.setValue('name', file.name);
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: TCreateTemplateFormSchema) => {
|
||||
if (!uploadedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file: File = uploadedFile.file;
|
||||
|
||||
try {
|
||||
const { type, data } = await putFile(file);
|
||||
|
||||
const { id: templateDocumentDataId } = await createDocumentData({
|
||||
type,
|
||||
data,
|
||||
});
|
||||
|
||||
const { id } = await createTemplate({
|
||||
title: values.name ? values.name : file.name,
|
||||
templateDocumentDataId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Template document uploaded',
|
||||
description:
|
||||
'Your document has been uploaded successfully. You will be redirected to the template page.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setShowNewTemplateDialog(false);
|
||||
|
||||
void router.push(`/templates/${id}`);
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
if (form.getValues('name') === uploadedFile?.file.name) {
|
||||
form.reset();
|
||||
}
|
||||
|
||||
setUploadedFile(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNewTemplateDialog) {
|
||||
form.reset();
|
||||
}
|
||||
}, [form, showNewTemplateDialog]);
|
||||
|
||||
return (
|
||||
<Dialog open={showNewTemplateDialog} onOpenChange={setShowNewTemplateDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
|
||||
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
||||
New Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="w-full max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-4">New Template</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name your template</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="email" type="text" className="bg-background mt-1.5" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Leave this empty if you would like to use your document's name for the
|
||||
template
|
||||
</span>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="template">Upload a Document</Label>
|
||||
|
||||
<div className="my-3">
|
||||
{uploadedFile ? (
|
||||
<Card gradient className="h-[40vh]">
|
||||
<CardContent className="flex h-full flex-col items-center justify-center p-2">
|
||||
<button
|
||||
onClick={() => resetForm()}
|
||||
title="Remove Template"
|
||||
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
<span className="sr-only">Remove Template</span>
|
||||
</button>
|
||||
|
||||
<div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm">
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||
</div>
|
||||
|
||||
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
|
||||
Uploaded Document
|
||||
</p>
|
||||
|
||||
<span className="text-muted-foreground/80 mt-1 text-sm">
|
||||
{uploadedFile.file.name}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<DocumentDropzone
|
||||
className="mt-1.5 h-[40vh]"
|
||||
onDrop={onFileDrop}
|
||||
type="template"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button loading={isCreatingTemplate} type="submit">
|
||||
Create Template
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
52
apps/web/src/app/(dashboard)/templates/page.tsx
Normal file
52
apps/web/src/app/(dashboard)/templates/page.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
|
||||
|
||||
import { TemplatesDataTable } from './data-table-templates';
|
||||
import { EmptyTemplateState } from './empty-state';
|
||||
import { NewTemplateDialog } from './new-template-dialog';
|
||||
|
||||
type TemplatesPageProps = {
|
||||
searchParams?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 10;
|
||||
|
||||
const { templates, totalPages } = await getTemplates({
|
||||
userId: user.id,
|
||||
page: page,
|
||||
perPage: perPage,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="mb-5 mt-2 truncate text-2xl font-semibold md:text-3xl">Templates</h1>
|
||||
|
||||
<div>
|
||||
<NewTemplateDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{templates.length > 0 ? (
|
||||
<TemplatesDataTable
|
||||
templates={templates}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
) : (
|
||||
<EmptyTemplateState />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -67,18 +67,24 @@ export default async function CompletedSigningPage({
|
||||
/>
|
||||
|
||||
<div className="relative mt-6 flex w-full flex-col items-center">
|
||||
{match(document.status)
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<div className="text-documenso-700 flex items-center text-center">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Everyone has signed</span>
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
.with({ deletedAt: null }, () => (
|
||||
<div className="flex items-center text-center text-blue-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Waiting for others to sign</span>
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="flex items-center text-center text-red-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Document no longer available to sign</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
@ -86,16 +92,22 @@ export default async function CompletedSigningPage({
|
||||
<span className="mt-1.5 block">"{document.title}"</span>
|
||||
</h2>
|
||||
|
||||
{match(document.status)
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
Everyone has signed! You will receive an Email copy of the signed document.
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => (
|
||||
.with({ deletedAt: null }, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
You will receive an Email copy of the signed document once everyone has signed.
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
This document has been cancelled by the owner and is no longer available for others to
|
||||
sign.
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||
|
||||
@ -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`);
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
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 (
|
||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
|
||||
<SigningCard3D
|
||||
name={recipientName}
|
||||
signature={recipientSignature}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
|
||||
<div className="relative mt-2 flex w-full flex-col items-center">
|
||||
<div className="mt-8 flex items-center text-center text-red-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Document Cancelled</span>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
<span className="mt-1.5 block">"{document.title}"</span>
|
||||
is no longer available to sign
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
This document has been cancelled by the owner.
|
||||
</p>
|
||||
|
||||
{session?.user ? (
|
||||
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
|
||||
Go Back Home
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||
Want to send slick signing links like this one?{' '}
|
||||
<Link
|
||||
href="https://documenso.com"
|
||||
className="text-documenso-700 hover:text-documenso-600"
|
||||
>
|
||||
Check out Documenso.
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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 (
|
||||
<NoLongerAvailable
|
||||
document={document}
|
||||
recipientName={recipient.name}
|
||||
recipientSignature={recipientSignature}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SigningProvider
|
||||
email={recipient.email}
|
||||
|
||||
@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
DOCUMENTS_PAGE_SHORTCUT,
|
||||
SETTINGS_PAGE_SHORTCUT,
|
||||
} from '@documenso/lib/constants/keyboard-shortcuts';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
@ -29,13 +30,20 @@ const DOCUMENTS_PAGES = [
|
||||
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
|
||||
},
|
||||
{ label: 'Draft documents', path: '/documents?status=DRAFT' },
|
||||
{ label: 'Completed documents', path: '/documents?status=COMPLETED' },
|
||||
{
|
||||
label: 'Completed documents',
|
||||
path: '/documents?status=COMPLETED',
|
||||
},
|
||||
{ label: 'Pending documents', path: '/documents?status=PENDING' },
|
||||
{ label: 'Inbox documents', path: '/documents?status=INBOX' },
|
||||
];
|
||||
|
||||
const SETTINGS_PAGES = [
|
||||
{ label: 'Settings', path: '/settings', shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', '') },
|
||||
{
|
||||
label: 'Settings',
|
||||
path: '/settings',
|
||||
shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''),
|
||||
},
|
||||
{ label: 'Profile', path: '/settings/profile' },
|
||||
{ label: 'Password', path: '/settings/password' },
|
||||
];
|
||||
@ -53,6 +61,28 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
|
||||
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.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
||||
}));
|
||||
}, [searchDocumentsData]);
|
||||
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
const toggleOpen = () => {
|
||||
@ -113,7 +143,13 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog commandProps={{ onKeyDown: handleKeyDown }} open={open} onOpenChange={setOpen}>
|
||||
<CommandDialog
|
||||
commandProps={{
|
||||
onKeyDown: handleKeyDown,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<CommandInput
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
@ -121,7 +157,17 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
/>
|
||||
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{isSearchingDocuments ? (
|
||||
<CommandEmpty>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="animate-spin">
|
||||
<Loader />
|
||||
</span>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
) : (
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
)}
|
||||
{!currentPage && (
|
||||
<>
|
||||
<CommandGroup heading="Documents">
|
||||
@ -133,6 +179,11 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
<CommandGroup heading="Preferences">
|
||||
<CommandItem onSelect={() => addPage('theme')}>Change theme</CommandItem>
|
||||
</CommandGroup>
|
||||
{searchResults.length > 0 && (
|
||||
<CommandGroup heading="Your documents">
|
||||
<Commands push={push} pages={searchResults} />
|
||||
</CommandGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentPage === 'theme' && <ThemeCommands setTheme={setTheme} />}
|
||||
@ -146,10 +197,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) => (
|
||||
<CommandItem key={page.path} onSelect={() => push(page.path)}>
|
||||
return pages.map((page, idx) => (
|
||||
<CommandItem
|
||||
key={page.path + idx}
|
||||
value={page.value ?? page.label}
|
||||
onSelect={() => push(page.path)}
|
||||
>
|
||||
{page.label}
|
||||
{page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>}
|
||||
</CommandItem>
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -10,10 +13,22 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { CommandMenu } from '../common/command-menu';
|
||||
|
||||
const navigationLinks = [
|
||||
{
|
||||
href: '/documents',
|
||||
label: 'Documents',
|
||||
},
|
||||
{
|
||||
href: '/templates',
|
||||
label: 'Templates',
|
||||
},
|
||||
];
|
||||
|
||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
// const pathname = usePathname();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||
|
||||
@ -26,9 +41,29 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('ml-8 hidden flex-1 gap-x-6 md:flex md:justify-center', className)}
|
||||
className={cn(
|
||||
'ml-8 hidden flex-1 items-center gap-x-12 md:flex md:justify-between',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-baseline gap-x-6">
|
||||
{navigationLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||
{
|
||||
'text-foreground dark:text-muted-foreground': pathname?.startsWith(href),
|
||||
},
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CommandMenu open={open} onOpenChange={setOpen} />
|
||||
|
||||
<Button
|
||||
@ -47,19 +82,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* We have no other subpaths rn */}
|
||||
{/* <Link
|
||||
href="/documents"
|
||||
className={cn(
|
||||
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||
{
|
||||
'text-foreground': pathname?.startsWith('/documents'),
|
||||
},
|
||||
)}
|
||||
>
|
||||
Documents
|
||||
</Link> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
@ -32,7 +33,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[1000] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
||||
scrollY > 5 && 'border-b-border',
|
||||
className,
|
||||
)}
|
||||
@ -48,7 +49,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
|
||||
<DesktopNav />
|
||||
|
||||
<div className="flex gap-x-4">
|
||||
<div className="flex gap-x-4 md:ml-8">
|
||||
<ProfileDropdown user={user} />
|
||||
|
||||
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
||||
|
||||
@ -4,6 +4,7 @@ import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CreditCard,
|
||||
FileSpreadsheet,
|
||||
Lock,
|
||||
LogOut,
|
||||
User as LucideUser,
|
||||
@ -20,7 +21,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 +57,11 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
title="Profile Dropdown"
|
||||
className="relative h-10 w-10 rounded-full"
|
||||
>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback>{avatarFallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
@ -102,6 +107,13 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/templates" className="cursor-pointer">
|
||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||
Templates
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
|
||||
50
apps/web/src/components/formatter/template-type.tsx
Normal file
50
apps/web/src/components/formatter/template-type.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import { Globe, Lock } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
type TemplateTypeIcon = {
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type TemplateTypes = (typeof TemplateTypePrisma)[keyof typeof TemplateTypePrisma];
|
||||
|
||||
const TEMPLATE_TYPES: Record<TemplateTypes, TemplateTypeIcon> = {
|
||||
PRIVATE: {
|
||||
label: 'Private',
|
||||
icon: Lock,
|
||||
color: 'text-blue-600 dark:text-blue-300',
|
||||
},
|
||||
PUBLIC: {
|
||||
label: 'Public',
|
||||
icon: Globe,
|
||||
color: 'text-green-500 dark:text-green-300',
|
||||
},
|
||||
};
|
||||
|
||||
export type TemplateTypeProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
type: TemplateTypes;
|
||||
inheritColor?: boolean;
|
||||
};
|
||||
|
||||
export const TemplateType = ({ className, type, inheritColor, ...props }: TemplateTypeProps) => {
|
||||
const { label, icon: Icon, color } = TEMPLATE_TYPES[type];
|
||||
|
||||
return (
|
||||
<span className={cn('flex items-center', className)} {...props}>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cn('mr-2 inline-block h-4 w-4', {
|
||||
[color]: !inheritColor,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
|
||||
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
||||
|
||||
export type AddTemplateFieldsActionInput = TAddTemplateFieldsFormSchema & {
|
||||
templateId: number;
|
||||
};
|
||||
|
||||
export const addTemplateFields = async ({ templateId, fields }: AddTemplateFieldsActionInput) => {
|
||||
'use server';
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
await setFieldsForTemplate({
|
||||
userId: user.id,
|
||||
templateId,
|
||||
fields: fields.map((field) => ({
|
||||
id: field.nativeId,
|
||||
signerEmail: field.signerEmail,
|
||||
signerId: field.signerId,
|
||||
signerToken: field.signerToken,
|
||||
type: field.type,
|
||||
pageNumber: field.pageNumber,
|
||||
pageX: field.pageX,
|
||||
pageY: field.pageY,
|
||||
pageWidth: field.pageWidth,
|
||||
pageHeight: field.pageHeight,
|
||||
})),
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template';
|
||||
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
|
||||
|
||||
export type AddTemplatePlaceholdersActionInput = TAddTemplatePlacholderRecipientsFormSchema & {
|
||||
templateId: number;
|
||||
};
|
||||
|
||||
export const addTemplatePlaceholders = async ({
|
||||
templateId,
|
||||
signers,
|
||||
}: AddTemplatePlaceholdersActionInput) => {
|
||||
'use server';
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
await setRecipientsForTemplate({
|
||||
userId: user.id,
|
||||
templateId,
|
||||
recipients: signers.map((signer) => ({
|
||||
id: signer.nativeId!,
|
||||
email: signer.email,
|
||||
name: signer.name!,
|
||||
})),
|
||||
});
|
||||
};
|
||||
@ -8,6 +8,7 @@ import { signIn } from 'next-auth/react';
|
||||
import { Controller, 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';
|
||||
@ -36,6 +37,7 @@ export type SignUpFormProps = {
|
||||
|
||||
export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const {
|
||||
@ -64,6 +66,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({
|
||||
|
||||
@ -4,6 +4,11 @@ import { appRouter } from '@documenso/trpc/server/router';
|
||||
|
||||
export const config = {
|
||||
maxDuration: 60,
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: '50mb',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default trpcNext.createNextApiHandler({
|
||||
|
||||
@ -17,3 +17,20 @@ services:
|
||||
- 9000:9000
|
||||
- 2500:2500
|
||||
- 1100:1100
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
container_name: minio
|
||||
ports:
|
||||
- 9002:9002
|
||||
- 9001:9001
|
||||
volumes:
|
||||
- minio:/data
|
||||
environment:
|
||||
MINIO_ROOT_USER: documenso
|
||||
MINIO_ROOT_PASSWORD: password
|
||||
entrypoint: sh
|
||||
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
|
||||
|
||||
volumes:
|
||||
minio:
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
module.exports = {
|
||||
'**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}': ['prettier --write'],
|
||||
'**/*.{ts,tsx,cts,mts}': ['eslint --fix'],
|
||||
'**/*.{js,jsx,cjs,mjs}': ['prettier --write'],
|
||||
'**/*.{yml,mdx}': ['prettier --write'],
|
||||
'**/*/package.json': ['npm run precommit'],
|
||||
};
|
||||
|
||||
5
package-lock.json
generated
5
package-lock.json
generated
@ -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"
|
||||
|
||||
@ -22,7 +22,8 @@
|
||||
"prisma:seed": "npm run with:env -- npm run prisma:seed -w @documenso/prisma",
|
||||
"prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma",
|
||||
"with:env": "dotenv -e .env -e .env.local --",
|
||||
"reset:hard": "npm run clean && npm i && npm run prisma:generate"
|
||||
"reset:hard": "npm run clean && npm i && npm run prisma:generate",
|
||||
"precommit": "npm install && git add package.json package-lock.json"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.6.0",
|
||||
|
||||
192
packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts
Normal file
192
packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts
Normal file
@ -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();
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
75
packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts
Normal file
75
packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts
Normal file
@ -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();
|
||||
});
|
||||
@ -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');
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
8
packages/app-tests/tsconfig.json
Normal file
8
packages/app-tests/tsconfig.json
Normal file
@ -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"]
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
{inviterName} has cancelled the document
|
||||
<br />"{documentName}"
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
You don't need to sign it anymore.
|
||||
</Text>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateDocumentCancel;
|
||||
66
packages/email/templates/document-cancel.tsx
Normal file
66
packages/email/templates/document-cancel.tsx
Normal file
@ -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<TemplateDocumentCancelProps>;
|
||||
|
||||
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 (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
|
||||
<TemplateDocumentCancel
|
||||
inviterName={inviterName}
|
||||
inviterEmail={inviterEmail}
|
||||
documentName={documentName}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentCancelTemplate;
|
||||
@ -19,9 +19,10 @@ export const getRecipientsStats = async () => {
|
||||
|
||||
results.forEach((result) => {
|
||||
const { readStatus, signingStatus, sendStatus, _count } = result;
|
||||
stats[readStatus] += _count;
|
||||
stats[signingStatus] += _count;
|
||||
stats[sendStatus] += _count;
|
||||
stats[readStatus as keyof typeof stats] += _count;
|
||||
stats.TOTAL_RECIPIENTS += _count;
|
||||
stats[signingStatus as keyof typeof stats] += _count;
|
||||
stats[sendStatus as keyof typeof stats] += _count;
|
||||
stats.TOTAL_RECIPIENTS += _count;
|
||||
});
|
||||
|
||||
|
||||
88
packages/lib/server-only/document/delete-document.ts
Normal file
88
packages/lib/server-only/document/delete-document.ts
Normal file
@ -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(),
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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 } });
|
||||
};
|
||||
@ -8,7 +8,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
|
||||
|
||||
import type { FindResultSet } from '../../types/find-result-set';
|
||||
|
||||
export interface FindDocumentsOptions {
|
||||
export type FindDocumentsOptions = {
|
||||
userId: number;
|
||||
term?: string;
|
||||
status?: ExtendedDocumentStatus;
|
||||
@ -19,7 +19,7 @@ export interface FindDocumentsOptions {
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
period?: '' | '7d' | '14d' | '30d';
|
||||
}
|
||||
};
|
||||
|
||||
export const findDocuments = async ({
|
||||
userId,
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
};
|
||||
22
packages/lib/server-only/field/get-fields-for-template.ts
Normal file
22
packages/lib/server-only/field/get-fields-for-template.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface GetFieldsForTemplateOptions {
|
||||
templateId: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForTemplateOptions) => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: {
|
||||
templateId,
|
||||
Template: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return fields;
|
||||
};
|
||||
@ -27,6 +27,10 @@ export const removeSignedFieldWithToken = async ({
|
||||
|
||||
const { Document: document, Recipient: recipient } = field;
|
||||
|
||||
if (!document) {
|
||||
throw new Error(`Document not found for field ${field.id}`);
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
throw new Error(`Document ${document.id} has already been completed`);
|
||||
}
|
||||
|
||||
116
packages/lib/server-only/field/set-fields-for-template.ts
Normal file
116
packages/lib/server-only/field/set-fields-for-template.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export type Field = {
|
||||
id?: number | null;
|
||||
type: FieldType;
|
||||
signerEmail: string;
|
||||
signerId?: number;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
};
|
||||
|
||||
export type SetFieldsForTemplateOptions = {
|
||||
userId: number;
|
||||
templateId: number;
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
export const setFieldsForTemplate = async ({
|
||||
userId,
|
||||
templateId,
|
||||
fields,
|
||||
}: SetFieldsForTemplateOptions) => {
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const existingFields = await prisma.field.findMany({
|
||||
where: {
|
||||
templateId,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
const removedFields = existingFields.filter(
|
||||
(existingField) =>
|
||||
!fields.find(
|
||||
(field) =>
|
||||
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
|
||||
),
|
||||
);
|
||||
|
||||
const linkedFields = fields.map((field) => {
|
||||
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
||||
|
||||
return {
|
||||
...field,
|
||||
_persisted: existing,
|
||||
};
|
||||
});
|
||||
|
||||
const persistedFields = await prisma.$transaction(
|
||||
// Disabling as wrapping promises here causes type issues
|
||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||
linkedFields.map((field) =>
|
||||
prisma.field.upsert({
|
||||
where: {
|
||||
id: field._persisted?.id ?? -1,
|
||||
templateId,
|
||||
},
|
||||
update: {
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.pageWidth,
|
||||
height: field.pageHeight,
|
||||
},
|
||||
create: {
|
||||
type: field.type,
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.pageWidth,
|
||||
height: field.pageHeight,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
Template: {
|
||||
connect: {
|
||||
id: templateId,
|
||||
},
|
||||
},
|
||||
Recipient: {
|
||||
connect: {
|
||||
id: field.signerId,
|
||||
email: field.signerEmail,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (removedFields.length > 0) {
|
||||
await prisma.field.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: removedFields.map((field) => field.id),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return persistedFields;
|
||||
};
|
||||
@ -33,10 +33,18 @@ export const signFieldWithToken = async ({
|
||||
|
||||
const { Document: document, Recipient: recipient } = field;
|
||||
|
||||
if (!document) {
|
||||
throw new Error(`Document not found for field ${field.id}`);
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
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`);
|
||||
}
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface GetRecipientsForTemplateOptions {
|
||||
templateId: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const getRecipientsForTemplate = async ({
|
||||
templateId,
|
||||
userId,
|
||||
}: GetRecipientsForTemplateOptions) => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
templateId,
|
||||
Template: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return recipients;
|
||||
};
|
||||
@ -0,0 +1,97 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { nanoid } from '../../universal/id';
|
||||
|
||||
export type SetRecipientsForTemplateOptions = {
|
||||
userId: number;
|
||||
templateId: number;
|
||||
recipients: {
|
||||
id?: number;
|
||||
email: string;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const setRecipientsForTemplate = async ({
|
||||
userId,
|
||||
templateId,
|
||||
recipients,
|
||||
}: SetRecipientsForTemplateOptions) => {
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
email: recipient.email.toLowerCase(),
|
||||
}));
|
||||
|
||||
const existingRecipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
templateId,
|
||||
},
|
||||
});
|
||||
|
||||
const removedRecipients = existingRecipients.filter(
|
||||
(existingRecipient) =>
|
||||
!normalizedRecipients.find(
|
||||
(recipient) =>
|
||||
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
||||
),
|
||||
);
|
||||
|
||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
);
|
||||
|
||||
return {
|
||||
...recipient,
|
||||
_persisted: existing,
|
||||
};
|
||||
});
|
||||
|
||||
const persistedRecipients = await prisma.$transaction(
|
||||
// Disabling as wrapping promises here causes type issues
|
||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||
linkedRecipients.map((recipient) =>
|
||||
prisma.recipient.upsert({
|
||||
where: {
|
||||
id: recipient._persisted?.id ?? -1,
|
||||
templateId,
|
||||
},
|
||||
update: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
templateId,
|
||||
},
|
||||
create: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
token: nanoid(),
|
||||
templateId,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (removedRecipients.length > 0) {
|
||||
await prisma.recipient.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: removedRecipients.map((recipient) => recipient.id),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return persistedRecipients;
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TCreateDocumentFromTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
|
||||
|
||||
export type CreateDocumentFromTemplateOptions = TCreateDocumentFromTemplateMutationSchema & {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const createDocumentFromTemplate = async ({
|
||||
templateId,
|
||||
userId,
|
||||
}: CreateDocumentFromTemplateOptions) => {
|
||||
const template = await prisma.template.findUnique({
|
||||
where: { id: templateId, userId },
|
||||
include: {
|
||||
Recipient: true,
|
||||
Field: true,
|
||||
templateDocumentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new Error('Template not found.');
|
||||
}
|
||||
|
||||
const documentData = await prisma.documentData.create({
|
||||
data: {
|
||||
type: template.templateDocumentData.type,
|
||||
data: template.templateDocumentData.data,
|
||||
initialData: template.templateDocumentData.initialData,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
userId,
|
||||
title: template.title,
|
||||
documentDataId: documentData.id,
|
||||
Recipient: {
|
||||
create: template.Recipient.map((recipient) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
token: nanoid(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.field.createMany({
|
||||
data: template.Field.map((field) => {
|
||||
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
|
||||
|
||||
return {
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: field.customText,
|
||||
inserted: field.inserted,
|
||||
documentId: document.id,
|
||||
recipientId: documentRecipient?.id || null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
return document;
|
||||
};
|
||||
20
packages/lib/server-only/template/create-template.ts
Normal file
20
packages/lib/server-only/template/create-template.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
|
||||
|
||||
export type CreateTemplateOptions = TCreateTemplateMutationSchema & {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const createTemplate = async ({
|
||||
title,
|
||||
userId,
|
||||
templateDocumentDataId,
|
||||
}: CreateTemplateOptions) => {
|
||||
return await prisma.template.create({
|
||||
data: {
|
||||
title,
|
||||
userId,
|
||||
templateDocumentDataId,
|
||||
},
|
||||
});
|
||||
};
|
||||
12
packages/lib/server-only/template/delete-template.ts
Normal file
12
packages/lib/server-only/template/delete-template.ts
Normal file
@ -0,0 +1,12 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type DeleteTemplateOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const deleteTemplate = async ({ id, userId }: DeleteTemplateOptions) => {
|
||||
return await prisma.template.delete({ where: { id, userId } });
|
||||
};
|
||||
74
packages/lib/server-only/template/duplicate-template.ts
Normal file
74
packages/lib/server-only/template/duplicate-template.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
|
||||
|
||||
export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplateOptions) => {
|
||||
const template = await prisma.template.findUnique({
|
||||
where: { id: templateId, userId },
|
||||
include: {
|
||||
Recipient: true,
|
||||
Field: true,
|
||||
templateDocumentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new Error('Template not found.');
|
||||
}
|
||||
|
||||
const documentData = await prisma.documentData.create({
|
||||
data: {
|
||||
type: template.templateDocumentData.type,
|
||||
data: template.templateDocumentData.data,
|
||||
initialData: template.templateDocumentData.initialData,
|
||||
},
|
||||
});
|
||||
|
||||
const duplicatedTemplate = await prisma.template.create({
|
||||
data: {
|
||||
userId,
|
||||
title: template.title + ' (copy)',
|
||||
templateDocumentDataId: documentData.id,
|
||||
Recipient: {
|
||||
create: template.Recipient.map((recipient) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
token: nanoid(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.field.createMany({
|
||||
data: template.Field.map((field) => {
|
||||
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
const duplicatedTemplateRecipient = duplicatedTemplate.Recipient.find(
|
||||
(doc) => doc.email === recipient?.email,
|
||||
);
|
||||
|
||||
return {
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: field.customText,
|
||||
inserted: field.inserted,
|
||||
templateId: duplicatedTemplate.id,
|
||||
recipientId: duplicatedTemplateRecipient?.id || null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
return duplicatedTemplate;
|
||||
};
|
||||
18
packages/lib/server-only/template/get-template-by-id.ts
Normal file
18
packages/lib/server-only/template/get-template-by-id.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface GetTemplateByIdOptions {
|
||||
id: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) => {
|
||||
return await prisma.template.findFirstOrThrow({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
templateDocumentData: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
37
packages/lib/server-only/template/get-templates.ts
Normal file
37
packages/lib/server-only/template/get-templates.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetTemplatesOptions = {
|
||||
userId: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
export const getTemplates = async ({ userId, page = 1, perPage = 10 }: GetTemplatesOptions) => {
|
||||
const [templates, count] = await Promise.all([
|
||||
await prisma.template.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
templateDocumentData: true,
|
||||
Field: true,
|
||||
},
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
}),
|
||||
await prisma.template.count({
|
||||
where: {
|
||||
User: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
templates,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
};
|
||||
};
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TemplateStatus" AS ENUM ('PUBLIC', 'PRIVATE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Template" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" "TemplateStatus" NOT NULL DEFAULT 'PRIVATE',
|
||||
"templateDataId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TemplateData" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "DocumentDataType" NOT NULL,
|
||||
"data" TEXT NOT NULL,
|
||||
"initialData" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "TemplateData_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TemplateField" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"templateId" INTEGER NOT NULL,
|
||||
"type" "FieldType" NOT NULL,
|
||||
"page" INTEGER NOT NULL,
|
||||
"positionX" DECIMAL(65,30) NOT NULL DEFAULT 0,
|
||||
"positionY" DECIMAL(65,30) NOT NULL DEFAULT 0,
|
||||
"width" DECIMAL(65,30) NOT NULL DEFAULT -1,
|
||||
"height" DECIMAL(65,30) NOT NULL DEFAULT -1,
|
||||
"customText" TEXT NOT NULL,
|
||||
"inserted" BOOLEAN NOT NULL,
|
||||
|
||||
CONSTRAINT "TemplateField_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Template_templateDataId_key" ON "Template"("templateDataId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Template" ADD CONSTRAINT "Template_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDataId_fkey" FOREIGN KEY ("templateDataId") REFERENCES "TemplateData"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TemplateField" ADD CONSTRAINT "TemplateField_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,15 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The `status` column on the `Template` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TemplateType" AS ENUM ('PUBLIC', 'PRIVATE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Template" DROP COLUMN "status",
|
||||
ADD COLUMN "status" "TemplateType" NOT NULL DEFAULT 'PRIVATE';
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "TemplateStatus";
|
||||
@ -0,0 +1,14 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `TemplateData` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDataId_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "TemplateData";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDataId_fkey" FOREIGN KEY ("templateDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `inserted` on the `TemplateField` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "TemplateField" DROP COLUMN "inserted";
|
||||
@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `documentName` to the `Template` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Template" ADD COLUMN "documentName" TEXT NOT NULL;
|
||||
@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `updatedAt` to the `Template` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Template" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
|
||||
@ -0,0 +1,51 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `description` on the `Template` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `documentName` on the `Template` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `status` on the `Template` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `templateDataId` on the `Template` table. All the data in the column will be lost.
|
||||
- A unique constraint covering the columns `[tempateDocumentDataId]` on the table `Template` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `tempateDocumentDataId` to the `Template` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `inserted` to the `TemplateField` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDataId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Template_templateDataId_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Template" DROP COLUMN "description",
|
||||
DROP COLUMN "documentName",
|
||||
DROP COLUMN "status",
|
||||
DROP COLUMN "templateDataId",
|
||||
ADD COLUMN "tempateDocumentDataId" TEXT NOT NULL,
|
||||
ADD COLUMN "type" "TemplateType" NOT NULL DEFAULT 'PRIVATE',
|
||||
ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TemplateField" ADD COLUMN "inserted" BOOLEAN NOT NULL,
|
||||
ADD COLUMN "recipientId" INTEGER;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TemplateRecipient" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"templateId" INTEGER NOT NULL,
|
||||
"placeholder" VARCHAR(255) NOT NULL,
|
||||
|
||||
CONSTRAINT "TemplateRecipient_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Template_tempateDocumentDataId_key" ON "Template"("tempateDocumentDataId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Template" ADD CONSTRAINT "Template_tempateDocumentDataId_fkey" FOREIGN KEY ("tempateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TemplateRecipient" ADD CONSTRAINT "TemplateRecipient_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TemplateField" ADD CONSTRAINT "TemplateField_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "TemplateRecipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,23 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `tempateDocumentDataId` on the `Template` table. All the data in the column will be lost.
|
||||
- A unique constraint covering the columns `[templateDocumentDataId]` on the table `Template` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `templateDocumentDataId` to the `Template` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Template" DROP CONSTRAINT "Template_tempateDocumentDataId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Template_tempateDocumentDataId_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Template" DROP COLUMN "tempateDocumentDataId",
|
||||
ADD COLUMN "templateDocumentDataId" TEXT NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Template_templateDocumentDataId_key" ON "Template"("templateDocumentDataId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDocumentDataId_fkey" FOREIGN KEY ("templateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `email` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "TemplateRecipient" ADD COLUMN "email" VARCHAR(255) NOT NULL;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user