mirror of
https://github.com/documenso/documenso.git
synced 2026-09-30 16:54:34 +10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e9c222d5c | ||
|
|
ae07df6061 |
@@ -0,0 +1,75 @@
|
||||
---
|
||||
date: 2026-05-29
|
||||
title: Team Analytics Dashboard
|
||||
---
|
||||
|
||||
> Source: issue #242 (documenso/backlog-internal, "Team signing analytics/dashboard"). Redo of stale PR #1976 (`feat/team-dashboard`, closed DIRTY) — build fresh on `main`; do NOT resurrect that branch or its parallel `analytics/` module. Scope locked via interview 2026-05-29.
|
||||
|
||||
## V1 decisions (locked)
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Goal | Team **document usage** dashboard. NOT signature / recipient / user metrics; no "cumulative active users". |
|
||||
| Headline | **Documents Sent** (non-draft) + **Completed**. Raw counts only — no completion-rate or derived metrics, no period-over-period deltas. |
|
||||
| State tiles | Draft · Pending · Completed · **Declined (= `REJECTED`)**. "Voided" dropped (no `VOIDED` status exists). Render as a **row of compact stat tiles** (big number + small label). |
|
||||
| Attribution | By document **owner** (`Envelope.userId`, reuse `senderIds`). Full per-member, **no** privacy guardrail. |
|
||||
| Member control | **Filter only** — multiselect of team members + easy "All"; every number reflects the selected subset. Reuse `documents-table-sender-filter`. |
|
||||
| Time filter | **Calendar presets** (This week / This month / This quarter / This year, Last month, …). Default **This month / last 30 days**. **No per-bucket breakdown, no trend chart** — one total per metric for the range. Boundaries in the **viewer's local timezone**. |
|
||||
| Folder scope | Aggregate **all folders**. Folder filter control → V2. |
|
||||
| Counts | **Exact** — drop `STATS_COUNT_CAP` on the analytics path. |
|
||||
| Freshness | **Live** on every load (no cache in V1). |
|
||||
| Number format | Full, thousands-separated (`1,234`). |
|
||||
| Inbox | **Excluded** — dashboard = docs the team PRODUCES, not receives. |
|
||||
| Placement | Standalone top-level route `/t/:teamUrl/analytics` + **primary nav entry**. |
|
||||
| Access | `ADMIN` + `MANAGER` only. `MEMBER` → **hide nav + silent redirect** to documents (no 403, no existence leak). |
|
||||
| Rollout | Behind a **feature flag**, then open to all teams. No plan/tier gating. |
|
||||
| Empty state | Friendly empty state with CTA to send the first document. |
|
||||
| Export | Out of V1 (gold-plating). |
|
||||
| Deferred → V2 | Folder filter, personal/individual analytics, org-wide cross-team rollup, trend charts, period deltas, derived metrics. |
|
||||
|
||||
## Metric semantics — READ THIS
|
||||
|
||||
**Event model, bucketed by status date.** Each number counts documents that ENTERED that state during the selected period, each on its OWN date axis:
|
||||
|
||||
- **Documents Sent** = non-draft, `createdAt` ∈ period. *(createdAt is the sent-date proxy — see Risks.)*
|
||||
- **Pending** = currently `PENDING`, `createdAt` ∈ period (sent in period, still pending).
|
||||
- **Completed** = status `COMPLETED`, **`Envelope.completedAt`** ∈ period.
|
||||
- **Declined** = status `REJECTED`, rejection time ∈ period — sourced from **`DocumentAuditLog` `DOCUMENT_RECIPIENT_REJECTED`** (there is no `Envelope.rejectedAt`).
|
||||
- **Draft** = currently `DRAFT`, `createdAt` ∈ period (informational; excluded from "Sent").
|
||||
|
||||
⚠️ **Tiles are independent activity counts on different date axes → they do NOT sum to "Documents Sent."** A doc sent in April but completed in May lands in May's Completed, not April's. The UI must NOT present the tiles as if they add up to the headline (no "x of y" framing, no stacked-total visuals).
|
||||
|
||||
## Backend
|
||||
|
||||
- **New dedicated analytics query** (e.g. `packages/lib/server-only/team/get-team-analytics.ts`). **Do NOT call `getStats` directly** — its semantics diverge (root-folder-only, `STATS_COUNT_CAP`-capped, every status bucketed by `createdAt`). **Reuse its *patterns*:** Kysely builder, team `visibilityFilter` / `teamDeletedFilter`, `senderIds`, `EnvelopeType.DOCUMENT`, `deletedAt IS NULL`.
|
||||
- Per-metric windows: `createdAt` for Sent/Pending/Draft; `Envelope.completedAt` for Completed; join `DocumentAuditLog` (`type = DOCUMENT_RECIPIENT_REJECTED`, by `envelopeId`, earliest timestamp) for Declined. Exact `COUNT(*)` — no cap.
|
||||
- Period: resolve `[start, end)` from preset + **viewer timezone** (client sends IANA zone/offset; default UTC if absent). Use Luxon (already imported in `get-stats.ts`).
|
||||
- tRPC: new `team.getAnalytics` (team-router per-file pattern, `packages/trpc/server/team-router/`). Input `{ teamId, period | { from, to }, senderIds[] }`. **Gate `ADMIN`/`MANAGER`** via team role (`getTeamById` → `currentTeamRole`); deny `MEMBER`.
|
||||
|
||||
## Frontend
|
||||
|
||||
- Route `apps/remix/app/routes/_authenticated+/t.$teamUrl+/analytics._index.tsx`. Loader resolves team + role; `MEMBER` → `redirect` to `/t/:teamUrl/documents`. Behind feature flag (hidden + redirect when off).
|
||||
- Components (tight, glanceable): headline (Documents Sent, Completed) + compact tile row (Draft/Pending/Completed/Declined); member multiselect (reuse `documents-table-sender-filter`, "All"); calendar-preset period selector; empty state.
|
||||
- Nav entry in `apps/remix/app/components/general/menu-switcher.tsx`, gated by role + flag.
|
||||
|
||||
## Design
|
||||
|
||||
1. **Match existing Documenso UI** — Shadcn + Tailwind cards/typography from documents index & admin stats; reuse primitives, don't invent.
|
||||
2. **Consult the `uidotsh` (`/ui`) skill for EVERY design decision.** No layout/spacing/component/visual choice ships without first fetching `uidotsh://ui`, routing to the matching subskill, and loading its design-guideline files before writing markup. Subskills: `ideas` (tile-row layout options), `design` (`design-guidelines.md`), `finalize` = `componentize` + `canonicalize-tailwind`, `add-dark-mode`, `make-responsive`.
|
||||
|
||||
## Approach order
|
||||
|
||||
Per ElTimuro: **iterate on the UI first, then finalize the backend.** Stand up the page + filters against a thin query, agree the tile layout via `uidotsh`, then lock the analytics query (date axes, audit-log join, exact counts).
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit (`get-team-analytics`): doc sent-April/completed-May counts in May's Completed only (not April); Declined dated from audit log; all-folders aggregation; exact counts beyond the old cap; `senderIds` attribution; timezone-correct period boundaries.
|
||||
- Unit (route/router): `ADMIN`/`MANAGER` allowed, `MEMBER` denied/redirected; flag off → nav hidden + redirect.
|
||||
- E2E (Playwright, `@documenso/app-tests`): admin sees dashboard; member redirected away; member-filter and period-preset changes move the numbers; new team shows empty state.
|
||||
|
||||
## Risks / open
|
||||
|
||||
1. **Sent-date proxy:** `createdAt` ≠ true send time for draft-then-sent docs. More accurate = `DocumentAuditLog DOCUMENT_SENT`. V1 uses `createdAt` (no extra join); revisit if attribution looks wrong.
|
||||
2. **Audit-log join cost** for Declined on large teams (live + uncapped). Acceptable for V1; caching/precompute is the V2 lever if it bites.
|
||||
3. **Non-summing tiles** can confuse stakeholders — needs clear labels/tooltip; confirm framing with ElTimuro on the UI pass.
|
||||
4. **Timezone plumbing:** client must send its zone and the server must bucket in it. Confirm no existing team-timezone setting should take precedence.
|
||||
@@ -168,6 +168,8 @@ NEXT_PRIVATE_REDIS_PREFIX="documenso"
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
# OPTIONAL: Leave blank to disable billing.
|
||||
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||
# OPTIONAL: Team analytics dashboard kill-switch. Enabled by default; set to "false" to hide it during rollout.
|
||||
NEXT_PUBLIC_FEATURE_TEAM_ANALYTICS_ENABLED=
|
||||
# OPTIONAL: Set to "true" to disable all signup methods (email, Google, Microsoft, OIDC, including the organisation OIDC portal).
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||
# OPTIONAL: Set to "true" to disable email/password signup only.
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
name: 'One-Click Deploy Provider Request'
|
||||
description: Request a new one-click deployment provider (Railway, Render, etc.) to be added to our README
|
||||
title: 'One-Click Deploy Provider Request: [Provider Name]'
|
||||
labels: ['deploy-provider-request']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for your interest in adding a one-click deploy option for Documenso!
|
||||
|
||||
Each provider we list requires us to create, test, and maintain a deployment template, which is ongoing work on top of everything else. To keep this manageable, we ask that providers (or users) **open an issue instead of a PR** so the community can signal interest.
|
||||
|
||||
**How this works:**
|
||||
|
||||
- 👍 this issue if you'd like to see Documenso deployable on this provider.
|
||||
- If community interest is high enough, we'll consider adding it to the README.
|
||||
- Opening an issue is not a guarantee of inclusion. PRs adding badges without a prior issue and demonstrated interest will be closed.
|
||||
- type: input
|
||||
attributes:
|
||||
label: Provider Name
|
||||
placeholder: e.g. Railway
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Provider Website
|
||||
placeholder: e.g. https://railway.com
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Deploy/Template URL
|
||||
description: A link to an existing deployment template or deploy button URL, if one exists.
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Who creates and maintains the deployment template?
|
||||
options:
|
||||
- The provider
|
||||
- Me / the community
|
||||
- Nobody yet
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Testing & Maintenance
|
||||
description: Has the template been tested against the current Documenso release? How are updates handled when Documenso ships breaking changes (env vars, migrations, Docker changes)?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Why this provider?
|
||||
description: Tell us why Documenso users would benefit — existing user base, region coverage, free tier, etc.
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Please check the boxes that apply to this request.
|
||||
options:
|
||||
- label: I have searched existing issues to make sure this provider has not already been requested.
|
||||
- label: I understand that inclusion depends on community interest and is not guaranteed.
|
||||
- label: I understand that PRs adding deploy badges without a prior issue will be closed.
|
||||
@@ -2,7 +2,7 @@ name: 'Setup node'
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: v24.x
|
||||
default: v22.x
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
|
||||
@@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
||||
|
||||
To run Documenso locally, you will need
|
||||
|
||||
- Node.js (v24 or above)
|
||||
- Node.js (v22 or above)
|
||||
- Postgres SQL Database
|
||||
- Docker (optional)
|
||||
|
||||
@@ -186,37 +186,21 @@ For full instructions, requirements, and configuration details, see the [Self Ho
|
||||
|
||||
### One-Click Deploys
|
||||
|
||||
> [!NOTE]
|
||||
> Want to see another provider listed here? Please [open a provider request](https://github.com/documenso/documenso/issues/new?template=deploy-provider-request.yml) instead of a PR so the community can signal interest. PRs adding deploy badges without a prior issue will be closed.
|
||||
#### Railway
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="200">
|
||||
<a href="https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic">
|
||||
<img src="https://railway.com/button.svg" alt="Deploy on Railway" height="40" />
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="200">
|
||||
<a href="https://render.com/deploy?repo=https://github.com/documenso/documenso">
|
||||
<img src="https://render.com/images/deploy-to-render-button.svg" alt="Deploy to Render" height="40" />
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="200">
|
||||
<a href="https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile">
|
||||
<img src="https://www.koyeb.com/static/images/deploy/button.svg" alt="Deploy to Koyeb" height="40" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="200">
|
||||
<a href="https://elest.io/open-source/documenso">
|
||||
<img src="https://elest.io/images/logos/deploy-to-elestio-btn.png" alt="Deploy on Elestio" height="40" />
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="200"></td>
|
||||
<td align="center" width="200"></td>
|
||||
</tr>
|
||||
</table>
|
||||
[](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
|
||||
|
||||
#### Render
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
||||
|
||||
#### Koyeb
|
||||
|
||||
[](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
|
||||
|
||||
#### Elestio
|
||||
|
||||
[](https://elest.io/open-source/documenso)
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ Documents created with a team token belong to that team:
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F 'payload={
|
||||
"type": "DOCUMENT",
|
||||
@@ -157,11 +157,11 @@ Retrieve all documents belonging to the team:
|
||||
```bash
|
||||
# List all team documents
|
||||
curl -X GET "https://app.documenso.com/api/v2/envelope" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
|
||||
|
||||
# Filter by status
|
||||
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
|
||||
````
|
||||
|
||||
</Tab>
|
||||
@@ -191,7 +191,7 @@ Templates created with a team token are shared across the team.
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
curl -X POST "https://app.documenso.com/api/v2/template/create" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F 'payload={
|
||||
"title": "NDA Template",
|
||||
@@ -269,7 +269,7 @@ console.log('Created team template:', template.id);
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
curl -X GET "https://app.documenso.com/api/v2/template" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
|
||||
````
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -102,7 +102,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------------------- | -------------------------------------------------------------- | ------------------------- |
|
||||
| `PORT` | Port the application listens on | `3000` |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container — set to the volume-mount path (e.g. `/opt/documenso/cert.p12`). Only Docker Compose defaults this; plain `docker run` must set it explicitly | - |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container | `/opt/documenso/cert.p12` |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for the signing certificate | - |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` |
|
||||
@@ -136,7 +136,6 @@ docker run -d \
|
||||
-e NEXT_PUBLIC_WEBAPP_URL="https://sign.example.com" \
|
||||
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \
|
||||
-e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@db-host:5432/documenso" \
|
||||
-e NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH="/opt/documenso/cert.p12" \
|
||||
-e NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" \
|
||||
-e NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" \
|
||||
-e NEXT_PRIVATE_SMTP_HOST="smtp.example.com" \
|
||||
@@ -155,12 +154,6 @@ A signing certificate is required for document signing. You have two options for
|
||||
- **Volume mount** — mount a `.p12` file from the host into the container at `/opt/documenso/cert.p12` (shown above). This is the simplest approach for small to moderate deployments.
|
||||
- **Base64-encoded contents** — set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` with the base64-encoded certificate string. Use this when file mounting is not available (e.g., Railway, Vercel).
|
||||
|
||||
<Callout type="warn">
|
||||
Plain `docker run` deployments must set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` explicitly. This
|
||||
prevents production deployments from accidentally using the insecure example certificate.
|
||||
Docker Compose sets the file path for you.
|
||||
</Callout>
|
||||
|
||||
For production deployments that require Adobe Approved Trust List recognition, consider using a [Google Cloud HSM](/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm) or another external HSM.
|
||||
|
||||
<Callout type="warn">
|
||||
@@ -185,7 +178,6 @@ NEXT_PUBLIC_WEBAPP_URL=https://sign.example.com
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000
|
||||
NEXT_PRIVATE_DATABASE_URL=postgresql://user:password@db-host:5432/documenso
|
||||
NEXT_PRIVATE_DIRECT_DATABASE_URL=postgresql://user:password@db-host:5432/documenso
|
||||
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password
|
||||
NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth
|
||||
NEXT_PRIVATE_SMTP_HOST=smtp.example.com
|
||||
@@ -211,12 +203,6 @@ docker run -d \
|
||||
|
||||
Documenso provides health check endpoints for monitoring:
|
||||
|
||||
<Callout type="info">
|
||||
If a certificate is mounted but signing fails, ensure `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH`
|
||||
explicitly points to its path inside the container. Production does not use the development
|
||||
example certificate as a fallback.
|
||||
</Callout>
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| ------------------------- | -------------------------------------------------------------- |
|
||||
| `/api/health` | Checks database connectivity and certificate status |
|
||||
|
||||
@@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 24 or later
|
||||
- npm 11.17 or later
|
||||
- Node.js 22 or later
|
||||
- npm 11 or later
|
||||
- PostgreSQL 14 or later
|
||||
- A Linux server (for systemd service setup)
|
||||
|
||||
|
||||
@@ -141,8 +141,8 @@ If building from source (not using Docker images):
|
||||
|
||||
| Requirement | Version |
|
||||
| ----------- | ------- |
|
||||
| Node.js | 24+ |
|
||||
| npm | 11.17+ |
|
||||
| Node.js | 22+ |
|
||||
| npm | 11+ |
|
||||
|
||||
---
|
||||
|
||||
@@ -169,7 +169,7 @@ Documenso runs on:
|
||||
| MySQL/MariaDB | PostgreSQL-specific features required |
|
||||
| SQLite | Not suitable for production workloads |
|
||||
| MongoDB | Relational database required |
|
||||
| Node.js < 24 | Modern JavaScript features required |
|
||||
| Node.js < 22 | Modern JavaScript features required |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ To access the preferences, navigate to either the organisation or teams settings
|
||||
| **Default Recipients** | Recipients that are automatically added to new documents. Can be overridden per document. |
|
||||
| **Default Envelope Expiration** | How long recipients have to sign before the signing link expires. See [recipient expiration](/docs/users/documents/advanced/recipient-expiration). |
|
||||
| **Default Signing Reminders** | When and how often to email recipients who have not yet signed. See [signing reminders](/docs/users/documents/advanced/signing-reminders). |
|
||||
| **Delegate Document Ownership** | By default, documents created with a team API token are owned by the user who created the token. Enable this setting to let supported API requests assign ownership to another team member. |
|
||||
| **Delegate Document Ownership** | Allow team API tokens to delegate document ownership to another team member. |
|
||||
| **AI Features** | Enable AI-powered features such as automatic recipient detection. Only shown if AI features are configured on the instance. |
|
||||
|
||||
Document visibility, language, and signature settings can be overridden per document.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM oven/bun:1 AS dependencies-env
|
||||
COPY . /app
|
||||
|
||||
FROM dependencies-env AS development-dependencies-env
|
||||
COPY ./package.json bun.lockb /app/
|
||||
WORKDIR /app
|
||||
RUN bun i --frozen-lockfile
|
||||
|
||||
FROM dependencies-env AS production-dependencies-env
|
||||
COPY ./package.json bun.lockb /app/
|
||||
WORKDIR /app
|
||||
RUN bun i --production
|
||||
|
||||
FROM dependencies-env AS build-env
|
||||
COPY ./package.json bun.lockb /app/
|
||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||
WORKDIR /app
|
||||
RUN bun run build
|
||||
|
||||
FROM dependencies-env
|
||||
COPY ./package.json bun.lockb /app/
|
||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||
COPY --from=build-env /app/build /app/build
|
||||
WORKDIR /app
|
||||
CMD ["bun", "run", "start"]
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM node:20-alpine AS dependencies-env
|
||||
RUN npm i -g pnpm
|
||||
COPY . /app
|
||||
|
||||
FROM dependencies-env AS development-dependencies-env
|
||||
COPY ./package.json pnpm-lock.yaml /app/
|
||||
WORKDIR /app
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
FROM dependencies-env AS production-dependencies-env
|
||||
COPY ./package.json pnpm-lock.yaml /app/
|
||||
WORKDIR /app
|
||||
RUN pnpm i --prod --frozen-lockfile
|
||||
|
||||
FROM dependencies-env AS build-env
|
||||
COPY ./package.json pnpm-lock.yaml /app/
|
||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||
WORKDIR /app
|
||||
RUN pnpm build
|
||||
|
||||
FROM dependencies-env
|
||||
COPY ./package.json pnpm-lock.yaml /app/
|
||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||
COPY --from=build-env /app/build /app/build
|
||||
WORKDIR /app
|
||||
CMD ["pnpm", "start"]
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
@@ -72,7 +71,6 @@ export const EnvelopeDistributeDialog = ({
|
||||
const { toast } = useToast();
|
||||
const { t, i18n } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
@@ -202,12 +200,6 @@ export const EnvelopeDistributeDialog = ({
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'editor',
|
||||
location: 'distribute_document',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
const errorMessage = getDistributeErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
@@ -52,7 +51,6 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t, i18n } = useLingui();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
@@ -97,13 +95,6 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'editor',
|
||||
location: 'redistribute_document',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
const errorMessage = getDistributeErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
@@ -20,9 +17,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { File as FileIcon, Upload, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
@@ -34,8 +29,6 @@ const ZBulkSendFormSchema = z.object({
|
||||
|
||||
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
|
||||
|
||||
type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string };
|
||||
|
||||
export type TemplateBulkSendDialogProps = {
|
||||
templateId: number;
|
||||
recipients: Array<{ email: string; name?: string | null }>;
|
||||
@@ -49,9 +42,6 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [validationError, setValidationError] = useState<TBulkSendValidationError | null>(null);
|
||||
|
||||
const form = useForm<TBulkSendFormSchema>({
|
||||
resolver: zodResolver(ZBulkSendFormSchema),
|
||||
defaultValues: {
|
||||
@@ -61,20 +51,6 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
|
||||
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
|
||||
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (form.formState.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(value);
|
||||
|
||||
if (!value) {
|
||||
setValidationError(null);
|
||||
|
||||
form.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const onDownloadTemplate = () => {
|
||||
const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]);
|
||||
|
||||
@@ -95,44 +71,36 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
};
|
||||
|
||||
const onSubmit = async (values: TBulkSendFormSchema) => {
|
||||
setValidationError(null);
|
||||
|
||||
try {
|
||||
const csv = await values.file.text();
|
||||
|
||||
const result = await uploadBulkSend({
|
||||
await uploadBulkSend({
|
||||
templateId,
|
||||
teamId: team?.id,
|
||||
csv: csv,
|
||||
sendImmediately: values.sendImmediately,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setValidationError(result.error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="outline" className="shrink-0" size="sm">
|
||||
@@ -206,10 +174,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
setValidationError(null);
|
||||
|
||||
onChange(file);
|
||||
}
|
||||
}}
|
||||
@@ -230,11 +195,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
type="button"
|
||||
variant="link"
|
||||
className="p-0 text-destructive text-xs hover:text-destructive"
|
||||
onClick={() => {
|
||||
setValidationError(null);
|
||||
|
||||
form.resetField('file');
|
||||
}}
|
||||
onClick={() => onChange(null)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -257,67 +218,6 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
)}
|
||||
/>
|
||||
|
||||
{validationError !== null && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="max-h-32 overflow-y-auto">
|
||||
{match(validationError)
|
||||
.with({ type: 'PARSE_ERROR' }, () => (
|
||||
<Trans>The CSV could not be parsed. Please check the file format and try again.</Trans>
|
||||
))
|
||||
.with({ type: 'EMPTY' }, () => (
|
||||
<Trans>
|
||||
The CSV does not contain any rows. Please add at least one row of recipient details.
|
||||
</Trans>
|
||||
))
|
||||
.with({ type: 'ROW_LIMIT_EXCEEDED' }, ({ rowCount, maxRows }) => (
|
||||
<Trans>
|
||||
The CSV contains {rowCount} rows. A maximum of {maxRows} rows is allowed per upload.
|
||||
</Trans>
|
||||
))
|
||||
.with({ type: 'MISSING_COLUMNS' }, ({ missingColumns }) => (
|
||||
<>
|
||||
<Trans>
|
||||
The CSV is missing the following required columns. Please download the template CSV for the
|
||||
correct format.
|
||||
</Trans>
|
||||
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{missingColumns.map((column) => (
|
||||
<li key={column} className="font-mono">
|
||||
{column}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
))
|
||||
.with({ type: 'INVALID_RECIPIENTS' }, ({ rowErrors }) => (
|
||||
<>
|
||||
<Trans>The CSV contains invalid recipient emails. Please fix the following rows:</Trans>
|
||||
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{rowErrors.map((rowError, index) => (
|
||||
<li key={index}>
|
||||
<Trans>
|
||||
Row {rowError.row}: <span className="font-mono">{rowError.column}</span> must be a valid
|
||||
email or empty
|
||||
</Trans>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
))
|
||||
.with({ type: 'UPLOAD_ERROR' }, ({ code }) =>
|
||||
code === AppErrorCode.LIMIT_EXCEEDED ? (
|
||||
<Trans>The CSV exceeds the maximum file size.</Trans>
|
||||
) : (
|
||||
<Trans>Failed to upload CSV. Please check the file format and try again.</Trans>
|
||||
),
|
||||
)
|
||||
.exhaustive()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sendImmediately"
|
||||
@@ -340,12 +240,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
type="button"
|
||||
>
|
||||
<Button variant="secondary" onClick={() => form.reset()} type="button">
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
@@ -78,7 +77,6 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
}: EmbedDirectTemplateClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
@@ -266,13 +264,6 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
const error = AppError.parseError(err);
|
||||
const errorMessage = getDirectTemplateErrorMessage(error.code);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'direct_template',
|
||||
recipientId: recipient.id,
|
||||
envelopeId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(errorMessage.title),
|
||||
description: _(errorMessage.description),
|
||||
@@ -317,14 +308,6 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'embed_init',
|
||||
recipientId: recipient.id,
|
||||
envelopeId,
|
||||
});
|
||||
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
@@ -76,7 +75,6 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
}: EmbedSignDocumentV1ClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext();
|
||||
|
||||
@@ -156,14 +154,6 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
|
||||
setHasCompletedDocument(true);
|
||||
} catch (err) {
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'complete_document',
|
||||
recipientId: recipient.id,
|
||||
documentId,
|
||||
envelopeId,
|
||||
});
|
||||
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
@@ -246,15 +236,6 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'embed_init',
|
||||
recipientId: recipient.id,
|
||||
documentId,
|
||||
envelopeId,
|
||||
});
|
||||
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
@@ -26,7 +25,6 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
allowWhitelabelling = false,
|
||||
}: EmbedSignDocumentV2ClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
@@ -172,14 +170,6 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'embed_init',
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
@@ -58,7 +57,6 @@ export const MultiSignDocumentSigningView = ({
|
||||
}: MultiSignDocumentSigningViewProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
||||
|
||||
@@ -103,13 +101,6 @@ export const MultiSignDocumentSigningView = ({
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'sign_field',
|
||||
recipientId,
|
||||
documentId: document?.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
@@ -129,13 +120,6 @@ export const MultiSignDocumentSigningView = ({
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'remove_field',
|
||||
recipientId,
|
||||
documentId: document?.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,13 +140,6 @@ export const MultiSignDocumentSigningView = ({
|
||||
recipientId,
|
||||
});
|
||||
} catch (err) {
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'complete_document',
|
||||
recipientId,
|
||||
documentId: document?.id,
|
||||
});
|
||||
|
||||
onDocumentError?.();
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ZAnalyticsPeriodSchema } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
const DEFAULT_PERIOD = 'month';
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ value: 'week', label: msg`This week` },
|
||||
{ value: 'month', label: msg`This month` },
|
||||
{ value: 'quarter', label: msg`This quarter` },
|
||||
{ value: 'year', label: msg`This year` },
|
||||
{ value: 'lastMonth', label: msg`Last month` },
|
||||
{ value: 'last7Days', label: msg`Last 7 days` },
|
||||
{ value: 'last30Days', label: msg`Last 30 days` },
|
||||
] as const;
|
||||
|
||||
export const AnalyticsPeriodSelector = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const period = useMemo(() => {
|
||||
const parsed = ZAnalyticsPeriodSchema.safeParse(searchParams?.get('period') ?? DEFAULT_PERIOD);
|
||||
|
||||
return parsed.success ? parsed.data : DEFAULT_PERIOD;
|
||||
}, [searchParams]);
|
||||
|
||||
const onPeriodChange = (newPeriod: string) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('period', newPeriod);
|
||||
|
||||
if (newPeriod === DEFAULT_PERIOD) {
|
||||
params.delete('period');
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={period} onValueChange={onPeriodChange}>
|
||||
<SelectTrigger className="max-w-[200px] text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{PERIOD_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{_(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -44,7 +46,7 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
const links = [
|
||||
{
|
||||
href: `/t/${teamUrl}/documents`,
|
||||
label: msg`Documents`,
|
||||
@@ -54,6 +56,19 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
|
||||
label: msg`Templates`,
|
||||
},
|
||||
];
|
||||
|
||||
if (
|
||||
currentTeam &&
|
||||
IS_TEAM_ANALYTICS_ENABLED() &&
|
||||
canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole)
|
||||
) {
|
||||
links.push({
|
||||
href: `/t/${currentTeam.url}/analytics`,
|
||||
label: msg`Analytics`,
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
}, [currentTeam, organisations]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import LogoImage from '@documenso/assets/logo.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||
@@ -56,7 +58,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
const links = [
|
||||
{
|
||||
href: `/t/${teamUrl}/documents`,
|
||||
text: t`Documents`,
|
||||
@@ -65,6 +67,20 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
href: `/t/${teamUrl}/templates`,
|
||||
text: t`Templates`,
|
||||
},
|
||||
];
|
||||
|
||||
if (
|
||||
currentTeam &&
|
||||
IS_TEAM_ANALYTICS_ENABLED() &&
|
||||
canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole)
|
||||
) {
|
||||
links.push({
|
||||
href: `/t/${currentTeam.url}/analytics`,
|
||||
text: t`Analytics`,
|
||||
});
|
||||
}
|
||||
|
||||
links.push(
|
||||
{
|
||||
href: '/inbox',
|
||||
text: t`Inbox`,
|
||||
@@ -73,7 +89,9 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
href: '/settings/profile',
|
||||
text: t`Settings`,
|
||||
},
|
||||
];
|
||||
);
|
||||
|
||||
return links;
|
||||
}, [currentTeam, organisations]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { TTemplate } from '@documenso/lib/types/template';
|
||||
@@ -42,7 +41,6 @@ export const DirectTemplatePageView = ({
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { email, fullName, setEmail } = useRequiredDocumentSigningContext();
|
||||
const { recipient, setRecipient } = useRequiredDocumentSigningAuthContext();
|
||||
@@ -126,13 +124,6 @@ export const DirectTemplatePageView = ({
|
||||
const error = AppError.parseError(err);
|
||||
const errorMessage = getDirectTemplateErrorMessage(error.code);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'direct_template',
|
||||
recipientId: directTemplateRecipient.id,
|
||||
envelopeId: template.envelopeId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(errorMessage.title),
|
||||
description: _(errorMessage.description),
|
||||
|
||||
-23
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -40,7 +39,6 @@ export const DocumentSigningCheckboxField = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -128,13 +126,6 @@ export const DocumentSigningCheckboxField = ({
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -166,13 +157,6 @@ export const DocumentSigningCheckboxField = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
@@ -232,13 +216,6 @@ export const DocumentSigningCheckboxField = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while updating the signature.`),
|
||||
|
||||
-7
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { type TRecipientAccessAuth, ZDocumentAccessAuthSchema } from '@documenso/lib/types/document-auth';
|
||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
@@ -89,7 +88,6 @@ export const DocumentSigningCompleteDialog = ({
|
||||
position,
|
||||
disableNameInput = false,
|
||||
}: DocumentSigningCompleteDialogProps) => {
|
||||
const analytics = useAnalytics();
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -181,11 +179,6 @@ export const DocumentSigningCompleteDialog = ({
|
||||
return;
|
||||
}
|
||||
|
||||
analytics.captureException(error, {
|
||||
source: 'signing',
|
||||
location: 'complete_document',
|
||||
});
|
||||
|
||||
// This dialog owns the completion error toast for every signing surface
|
||||
// so the user gets a specific, actionable message. Callers should run
|
||||
// their own side effects (e.g. embeds posting document-error) and
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { convertToLocalSystemFormat, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
@@ -40,7 +39,6 @@ export const DocumentSigningDateField = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -87,13 +85,6 @@ export const DocumentSigningDateField = ({
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -122,13 +113,6 @@ export const DocumentSigningDateField = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
|
||||
-16
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -36,7 +35,6 @@ export const DocumentSigningDropdownField = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -88,13 +86,6 @@ export const DocumentSigningDropdownField = ({
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -129,13 +120,6 @@ export const DocumentSigningDropdownField = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -34,7 +33,6 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { email: providedEmail } = useRequiredDocumentSigningContext();
|
||||
|
||||
@@ -80,13 +78,6 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -115,13 +106,6 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
|
||||
-16
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -39,7 +38,6 @@ export const DocumentSigningInitialsField = ({
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { fullName } = useRequiredDocumentSigningContext();
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
@@ -86,13 +84,6 @@ export const DocumentSigningInitialsField = ({
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -121,13 +112,6 @@ export const DocumentSigningInitialsField = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -40,7 +39,6 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredDocumentSigningContext();
|
||||
|
||||
@@ -118,13 +116,6 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -153,13 +144,6 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -48,7 +47,6 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -144,13 +142,6 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -202,13 +193,6 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
@@ -82,6 +83,8 @@ export const DocumentSigningPageViewV1 = ({
|
||||
? authUser.twoFactorEnabled && authUser.email === recipient.email
|
||||
: false;
|
||||
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
@@ -115,6 +118,12 @@ export const DocumentSigningPageViewV1 = ({
|
||||
|
||||
await completeDocumentWithToken(payload);
|
||||
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: document.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (documentMeta?.redirectUrl) {
|
||||
window.location.href = documentMeta.redirectUrl;
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -32,7 +31,6 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -93,13 +91,6 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -129,13 +120,6 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the selection.`),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -42,7 +41,6 @@ export function DocumentSigningRejectDialog({
|
||||
}: DocumentSigningRejectDialogProps) {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -78,12 +76,6 @@ export function DocumentSigningRejectDialog({
|
||||
window.location.href = `/sign/${token}/rejected`;
|
||||
}
|
||||
} catch (err) {
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'reject_document',
|
||||
documentId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while rejecting the document. Please try again.`,
|
||||
|
||||
-16
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -48,7 +47,6 @@ export const DocumentSigningSignatureField = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -159,13 +157,6 @@ export const DocumentSigningSignatureField = ({
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
@@ -192,13 +183,6 @@ export const DocumentSigningSignatureField = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -51,7 +50,6 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -172,13 +170,6 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: isAssistantMode
|
||||
@@ -209,13 +200,6 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'remove_field',
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
@@ -37,6 +38,7 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const userTimezone =
|
||||
@@ -101,6 +103,12 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
|
||||
description: _(msg`Your document has been uploaded successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
analytics.capture('App: Document Uploaded', {
|
||||
userId: user.id,
|
||||
documentId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Handle legacy template creation.
|
||||
|
||||
-15
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
@@ -38,12 +37,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
|
||||
|
||||
/** How far past a resize handle you can still grab it, in screen pixels. */
|
||||
const TRANSFORMER_ANCHOR_HIT_STROKE_PX = 24;
|
||||
|
||||
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const analytics = useAnalytics();
|
||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||
|
||||
@@ -281,13 +276,6 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
unsafeRenderFieldOnLayer(field);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'editor',
|
||||
location: 'envelope_page_render',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
setRenderError(true);
|
||||
}
|
||||
};
|
||||
@@ -362,9 +350,6 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
shouldOverdrawWholeArea: true,
|
||||
ignoreStroke: true,
|
||||
flipEnabled: false,
|
||||
anchorStyleFunc: (anchor) => {
|
||||
anchor.hitStrokeWidth(TRANSFORMER_ANCHOR_HIT_STROKE_PX / scale);
|
||||
},
|
||||
boundBoxFunc: (oldBox, newBox) => {
|
||||
// Enforce minimum size
|
||||
if (newBox.width < 30 || newBox.height < 20) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
@@ -46,7 +45,6 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const {
|
||||
envelope,
|
||||
@@ -215,12 +213,6 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
const { data } = await createPromise.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
analytics.captureException(error, {
|
||||
source: isEmbedded ? 'embed' : 'editor',
|
||||
location: 'create_envelope_items',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
// Set error state on files in batch upload.
|
||||
setLocalFiles((prev) =>
|
||||
prev.map((uploadingFile) =>
|
||||
@@ -298,12 +290,6 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
analytics.captureException(error, {
|
||||
source: isEmbedded ? 'embed' : 'editor',
|
||||
location: 'replace_pdf',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Replace failed`,
|
||||
description: t`Something went wrong while replacing the PDF`,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import {
|
||||
type PageRenderData,
|
||||
@@ -19,7 +18,6 @@ type GenericLocalField = TEnvelope['fields'][number] & {
|
||||
|
||||
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { i18n } = useLingui();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const {
|
||||
envelopeStatus,
|
||||
@@ -116,13 +114,6 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
||||
unsafeRenderFieldOnLayer(field);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'editor',
|
||||
location: 'envelope_page_render',
|
||||
envelopeId: currentEnvelopeItem?.envelopeId,
|
||||
});
|
||||
|
||||
setRenderError(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import {
|
||||
type PageRenderData,
|
||||
@@ -54,7 +53,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const {
|
||||
envelopeData,
|
||||
@@ -428,14 +426,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'page_render',
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
setRenderError(true);
|
||||
}
|
||||
};
|
||||
@@ -517,14 +507,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'sign_field',
|
||||
fieldType: payload.type,
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while signing the field.`,
|
||||
|
||||
+6
-14
@@ -118,6 +118,12 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
title: t`Document already signed`,
|
||||
description: t`This document was already signed and no further action was taken.`,
|
||||
});
|
||||
} else {
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: envelope.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (onDocumentCompleted) {
|
||||
@@ -142,13 +148,6 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'complete_document',
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
onDocumentError?.();
|
||||
}
|
||||
|
||||
@@ -222,13 +221,6 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
} catch (err) {
|
||||
console.log('err', err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'signing',
|
||||
location: 'complete_document_next_signer',
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
onDocumentError?.();
|
||||
|
||||
// Rethrow so DocumentSigningCompleteDialog can toast a specific
|
||||
|
||||
@@ -93,6 +93,14 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
if (type === EnvelopeType.DOCUMENT) {
|
||||
analytics.capture('App: Document Uploaded', {
|
||||
userId: user.id,
|
||||
documentId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const pathPrefix = type === EnvelopeType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
||||
|
||||
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
||||
@@ -101,11 +109,6 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'editor',
|
||||
location: 'upload_document',
|
||||
});
|
||||
|
||||
const errorMessage = getUploadErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
@@ -35,7 +34,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
@@ -114,11 +112,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'editor',
|
||||
location: 'upload_document',
|
||||
});
|
||||
|
||||
const errorMessage = getUploadErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -69,7 +68,6 @@ export default function PDFViewer({
|
||||
}: PDFViewerProps) {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -152,11 +150,6 @@ export default function PDFViewer({
|
||||
console.error(err);
|
||||
setLoadingState('error');
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'pdf_viewer',
|
||||
location: 'pdf_load',
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while loading the document.`,
|
||||
@@ -373,8 +366,6 @@ const PdfViewerPage = ({
|
||||
* Manages rendering a page from a pdf.
|
||||
*/
|
||||
const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }: PdfViewerPageProps) => {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [imageLoadingState, setImageLoadingState] = useState<ImageLoadingState>('loading');
|
||||
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
@@ -466,12 +457,6 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
|
||||
|
||||
if (!isCancelled) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'pdf_viewer',
|
||||
location: 'pdf_page_render',
|
||||
});
|
||||
|
||||
setImageLoadingState('error');
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -18,23 +18,6 @@ import './utils/polyfills/promise-with-resolvers';
|
||||
* the page early, leaving dead event handlers (broken dropdowns, native form
|
||||
* submits).
|
||||
*/
|
||||
/**
|
||||
* Signing and direct template URLs contain recipient tokens which must never
|
||||
* be sent to PostHog. Recipient context is attached explicitly via
|
||||
* `recipientId` where needed instead.
|
||||
*/
|
||||
const redactTokensFromUrl = (value: string) => {
|
||||
return value.replace(/(\/(?:sign|d|direct)\/)([^/?#]+)/g, '$1:token');
|
||||
};
|
||||
|
||||
const URL_EVENT_PROPERTIES = [
|
||||
'$current_url',
|
||||
'$pathname',
|
||||
'$referrer',
|
||||
'$initial_referrer',
|
||||
'$prev_pageview_pathname',
|
||||
] as const;
|
||||
|
||||
function initPosthog() {
|
||||
const postHogConfig = extractPostHogConfig();
|
||||
|
||||
@@ -42,34 +25,7 @@ function initPosthog() {
|
||||
void import('posthog-js').then(({ default: posthog }) => {
|
||||
posthog.init(postHogConfig.key, {
|
||||
api_host: postHogConfig.host,
|
||||
// Only create person profiles for identified (authenticated) users,
|
||||
// anonymous recipients on signing pages stay anonymous.
|
||||
person_profiles: 'identified_only',
|
||||
// Explicit events only, autocapture on signing pages blows up usage
|
||||
// without providing actionable data.
|
||||
autocapture: false,
|
||||
capture_pageview: true,
|
||||
capture_pageleave: false,
|
||||
capture_exceptions: true,
|
||||
before_send: (event) => {
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const property of URL_EVENT_PROPERTIES) {
|
||||
const value = event.properties?.[property];
|
||||
|
||||
if (typeof value === 'string') {
|
||||
event.properties[property] = redactTokensFromUrl(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.$set_once && typeof event.$set_once['$initial_current_url'] === 'string') {
|
||||
event.$set_once['$initial_current_url'] = redactTokensFromUrl(event.$set_once['$initial_current_url']);
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,11 +7,10 @@ import { createReadableStreamFromReadable } from '@react-router/node';
|
||||
import { isbot } from 'isbot';
|
||||
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
|
||||
import { renderToPipeableStream } from 'react-dom/server';
|
||||
import type { EntryContext, RouterContextProvider } from 'react-router';
|
||||
import type { AppLoadContext, EntryContext } from 'react-router';
|
||||
import { ServerRouter } from 'react-router';
|
||||
|
||||
import { langCookie } from './storage/lang-cookie.server';
|
||||
import { nonceContext } from './utils/nonce';
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
|
||||
@@ -20,7 +19,7 @@ export default async function handleRequest(
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
loadContext: RouterContextProvider,
|
||||
loadContext: AppLoadContext,
|
||||
) {
|
||||
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
|
||||
|
||||
@@ -34,7 +33,7 @@ export default async function handleRequest(
|
||||
// scripts it injects (route manifest, hydration data, module preloads).
|
||||
// The same nonce is also exposed to the React tree via the root loader so
|
||||
// our own inline scripts/styles can carry it.
|
||||
const nonce = loadContext.get(nonceContext) || undefined;
|
||||
const nonce = loadContext.nonce || undefined;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||
import { type MiddlewareFunction, redirect } from 'react-router';
|
||||
|
||||
export const adminMiddleware: MiddlewareFunction = async ({ request }, next) => {
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
if (!user || !isAdmin(user)) {
|
||||
throw redirect('/');
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { MiddlewareFunction } from 'react-router';
|
||||
|
||||
import { getRequestNonce } from '../../server/load-context';
|
||||
import { nonceContext } from '../utils/nonce';
|
||||
|
||||
export const nonceMiddleware: MiddlewareFunction = ({ context }) => {
|
||||
context.set(nonceContext, getRequestNonce());
|
||||
};
|
||||
+2
-15
@@ -1,5 +1,4 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
||||
import { getBasePath } from '@documenso/lib/constants/app';
|
||||
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||
@@ -10,7 +9,6 @@ import { getOrganisationSession } from '@documenso/trpc/server/organisation-rout
|
||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
data,
|
||||
isRouteErrorResponse,
|
||||
@@ -23,16 +21,13 @@ import {
|
||||
useMatches,
|
||||
} from 'react-router';
|
||||
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
||||
import { nonceMiddleware } from '~/middleware/nonce';
|
||||
import type { Route } from './+types/root';
|
||||
import stylesheet from './app.css?url';
|
||||
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
||||
import { langCookie } from './storage/lang-cookie.server';
|
||||
import { themeSessionResolver } from './storage/theme-session.server';
|
||||
import { appMetaTags } from './utils/meta';
|
||||
import { nonce, nonceContext } from './utils/nonce';
|
||||
|
||||
export const middleware = [nonceMiddleware];
|
||||
import { nonce } from './utils/nonce';
|
||||
|
||||
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
|
||||
|
||||
@@ -77,7 +72,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
|
||||
// SSR-rendered <script>/<style> elements in this layout (and child
|
||||
// routes that need it) can carry the matching nonce attribute.
|
||||
nonce: context.get(nonceContext),
|
||||
nonce: context.nonce,
|
||||
session: session.isAuthenticated
|
||||
? {
|
||||
user: session.user,
|
||||
@@ -205,19 +200,11 @@ export default function App() {
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||
|
||||
if (errorCode !== 404) {
|
||||
console.error('[RootErrorBoundary]', error);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (errorCode !== 404) {
|
||||
analytics.captureException(error, { source: 'app', location: 'root_boundary' });
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return <GenericErrorLayout errorCode={errorCode} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
|
||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -20,18 +20,16 @@ import {
|
||||
import { Link, Outlet, redirect, useLocation } from 'react-router';
|
||||
|
||||
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
|
||||
import { adminMiddleware } from '~/middleware/admin';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Admin`);
|
||||
}
|
||||
|
||||
export const middleware = [adminMiddleware];
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { user } = await getOptionalSession(request);
|
||||
const { user } = await getSession(request);
|
||||
|
||||
const license = await LicenseClient.getInstance()?.getCachedLicense();
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { canExecuteTeamAction, formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZAnalyticsPeriodSchema } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { parseAsStringLiteral, useQueryStates } from 'nuqs';
|
||||
import { useMemo } from 'react';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
import { AnalyticsPeriodSelector } from '~/components/general/analytics-period-selector';
|
||||
import { CardMetric } from '~/components/general/metric-card';
|
||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/analytics._index';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Analytics`);
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
// Behind a rollout flag: silently send everyone back to documents when off.
|
||||
if (!IS_TEAM_ANALYTICS_ENABLED()) {
|
||||
throw redirect(formatDocumentsPath(params.teamUrl));
|
||||
}
|
||||
|
||||
const session = await getSession(request);
|
||||
|
||||
const team = await getTeamByUrl({
|
||||
userId: session.user.id,
|
||||
teamUrl: params.teamUrl,
|
||||
});
|
||||
|
||||
// Admins and managers only. Members are silently redirected (no existence leak).
|
||||
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
|
||||
throw redirect(formatDocumentsPath(params.teamUrl));
|
||||
}
|
||||
}
|
||||
|
||||
export default function TeamAnalyticsPage() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [{ period, senderIds }] = useQueryStates({
|
||||
period: parseAsStringLiteral(ZAnalyticsPeriodSchema.options),
|
||||
senderIds: documentsSearchParams.senderIds,
|
||||
});
|
||||
|
||||
const timezone = useMemo(() => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { data, isLoading } = trpc.team.getAnalytics.useQuery({
|
||||
teamId: team.id,
|
||||
period: period ?? undefined,
|
||||
timezone,
|
||||
senderIds: senderIds ?? [],
|
||||
});
|
||||
|
||||
const analytics = data ?? {
|
||||
sent: 0,
|
||||
draft: 0,
|
||||
pending: 0,
|
||||
completed: 0,
|
||||
declined: 0,
|
||||
};
|
||||
|
||||
const hasActivity =
|
||||
analytics.sent > 0 ||
|
||||
analytics.draft > 0 ||
|
||||
analytics.pending > 0 ||
|
||||
analytics.completed > 0 ||
|
||||
analytics.declined > 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<div className="flex flex-row items-center">
|
||||
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Analytics</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap items-center gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<DocumentsTableSenderFilter teamId={team.id} />
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<AnalyticsPeriodSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{isLoading ? (
|
||||
<SpinnerBox className="py-32" />
|
||||
) : hasActivity ? (
|
||||
<div data-testid="team-analytics-content">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div data-testid="metric-sent" className="contents">
|
||||
<CardMetric title={_(msg`Documents Sent`)} value={analytics.sent} />
|
||||
</div>
|
||||
<div data-testid="metric-completed-headline" className="contents">
|
||||
<CardMetric title={_(msg`Completed`)} value={analytics.completed} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<CardMetric title={_(msg`Draft`)} value={analytics.draft} />
|
||||
<CardMetric title={_(msg`Pending`)} value={analytics.pending} />
|
||||
<CardMetric title={_(msg`Completed`)} value={analytics.completed} />
|
||||
<CardMetric title={_(msg`Declined`)} value={analytics.declined} />
|
||||
</div>
|
||||
|
||||
<p className="mt-3 max-w-3xl text-muted-foreground text-xs">
|
||||
<Trans>
|
||||
Each tile counts documents that entered that state during the selected period, on its own date. They are
|
||||
independent activity counts and do not add up to Documents Sent.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="team-analytics-empty"
|
||||
className="flex flex-col items-center justify-center rounded-lg border border-border border-dashed py-20 text-center"
|
||||
>
|
||||
<h3 className="font-semibold text-foreground text-lg">
|
||||
<Trans>No analytics to show yet</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="mt-2 max-w-md text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
There's no document activity for the selected period. Send your first document to start tracking your
|
||||
team's usage here.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild className="mt-6">
|
||||
<Link to={formatDocumentsPath(team.url)}>
|
||||
<Trans>Send a document</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
@@ -8,7 +7,6 @@ import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { ChevronLeftIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import type { ShouldRevalidateFunctionArgs } from 'react-router';
|
||||
import { isRouteErrorResponse, Link, Outlet, redirect } from 'react-router';
|
||||
|
||||
@@ -68,18 +66,8 @@ export default function DocumentsLayout() {
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||
|
||||
useEffect(() => {
|
||||
analytics.captureException(error, {
|
||||
source: 'editor',
|
||||
location: 'editor_layout_boundary',
|
||||
envelopeId: params.id,
|
||||
});
|
||||
}, [error]);
|
||||
|
||||
const errorCodeMap = {
|
||||
404: {
|
||||
subHeading: msg`404 Document not found`,
|
||||
|
||||
@@ -68,20 +68,6 @@ export default function ApiTokensPage() {
|
||||
return i18n.date(row.original.expires);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t`Last Used`,
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.lastUsedAt) {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>Never</Trans>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-foreground">{i18n.date(row.original.lastUsedAt)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t`Actions`,
|
||||
cell: ({ row }) => (
|
||||
@@ -160,9 +146,6 @@ export default function ApiTokensPage() {
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
@@ -9,7 +8,6 @@ import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { ChevronLeftIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { isRouteErrorResponse, Link, Outlet, redirect, type ShouldRevalidateFunctionArgs } from 'react-router';
|
||||
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
@@ -86,18 +84,8 @@ export default function TemplatesLayout() {
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||
|
||||
useEffect(() => {
|
||||
analytics.captureException(error, {
|
||||
source: 'editor',
|
||||
location: 'editor_layout_boundary',
|
||||
envelopeId: params.id,
|
||||
});
|
||||
}, [error]);
|
||||
|
||||
const errorCodeMap = {
|
||||
404: {
|
||||
subHeading: msg`404 Template not found`,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -6,7 +5,6 @@ import { i18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { isRouteErrorResponse, Link, Outlet } from 'react-router';
|
||||
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
@@ -50,14 +48,8 @@ export default function RecipientLayout({ matches }: Route.ComponentProps) {
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||
|
||||
useEffect(() => {
|
||||
analytics.captureException(error, { source: 'signing', location: 'recipient_layout_boundary' });
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
errorCode={errorCode}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
|
||||
|
||||
export const loader = async () => {
|
||||
export const loader = () => {
|
||||
try {
|
||||
const certStatus = await getCertificateStatus();
|
||||
const certStatus = getCertificateStatus();
|
||||
|
||||
return Response.json({
|
||||
isAvailable: certStatus.isAvailable,
|
||||
|
||||
@@ -22,7 +22,7 @@ export const loader = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const certStatus = await getCertificateStatus();
|
||||
const certStatus = getCertificateStatus();
|
||||
|
||||
if (certStatus.isAvailable) {
|
||||
checks.certificate = { status: 'ok' };
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import {
|
||||
IS_GOOGLE_SSO_ENABLED,
|
||||
IS_MICROSOFT_SSO_ENABLED,
|
||||
@@ -6,7 +5,6 @@ import {
|
||||
OIDC_PROVIDER_LABEL,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useEffect } from 'react';
|
||||
import { isRouteErrorResponse, Outlet, useRouteError } from 'react-router';
|
||||
|
||||
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
|
||||
@@ -49,30 +47,10 @@ export default function Layout() {
|
||||
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
|
||||
|
||||
const analytics = useAnalytics();
|
||||
const error = useRouteError();
|
||||
|
||||
console.log({ routeError: error });
|
||||
|
||||
useEffect(() => {
|
||||
const isExpectedEmbedResponse =
|
||||
isRouteErrorResponse(error) &&
|
||||
[
|
||||
'embed-authentication-required',
|
||||
'embed-paywall',
|
||||
'embed-waiting-for-turn',
|
||||
'embed-recipient-expired',
|
||||
'embed-document-rejected',
|
||||
'embed-document-completed',
|
||||
].includes(error.data?.type);
|
||||
|
||||
if (isExpectedEmbedResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
analytics.captureException(error, { source: 'embed', location: 'embed_layout_boundary' });
|
||||
}, [error]);
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
|
||||
return (
|
||||
|
||||
@@ -2,13 +2,11 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
|
||||
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
|
||||
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
|
||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { data } from 'react-router';
|
||||
@@ -95,30 +93,6 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
||||
|
||||
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
||||
|
||||
fireAndForget(async () => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: template.teamId,
|
||||
},
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
captureServerEvent({
|
||||
event: 'App: Embed Session Started',
|
||||
userId: user?.id,
|
||||
organisationId: team?.organisationId,
|
||||
teamId: template.teamId,
|
||||
properties: {
|
||||
type: 'signing',
|
||||
version: 'v0',
|
||||
recipientId: recipient.id,
|
||||
envelopeId: template.envelopeId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
@@ -220,30 +194,6 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
fireAndForget(async () => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: envelope.teamId,
|
||||
},
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
captureServerEvent({
|
||||
event: 'App: Embed Session Started',
|
||||
userId: user?.id,
|
||||
organisationId: team?.organisationId,
|
||||
teamId: envelope.teamId,
|
||||
properties: {
|
||||
type: 'signing',
|
||||
version: 'v0',
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
||||
@@ -14,7 +13,6 @@ import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
|
||||
@@ -141,30 +139,6 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
||||
})
|
||||
: [];
|
||||
|
||||
fireAndForget(async () => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: document.teamId,
|
||||
},
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
captureServerEvent({
|
||||
event: 'App: Embed Session Started',
|
||||
userId: user?.id,
|
||||
organisationId: team?.organisationId,
|
||||
teamId: document.teamId,
|
||||
properties: {
|
||||
type: 'signing',
|
||||
version: 'v0',
|
||||
recipientId: recipient.id,
|
||||
envelopeId: document.envelopeId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
@@ -298,30 +272,6 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||
}).catch(() => null);
|
||||
|
||||
fireAndForget(async () => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: envelope.teamId,
|
||||
},
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
captureServerEvent({
|
||||
event: 'App: Embed Session Started',
|
||||
userId: user?.id,
|
||||
organisationId: team?.organisationId,
|
||||
teamId: envelope.teamId,
|
||||
properties: {
|
||||
type: 'signing',
|
||||
version: 'v0',
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||
import { ZBaseEmbedAuthoringSchema } from '@documenso/lib/types/embed-authoring-base-schema';
|
||||
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@@ -38,40 +35,6 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
});
|
||||
|
||||
allowEmbedAuthoringWhiteLabel = organisationClaim.flags.embedAuthoringWhiteLabel ?? false;
|
||||
|
||||
// Derive the kind of authoring session from the child route path, e.g.
|
||||
// /embed/v1/authoring/document/create -> resource 'document', mode 'create'.
|
||||
// Completed and error pages are not new sessions and are skipped.
|
||||
const [resource, mode] = url.pathname.split('/').filter(Boolean).slice(3);
|
||||
|
||||
const isAuthoringSession =
|
||||
(resource === 'document' || resource === 'template') && (mode === 'create' || mode === 'edit');
|
||||
|
||||
if (isAuthoringSession) {
|
||||
fireAndForget(async () => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: result.teamId,
|
||||
},
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
captureServerEvent({
|
||||
event: 'App: Embed Session Started',
|
||||
userId: result.userId,
|
||||
organisationId: team?.organisationId,
|
||||
teamId: result.teamId,
|
||||
properties: {
|
||||
type: 'authoring',
|
||||
version: 'v1',
|
||||
resource,
|
||||
mode,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
||||
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
@@ -45,15 +42,6 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
const firstDocument = envelopes[0]?.document;
|
||||
|
||||
if (!firstDocument) {
|
||||
captureServerEvent({
|
||||
event: 'App: Embed Session Started',
|
||||
userId: user?.id,
|
||||
properties: {
|
||||
type: 'signing',
|
||||
version: 'v1',
|
||||
},
|
||||
});
|
||||
|
||||
return superLoaderJson({
|
||||
envelopes,
|
||||
user,
|
||||
@@ -67,29 +55,6 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
const allowWhitelabelling = organisationClaim.flags.embedSigningWhiteLabel;
|
||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||
|
||||
fireAndForget(async () => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: firstDocument.teamId,
|
||||
},
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
captureServerEvent({
|
||||
event: 'App: Embed Session Started',
|
||||
userId: user?.id,
|
||||
organisationId: team?.organisationId,
|
||||
teamId: firstDocument.teamId,
|
||||
properties: {
|
||||
type: 'signing',
|
||||
version: 'v1',
|
||||
envelopeId: firstDocument.envelopeId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return superLoaderJson({
|
||||
envelopes,
|
||||
user,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
@@ -13,7 +12,7 @@ import type { OrganisationSession } from '@documenso/trpc/server/organisation-ro
|
||||
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationMemberRole, OrganisationType, TeamMemberRole } from '@prisma/client';
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import { isRouteErrorResponse, Outlet, useLoaderData } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -167,17 +166,8 @@ export default function AuthoringLayout() {
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||
|
||||
useEffect(() => {
|
||||
analytics.captureException(error, {
|
||||
source: 'embed',
|
||||
location: 'embed_authoring_boundary',
|
||||
});
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{match(errorCode)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
ZEmbedCreateEnvelopeAuthoringSchema,
|
||||
} from '@documenso/lib/types/envelope-editor';
|
||||
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
|
||||
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||
import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document';
|
||||
import { buildEmbeddedEditorOptions, buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@@ -78,30 +76,6 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
},
|
||||
});
|
||||
|
||||
fireAndForget(async () => {
|
||||
const team = result.teamId
|
||||
? await prisma.team.findFirst({
|
||||
where: {
|
||||
id: result.teamId,
|
||||
},
|
||||
select: {
|
||||
organisationId: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
captureServerEvent({
|
||||
event: 'App: Embed Session Started',
|
||||
userId: result.userId,
|
||||
organisationId: team?.organisationId,
|
||||
teamId: result.teamId ?? undefined,
|
||||
properties: {
|
||||
type: 'authoring',
|
||||
version: 'v2',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return superLoaderJson({
|
||||
token,
|
||||
tokenUserId: result.userId,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { getEditorEnvelopeById } from '@documenso/lib/server-only/envelope/get-editor-envelope-by-id';
|
||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||
@@ -95,18 +94,6 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
brandingLogo = settings.brandingLogo;
|
||||
}
|
||||
|
||||
captureServerEvent({
|
||||
event: 'App: Embed Session Started',
|
||||
userId: result.userId,
|
||||
organisationId: envelope.team.organisationId,
|
||||
teamId: result.teamId ?? undefined,
|
||||
properties: {
|
||||
type: 'authoring',
|
||||
version: 'v2',
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
});
|
||||
|
||||
return superLoaderJson({
|
||||
token,
|
||||
envelope,
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { createContext, useRouteLoaderData } from 'react-router';
|
||||
|
||||
/**
|
||||
* Per-request CSP nonce. Set by the root route middleware, read with
|
||||
* `context.get(nonceContext)` in loaders/actions and `entry.server`.
|
||||
*/
|
||||
export const nonceContext = createContext<string>('');
|
||||
import { useRouteLoaderData } from 'react-router';
|
||||
|
||||
/**
|
||||
* Returns the supplied CSP nonce only when rendering on the server.
|
||||
|
||||
@@ -106,5 +106,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.18.0"
|
||||
"version": "2.17.0"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,4 @@ export default {
|
||||
// kept without a trailing slash so they match exactly, and so the bare
|
||||
// sub-path URL (e.g. "/ESign") still matches the basename at runtime.
|
||||
basename: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
|
||||
future: {
|
||||
v8_middleware: true,
|
||||
},
|
||||
} satisfies Config;
|
||||
|
||||
@@ -24,11 +24,12 @@ export const appContext = async (c: Context, next: Next) => {
|
||||
|
||||
// These are non page paths like API.
|
||||
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
|
||||
return await next();
|
||||
return next();
|
||||
}
|
||||
|
||||
// Add context to any pages you want here.
|
||||
return await next();
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
const setAppContext = (c: Context, context: AppContext) => {
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import { getContext } from 'hono/context-storage';
|
||||
import { RouterContextProvider } from 'react-router';
|
||||
import type { AppLoadContext } from 'react-router';
|
||||
|
||||
import type { HonoEnv } from './router';
|
||||
import { CSP_NONCE_KEY } from './security-headers';
|
||||
|
||||
/**
|
||||
* Per-request CSP nonce set by `securityHeadersMiddleware`, read via
|
||||
* `hono/context-storage` (enabled in `server/router.ts`).
|
||||
* Augment React Router's `AppLoadContext` so loaders, actions, and
|
||||
* `entry.server` can access fields by name without casts.
|
||||
*/
|
||||
export const getRequestNonce = (): string => getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
|
||||
declare module 'react-router' {
|
||||
interface AppLoadContext {
|
||||
/**
|
||||
* Per-request CSP nonce. Populated by `securityHeadersMiddleware` and surfaced here
|
||||
* so it can be threaded into `<ServerRouter nonce>` and root loader
|
||||
* data, which then feeds `<Scripts>`, `<Links>`, etc.
|
||||
*/
|
||||
nonce: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `future.v8_middleware` requires a `RouterContextProvider` instance here.
|
||||
* Builds the React Router `AppLoadContext` for both dev (vite plugin) and
|
||||
* production (`hono-react-router-adapter/node`).
|
||||
*
|
||||
* The Hono context isn't passed directly by the adapter, so we read it via
|
||||
* `hono/context-storage`, which is enabled in `server/router.ts`.
|
||||
*/
|
||||
export const getLoadContext = (): RouterContextProvider => new RouterContextProvider();
|
||||
export const getLoadContext = (): AppLoadContext => {
|
||||
const nonce = getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
|
||||
|
||||
return { nonce };
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import { lingui } from '@lingui/vite-plugin';
|
||||
import { reactRouter } from '@react-router/dev/vite';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
import serverAdapter from 'hono-react-router-adapter/vite';
|
||||
import type { AppLoadContext } from 'react-router';
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import { defineConfig, normalizePath } from 'vite';
|
||||
import macrosPlugin from 'vite-plugin-babel-macros';
|
||||
@@ -54,7 +53,7 @@ export default defineConfig({
|
||||
entry: 'server/router.ts',
|
||||
getLoadContext: async () => {
|
||||
const { getLoadContext } = await import('./server/load-context');
|
||||
return getLoadContext() as unknown as AppLoadContext;
|
||||
return getLoadContext();
|
||||
},
|
||||
exclude: [
|
||||
// Spread the defaults but replace the /.css$/ rule so that Bull
|
||||
|
||||
+12
-28
@@ -1,7 +1,7 @@
|
||||
###########################
|
||||
# BASE CONTAINER #
|
||||
###########################
|
||||
FROM node:24-alpine3.23 AS base
|
||||
FROM node:22-alpine3.22 AS base
|
||||
|
||||
RUN apk add --no-cache openssl
|
||||
RUN apk add --no-cache font-freefont
|
||||
@@ -19,10 +19,7 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
# Install the exact turbo version resolved in the lockfile, without installing
|
||||
# the rest of the dependency tree (prune must run before any npm ci).
|
||||
RUN TURBO_VERSION="$(jq -r '.packages["node_modules/turbo"].version' package-lock.json)" \
|
||||
&& npm install -g "turbo@${TURBO_VERSION}"
|
||||
RUN npm install -g "turbo@^2.10.0"
|
||||
|
||||
# Outputs to the /out folder
|
||||
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
|
||||
@@ -42,9 +39,9 @@ RUN apk add --no-cache make cmake g++ openssl bash
|
||||
WORKDIR /app
|
||||
|
||||
# Disable husky from installing hooks
|
||||
ENV HUSKY=0
|
||||
ENV DOCKER_OUTPUT=1
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV HUSKY 0
|
||||
ENV DOCKER_OUTPUT 1
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Encryption keys
|
||||
ARG NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
||||
@@ -79,7 +76,6 @@ COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/package-lock.json ./package-lock.json
|
||||
|
||||
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
|
||||
COPY --from=builder /app/patches ./patches
|
||||
|
||||
RUN npm ci
|
||||
|
||||
@@ -88,17 +84,17 @@ COPY --from=builder /app/out/full/ .
|
||||
# Finally copy the turbo.json file so that we can run turbo commands
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
ENV NODE_OPTIONS="--max-old-space-size=8192"
|
||||
RUN npm install -g "turbo@^2.10.0"
|
||||
|
||||
RUN npx turbo run build --filter=@documenso/remix...
|
||||
RUN turbo run build --filter=@documenso/remix...
|
||||
|
||||
###########################
|
||||
# RUNNER CONTAINER #
|
||||
###########################
|
||||
FROM base AS runner
|
||||
|
||||
ENV HUSKY=0
|
||||
ENV DOCKER_OUTPUT=1
|
||||
ENV HUSKY 0
|
||||
ENV DOCKER_OUTPUT 1
|
||||
|
||||
# Telemetry credentials (baked into image at build time, can be disabled at runtime)
|
||||
ARG NEXT_PRIVATE_TELEMETRY_KEY=""
|
||||
@@ -118,19 +114,8 @@ WORKDIR /app
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
|
||||
# Copy the tailwind config files across
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config
|
||||
# Copy the patches across
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
|
||||
|
||||
RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force
|
||||
|
||||
# Strip build-time residue that ships as production dependencies but is never
|
||||
# executed at runtime.
|
||||
RUN rm -rf \
|
||||
node_modules/react-email/dist/cli \
|
||||
node_modules/esbuild \
|
||||
node_modules/@esbuild \
|
||||
node_modules/.bin/esbuild \
|
||||
node_modules/.bin/email
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nodejs.org/docs/advanced-features/output-file-tracing
|
||||
@@ -141,9 +126,8 @@ COPY --from=installer --chown=nodejs:nodejs /app/apps/remix/public ./apps/remix/
|
||||
COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/schema.prisma ./packages/prisma/schema.prisma
|
||||
COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/migrations ./packages/prisma/migrations
|
||||
|
||||
# Generate the prisma client again, this time only targeting the client generator
|
||||
RUN npx prisma generate --schema ./packages/prisma/schema.prisma --generator client \
|
||||
&& npm cache clean --force
|
||||
# Generate the prisma client again
|
||||
RUN npx prisma generate --schema ./packages/prisma/schema.prisma
|
||||
|
||||
|
||||
# Get the start script from docker/
|
||||
|
||||
Generated
+14
-35
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "2.18.0",
|
||||
"version": "2.17.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "2.18.0",
|
||||
"version": "2.17.0",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -15,7 +15,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "5.0.48",
|
||||
"@documenso/prisma": "*",
|
||||
"@libpdf/core": "^0.4.2",
|
||||
"@libpdf/core": "^0.4.1",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@marsidev/react-turnstile": "^1.5.0",
|
||||
@@ -102,8 +102,8 @@
|
||||
"zod-prisma-types": "3.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
"npm": ">=11.17.0"
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=11.11.0"
|
||||
}
|
||||
},
|
||||
"apps/docs": {
|
||||
@@ -416,7 +416,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "2.18.0",
|
||||
"version": "2.17.0",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@documenso/api": "*",
|
||||
@@ -3153,7 +3153,6 @@
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz",
|
||||
"integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/gast": "10.5.0",
|
||||
@@ -3165,7 +3164,6 @@
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz",
|
||||
"integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "10.5.0",
|
||||
@@ -3176,14 +3174,12 @@
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz",
|
||||
"integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/utils": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz",
|
||||
"integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@commitlint/cli": {
|
||||
@@ -4806,9 +4802,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@libpdf/core": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.2.tgz",
|
||||
"integrity": "sha512-lbkIqLDZCCxjLpiC+8/Xvaru/ME7iVVoihl9tLqbp/CDUWZNF0q3u7s2tBJB9wRW/SzUWID6YPFvBWws770hrQ==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.1.tgz",
|
||||
"integrity": "sha512-DWGxWw1na8oFPixz+b6kOgu4tMD8xJLDgyPGVUNqIswO5POHAxsqmTvUvDW0IrwHP7kFRHBV3I3T/KhTABf9rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
@@ -5727,7 +5723,6 @@
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz",
|
||||
"integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chevrotain": "^10.5.0",
|
||||
@@ -10300,14 +10295,12 @@
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-6.19.3.tgz",
|
||||
"integrity": "sha512-+D6v7RIF21bJrZAXiiIdW0qR73TleYVCqTDozokdEHdGeqN997O7jJW5z+43s5CVwWTZJIwFGpoeXxDDyXDVxA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/driver-adapter-utils": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-6.19.3.tgz",
|
||||
"integrity": "sha512-UUxn6VLfKKVqm5n9vexOQgFJ9TCBIxupb7F5FzLQ6iM2VV0WZxhgzbQVqBDsGY+VmQLmqaSoKRusEfy3O5KZpA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.19.3"
|
||||
@@ -10356,14 +10349,12 @@
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-6.19.3.tgz",
|
||||
"integrity": "sha512-rzHJaIZEnEDUWNjjFAimsyMCS9osPxwbwiAbymwFprSHJSAglMxMDVU+xbQUCLeDsjUF09W5ag/aBPL2t3YqgA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/generator-helper": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.19.3.tgz",
|
||||
"integrity": "sha512-13S8ngSWVKcyuRFqaK/JGMyovjQC5sOKJ9A+ufqePQWeIUbvKO2QY2CmhcV4JAa2vuN22//4Sip5mORVKwS0sw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.19.3",
|
||||
@@ -10384,7 +10375,6 @@
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/internals/-/internals-6.19.3.tgz",
|
||||
"integrity": "sha512-1V5ba+BNtGFFlgoA8ecyAbjmoMWzIrEuebmLbJpeS7qbhnY6vbc1OZ6qc55lI/VAkfdALSL5vePG5KF9P8feLw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.19.3",
|
||||
@@ -10415,21 +10405,18 @@
|
||||
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/prisma-schema-wasm/-/prisma-schema-wasm-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
||||
"integrity": "sha512-/5JrsJQAOIWSl+9WMM/0ugd737kT9zdMNr7EFaP7ogeMxAhxQ78uaOV6JYGEocD8LC0gZx7MXmVE/CTfYdJ2iA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/schema-engine-wasm": {
|
||||
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/schema-engine-wasm/-/schema-engine-wasm-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
||||
"integrity": "sha512-mXtzeePkSdqpr/HAIclqzQEf+tBzLawy7NQ9AaRCfg8N/cruavpsocgbSwOOfV7JTk9JubyzKuR3tp2bNNuWbQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/schema-files-loader": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/schema-files-loader/-/schema-files-loader-6.19.3.tgz",
|
||||
"integrity": "sha512-rnoL2PgopghakRZLCZwj+d7LPvjbY9mFholFWuibEKzwO7aqS16s60Hz4wxAnKwqOcS/M6pV7QLrsHBYnNR0KA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/prisma-schema-wasm": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||
@@ -16287,7 +16274,6 @@
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz",
|
||||
"integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "10.5.0",
|
||||
@@ -16639,7 +16625,6 @@
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
|
||||
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/collapse-white-space": {
|
||||
@@ -19886,7 +19871,6 @@
|
||||
"version": "11.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
|
||||
"integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -22553,7 +22537,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -26400,7 +26383,6 @@
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/prisma-json-types-generator/-/prisma-json-types-generator-3.6.2.tgz",
|
||||
"integrity": "sha512-WX/oENQ0S74r/Wgd2uuHT5i3KbnwLFCP2Fq5ISzrXkus/htOC4uCaQPYuGP2m/wSeKZZCw1RxptTlD+ib7Ht/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/generator-helper": "^6.16.1",
|
||||
@@ -26426,7 +26408,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma-kysely/-/prisma-kysely-2.3.0.tgz",
|
||||
"integrity": "sha512-/+VF2t2DlY+t/27hhyH5ULWxBAAsMBPgOo8Ltq7uXpBz49lUf4XuO1ff9HV0l7J3aDojG+YyczEvY0og9uRqsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mrleebo/prisma-ast": "^0.13.1",
|
||||
@@ -27627,7 +27608,6 @@
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz",
|
||||
"integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rehype-raw": {
|
||||
@@ -31674,7 +31654,6 @@
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/zod-prisma-types/-/zod-prisma-types-3.3.5.tgz",
|
||||
"integrity": "sha512-PDuRRCdX1d6ch6UclNvrqM1SLb2qvuaQPvZT/OHRrcxOQqQUnCz009aBc1uxuwMho1+d42TTDCQr+HPXhTSJtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/dmmf": "^6.16.3",
|
||||
@@ -32431,17 +32410,17 @@
|
||||
"nanoid": "^5.1.6",
|
||||
"prisma": "^6.19.0",
|
||||
"prisma-extension-kysely": "^3.0.0",
|
||||
"prisma-json-types-generator": "^3.6.2",
|
||||
"prisma-kysely": "^2.3.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
"zod": "^3.25.76",
|
||||
"zod-prisma-types": "3.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"prisma-json-types-generator": "^3.6.2",
|
||||
"prisma-kysely": "^2.3.0",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "5.6.2",
|
||||
"zod-prisma-types": "3.3.5"
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
},
|
||||
"packages/prisma/node_modules/@esbuild/aix-ppc64": {
|
||||
|
||||
+5
-5
@@ -5,7 +5,7 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.18.0",
|
||||
"version": "2.17.0",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
@@ -41,10 +41,10 @@
|
||||
"translate:extract": "lingui extract --clean",
|
||||
"translate:compile": "lingui compile"
|
||||
},
|
||||
"packageManager": "npm@11.19.1",
|
||||
"packageManager": "npm@11.11.0",
|
||||
"engines": {
|
||||
"npm": ">=11.17.0",
|
||||
"node": ">=24.0.0"
|
||||
"npm": ">=11.11.0",
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.8",
|
||||
@@ -91,7 +91,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "5.0.48",
|
||||
"@documenso/prisma": "*",
|
||||
"@libpdf/core": "^0.4.2",
|
||||
"@libpdf/core": "^0.4.1",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
|
||||
@@ -10,7 +10,7 @@ test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||
|
||||
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organizations…';
|
||||
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…';
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
@@ -411,7 +411,7 @@ test('[ADMIN][DELETE_ORG]: the original owner loses access after deletion', asyn
|
||||
});
|
||||
|
||||
// They should NOT see the organisation settings heading for this org.
|
||||
await expect(page.getByText('Organization Settings')).not.toBeVisible();
|
||||
await expect(page.getByText('Organisation Settings')).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Access control: UI ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,9 +50,9 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Verify we're on the admin organisation page
|
||||
await expect(page.getByText(`Manage organization`)).toBeVisible();
|
||||
await expect(page.getByText(`Manage organisation`)).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Organization Name')).toHaveValue(organisation.name);
|
||||
await expect(page.getByLabel('Organisation Name')).toHaveValue(organisation.name);
|
||||
|
||||
// Check that the organisation members table shows the correct roles
|
||||
const ownerRow = page.getByRole('row', { name: ownerUser.email });
|
||||
@@ -356,7 +356,7 @@ test('[ADMIN]: error handling for invalid organisation', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Should show 404 error
|
||||
await expect(page.getByRole('heading', { name: 'Organization not found' })).toBeVisible({
|
||||
await expect(page.getByRole('heading', { name: 'Organisation not found' })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
@@ -525,8 +525,8 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
|
||||
|
||||
// Should be able to access organisation settings
|
||||
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
|
||||
await expect(page.getByLabel('Organization Name*')).toBeVisible();
|
||||
await expect(page.getByLabel('Organization Name*')).toBeEnabled();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
|
||||
|
||||
// Should have delete permissions
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
|
||||
@@ -408,7 +408,7 @@ test('[BULK_ACTIONS]: can cancel multiple pending documents', async ({ page }) =
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents canceled');
|
||||
await expectToastTextToBeVisible(page, 'Documents cancelled');
|
||||
|
||||
// Selection clears after a successful cancel.
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
@@ -455,7 +455,7 @@ test('[BULK_ACTIONS]: bulk cancel only affects pending documents', async ({ page
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
// Only one of the three was pending, so this is a partial result.
|
||||
await expectToastTextToBeVisible(page, 'Documents partially canceled');
|
||||
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
|
||||
|
||||
const pendingEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: pending.id },
|
||||
@@ -505,7 +505,7 @@ test('[BULK_ACTIONS]: a MEMBER cannot bulk cancel documents they do not own', as
|
||||
|
||||
// The server rejects the cancellation for a document the MEMBER does not own,
|
||||
// so it reports zero cancelled (a partial result with the document in failedIds).
|
||||
await expectToastTextToBeVisible(page, 'Documents partially canceled');
|
||||
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
|
||||
|
||||
// The document remains pending.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
|
||||
@@ -41,7 +41,7 @@ const cancelDocumentViaUi = async (page: Page, documentTitle: string, reason?: s
|
||||
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
|
||||
|
||||
if (reason) {
|
||||
await page.getByPlaceholder('Add an optional reason for canceling this document').fill(reason);
|
||||
await page.getByPlaceholder('Add an optional reason for cancelling this document').fill(reason);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel document' }).click();
|
||||
@@ -58,13 +58,13 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending', 'No longer required');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document canceled');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// The document must remain in the dashboard, unlike deleting a pending document.
|
||||
await checkDocumentCounts(page, { inbox: 0, pending: 0, cancelled: 1, all: 1 });
|
||||
|
||||
// The cancelled document is still listed.
|
||||
await selectDocumentStatusFilter(page, 'Canceled');
|
||||
await selectDocumentStatusFilter(page, 'Cancelled');
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
// The envelope status is persisted as CANCELLED.
|
||||
@@ -95,7 +95,7 @@ test('[DOCUMENTS]: cancelling a pending document retains it for recipients', asy
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document canceled');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
@@ -125,10 +125,10 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner
|
||||
});
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending');
|
||||
await expectToastTextToBeVisible(page, 'Document canceled');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
|
||||
await selectDocumentStatusFilter(page, 'Canceled');
|
||||
await selectDocumentStatusFilter(page, 'Cancelled');
|
||||
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
@@ -328,7 +328,7 @@ test('[DOCUMENTS]: a team ADMIN sees and can use the Cancel action on a document
|
||||
|
||||
await cancelDocumentViaUi(page, 'Admin Cancellable Document');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document canceled');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
|
||||
@@ -29,11 +29,11 @@ test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
|
||||
|
||||
await page.waitForURL(`/settings/organisations`);
|
||||
await expectTextToBeVisible(page, 'No results found');
|
||||
await page.getByRole('button', { name: 'Create organization' }).click();
|
||||
await page.getByRole('button', { name: 'Create organisation' }).click();
|
||||
|
||||
await page.getByLabel('Organization Name*').fill('test');
|
||||
await page.getByLabel('Organisation Name*').fill('test');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await expect(page.getByText('Your organization has been created').first()).toBeVisible();
|
||||
await expect(page.getByText('Your organisation has been created').first()).toBeVisible();
|
||||
await page.reload();
|
||||
|
||||
await page.getByRole('row').filter({ hasText: 'test' }).getByRole('link').nth(1).click();
|
||||
@@ -53,12 +53,12 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => {
|
||||
const updatedOrganisationId = `organisation-${Date.now()}`;
|
||||
|
||||
// Update team.
|
||||
await page.getByLabel('Organization Name*').click();
|
||||
await page.getByLabel('Organization Name*').clear();
|
||||
await page.getByLabel('Organization Name*').fill(updatedOrganisationId);
|
||||
await page.getByLabel('Organization URL*').click();
|
||||
await page.getByLabel('Organization URL*').clear();
|
||||
await page.getByLabel('Organization URL*').fill(updatedOrganisationId);
|
||||
await page.getByLabel('Organisation Name*').click();
|
||||
await page.getByLabel('Organisation Name*').clear();
|
||||
await page.getByLabel('Organisation Name*').fill(updatedOrganisationId);
|
||||
await page.getByLabel('Organisation URL*').click();
|
||||
await page.getByLabel('Organisation URL*').clear();
|
||||
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||
|
||||
@@ -277,8 +277,8 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
// Create a custom group A with 3 members "ORGANISATION ADMIN" to check that they get the correct roles.
|
||||
await page.getByRole('button', { name: 'Create group' }).click();
|
||||
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organization Member' }).click();
|
||||
await page.getByRole('option', { name: 'Organization Admin' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
|
||||
await page.getByRole('option', { name: 'Organisation Admin' }).click();
|
||||
await page.getByTestId('group-members-picker').click();
|
||||
await page.getByRole('option', { name: 'Member1' }).click();
|
||||
await page.getByRole('option', { name: 'Member2' }).click();
|
||||
@@ -291,16 +291,16 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
await page.goto(`/o/${organisation.url}/settings/members`);
|
||||
|
||||
// Confirm org roles have been applied to these members.
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail1)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail2)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail3)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail1)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail2)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail3)).toBeVisible();
|
||||
|
||||
// Test updating the group.
|
||||
await page.goto(`/o/${organisation.url}/settings/groups`);
|
||||
await page.getByRole('link', { name: 'Manage' }).click();
|
||||
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_A');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organization Admin' }).click();
|
||||
await page.getByRole('option', { name: 'Organization Member' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organisation Admin' }).click();
|
||||
await page.getByRole('option', { name: 'Organisation Member' }).click();
|
||||
// Remove Member3 by clicking the X on its chip in the multiselect.
|
||||
await page
|
||||
.getByTestId('group-members-picker')
|
||||
@@ -327,16 +327,16 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
await page.goto(`/o/${organisation.url}/settings/members`);
|
||||
|
||||
// Confirm admins still get admin roles.
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail1)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail2)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail3)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail1)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail2)).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail3)).toBeVisible();
|
||||
|
||||
// Create another custom group with 3 members with "ORGANISATION MEMBER" role.
|
||||
await page.goto(`/o/${organisation.url}/settings/groups`);
|
||||
await page.getByRole('button', { name: 'Create group' }).click();
|
||||
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_B');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organization Member' }).click();
|
||||
await page.getByRole('option', { name: 'Organization Admin' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
|
||||
await page.getByRole('option', { name: 'Organisation Admin' }).click();
|
||||
await page.getByTestId('group-members-picker').click();
|
||||
await page.getByRole('option', { name: 'Member4' }).click();
|
||||
await page.getByRole('option', { name: 'Member5' }).click();
|
||||
@@ -537,6 +537,6 @@ test('[ORGANISATIONS]: leave organisation', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Leave' }).click();
|
||||
await page.getByRole('button', { name: 'Leave' }).click();
|
||||
|
||||
await expect(page.getByText('You have successfully left this organization').first()).toBeVisible();
|
||||
await expect(page.getByText('You have successfully left this organisation').first()).toBeVisible();
|
||||
await expect(page.getByText('No results found').first()).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const BANNER_EXCEEDED_TEXT = 'Your organization has exceeded a fair use limit';
|
||||
const BANNER_NEARING_TEXT = 'Your organization is approaching a fair use limit';
|
||||
const BANNER_EXCEEDED_TEXT = 'Your organisation has exceeded a fair use limit';
|
||||
const BANNER_NEARING_TEXT = 'Your organisation is approaching a fair use limit';
|
||||
|
||||
type SeedQuotaStateOptions = {
|
||||
organisationId: string;
|
||||
@@ -162,7 +162,7 @@ test('[QUOTA BANNER]: is hidden for free-claim organisations', async ({ page })
|
||||
});
|
||||
|
||||
// Anchor on a stable element so banner-absence is meaningful (page fully loaded).
|
||||
await expect(page.getByLabel('Organization Name*')).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||
|
||||
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Learn more' })).toBeHidden();
|
||||
|
||||
@@ -176,7 +176,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||
|
||||
// Test inheritance by setting team back to inherit from organisation
|
||||
await page.getByTestId('enable-branding').click();
|
||||
await page.getByRole('option', { name: 'Inherit from organization' }).click();
|
||||
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||
|
||||
@@ -254,9 +254,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page
|
||||
.getByTestId('inheritable-email-document-settings')
|
||||
.getByRole('combobox')
|
||||
.filter({ hasText: 'Inherit from organization' })
|
||||
.filter({ hasText: 'Inherit from organisation' })
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'Override organization settings' }).click();
|
||||
await page.getByRole('option', { name: 'Override organisation settings' }).click();
|
||||
|
||||
// Update some email settings
|
||||
await page.getByRole('checkbox', { name: 'Email recipients with a signing request' }).uncheck();
|
||||
@@ -308,8 +308,8 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
|
||||
// Test inheritance by setting team back to inherit from organisation
|
||||
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Override organization settings' }).click();
|
||||
await page.getByRole('option', { name: 'Inherit from organization' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
|
||||
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ test.describe('Unified Settings', () => {
|
||||
|
||||
// Wait for the organisation page to actually render — asserting straight after
|
||||
// `waitForURL` can read the previous scope's still-mounted sidebar and pass falsely.
|
||||
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organization Settings');
|
||||
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organisation Settings');
|
||||
|
||||
await expect(trigger).toContainText(selected.name);
|
||||
|
||||
@@ -283,7 +283,7 @@ test.describe('Unified Settings', () => {
|
||||
|
||||
// Selecting the inherit option stages the field back to inherited.
|
||||
await page.getByTestId('document-language-trigger').click();
|
||||
await page.getByRole('option', { name: /inherit from organization/i }).click();
|
||||
await page.getByRole('option', { name: /inherit from organisation/i }).click();
|
||||
|
||||
await expect(langStatus).toHaveText(/inherited/i);
|
||||
});
|
||||
@@ -450,7 +450,7 @@ test.describe('Unified Settings', () => {
|
||||
// An empty group would just look broken, so it explains itself directly under the switcher.
|
||||
const emptyState = sidebar.getByTestId('unified-settings-organisation-empty-state');
|
||||
await expect(emptyState).toBeVisible();
|
||||
await expect(emptyState).toContainText(/permission to manage this organization/i);
|
||||
await expect(emptyState).toContainText(/permission to manage this organisation/i);
|
||||
|
||||
// Team and account pages remain navigable.
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-team-general')).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getTeamAnalytics } from '@documenso/lib/server-only/team/get-team-analytics';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedCompletedDocument,
|
||||
seedDraftDocument,
|
||||
seedPendingDocument,
|
||||
seedTeamDocuments,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
// Fixed, "now"-independent windows so the date-axis assertions are deterministic.
|
||||
const SENT_IN_APRIL = new Date('2026-04-10T12:00:00.000Z');
|
||||
const ACTIONED_IN_MAY = new Date('2026-05-10T12:00:00.000Z');
|
||||
|
||||
const APRIL = {
|
||||
periodStart: new Date('2026-04-01T00:00:00.000Z'),
|
||||
periodEnd: new Date('2026-05-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
const MAY = {
|
||||
periodStart: new Date('2026-05-01T00:00:00.000Z'),
|
||||
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
// ─── Query semantics (no browser / dev server) ───────────────────────────────
|
||||
|
||||
test('[ANALYTICS]: a completed document is counted by completedAt, not createdAt', async () => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
// Sent in April, completed in May — the document lands on two different axes.
|
||||
await seedCompletedDocument(owner, team.id, [], {
|
||||
createDocumentOptions: {
|
||||
createdAt: SENT_IN_APRIL,
|
||||
completedAt: ACTIONED_IN_MAY,
|
||||
},
|
||||
});
|
||||
|
||||
const april = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...APRIL });
|
||||
const may = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
|
||||
|
||||
// Created (and non-draft) in April → counts as Sent in April only.
|
||||
expect(april.sent).toBe(1);
|
||||
expect(april.completed).toBe(0);
|
||||
|
||||
// Completed in May → counts as Completed in May only, never as Sent in May.
|
||||
expect(may.sent).toBe(0);
|
||||
expect(may.completed).toBe(1);
|
||||
});
|
||||
|
||||
test('[ANALYTICS]: declined documents are dated from the rejection audit log', async () => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
// Document was created in April but only rejected in May.
|
||||
const rejected = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: {
|
||||
status: DocumentStatus.REJECTED,
|
||||
createdAt: SENT_IN_APRIL,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: {
|
||||
envelopeId: rejected.id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
|
||||
createdAt: ACTIONED_IN_MAY,
|
||||
data: {},
|
||||
},
|
||||
});
|
||||
|
||||
const april = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...APRIL });
|
||||
const may = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
|
||||
|
||||
// Rejection happened in May, so April (the creation month) records no decline.
|
||||
expect(april.declined).toBe(0);
|
||||
expect(may.declined).toBe(1);
|
||||
});
|
||||
|
||||
test('[ANALYTICS]: counts attribute by owner and aggregate across all folders', async () => {
|
||||
const { team, owner, organisation } = await seedTeam({ createTeamMembers: 1 });
|
||||
|
||||
const member = organisation.members[1].user;
|
||||
|
||||
const folder = await seedBlankFolder(owner, team.id);
|
||||
|
||||
// Owner: one pending in the root folder, one pending nested in a folder.
|
||||
await seedPendingDocument(owner, team.id, [], {
|
||||
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
|
||||
});
|
||||
await seedPendingDocument(owner, team.id, [], {
|
||||
createDocumentOptions: { createdAt: ACTIONED_IN_MAY, folderId: folder.id },
|
||||
});
|
||||
|
||||
// Member: one pending in the root folder.
|
||||
await seedPendingDocument(member, team.id, [], {
|
||||
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
|
||||
});
|
||||
|
||||
// All folders are aggregated: the nested document is included.
|
||||
const everyone = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
|
||||
expect(everyone.pending).toBe(3);
|
||||
|
||||
// Attribution by owner via senderIds.
|
||||
const ownerOnly = await getTeamAnalytics({
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
senderIds: [owner.id],
|
||||
...MAY,
|
||||
});
|
||||
expect(ownerOnly.pending).toBe(2);
|
||||
|
||||
const memberOnly = await getTeamAnalytics({
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
senderIds: [member.id],
|
||||
...MAY,
|
||||
});
|
||||
expect(memberOnly.pending).toBe(1);
|
||||
});
|
||||
|
||||
test('[ANALYTICS]: "Documents Sent" excludes drafts but counts every other status', async () => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
await seedDraftDocument(owner, team.id, [], {
|
||||
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
|
||||
});
|
||||
await seedPendingDocument(owner, team.id, [], {
|
||||
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
|
||||
});
|
||||
await seedCompletedDocument(owner, team.id, [], {
|
||||
createDocumentOptions: { createdAt: ACTIONED_IN_MAY, completedAt: ACTIONED_IN_MAY },
|
||||
});
|
||||
|
||||
const may = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
|
||||
|
||||
expect(may.draft).toBe(1);
|
||||
expect(may.pending).toBe(1);
|
||||
expect(may.completed).toBe(1);
|
||||
// Sent = non-draft created in the period (pending + completed), drafts excluded.
|
||||
expect(may.sent).toBe(2);
|
||||
});
|
||||
|
||||
// ─── Access control + dashboard UI (requires the running dev server) ──────────
|
||||
|
||||
test('[ANALYTICS]: a team admin sees the dashboard and filters move the numbers', async ({ page }) => {
|
||||
const { team, teamOwner, teamMember2 } = await seedTeamDocuments();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: teamOwner.email,
|
||||
redirectPath: `/t/${team.url}/analytics`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();
|
||||
await expect(page.getByTestId('team-analytics-content')).toBeVisible();
|
||||
|
||||
// teamMember1 (1 completed) + teamMember2 (2 pending) = 3 non-draft documents sent.
|
||||
await expect(page.getByTestId('metric-sent')).toContainText('3');
|
||||
|
||||
// Filtering to teamMember2 narrows the sent count to their 2 pending documents.
|
||||
await page.getByRole('button', { name: 'Sender' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
await expect(page.getByTestId('metric-sent')).toContainText('2');
|
||||
});
|
||||
|
||||
test('[ANALYTICS]: a team member is redirected away from the dashboard', async ({ page }) => {
|
||||
const { team } = await seedTeam();
|
||||
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: member.email,
|
||||
redirectPath: `/t/${team.url}/analytics`,
|
||||
});
|
||||
|
||||
// The loader silently redirects members back to documents (no 403, no leak).
|
||||
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents`);
|
||||
|
||||
expect(page.url()).toContain(`/t/${team.url}/documents`);
|
||||
expect(page.url()).not.toContain('/analytics');
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('[ANALYTICS]: a team with no document activity shows the empty state', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/analytics`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('team-analytics-empty')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Send a document' })).toBeVisible();
|
||||
await expect(page.getByTestId('team-analytics-content')).toHaveCount(0);
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test('[TEMPLATES]: bulk send via CSV from the table action dropdown', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
await seedTemplate({
|
||||
title: 'Bulk send template',
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const uniqueRecipientEmail = `bulk-send-${Date.now()}@documenso.com`;
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
const actionBtn = page
|
||||
.getByRole('row', { name: 'Bulk send template' })
|
||||
.getByRole('cell', { name: 'Use Template' })
|
||||
.getByRole('button')
|
||||
.nth(1);
|
||||
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
|
||||
await page.getByText('Bulk Send via CSV').click();
|
||||
|
||||
const dialog = page.getByRole('dialog').filter({ hasText: 'Bulk Send Template via CSV' });
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Opening the native file picker blurs the window. Radix dropdown menus close themselves on
|
||||
// window blur, which used to unmount this dialog when it was nested inside the menu content,
|
||||
// silently discarding the user's file selection.
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('blur')));
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const csv = ['recipient_1_email,recipient_1_name', `${uniqueRecipientEmail},Bulk Recipient`].join('\n');
|
||||
|
||||
await dialog.locator('input[type="file"]').setInputFiles({
|
||||
name: 'bulk-send.csv',
|
||||
mimeType: 'text/csv',
|
||||
buffer: Buffer.from(csv),
|
||||
});
|
||||
|
||||
await expect(dialog.getByText('bulk-send.csv')).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Upload and Process' }).click();
|
||||
|
||||
await expect(page.getByText('Your bulk send has been initiated').first()).toBeVisible();
|
||||
|
||||
// The bulk send runs as a background job, so poll for the created document.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
await prisma.envelope.count({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
teamId: team.id,
|
||||
recipients: {
|
||||
some: {
|
||||
email: uniqueRecipientEmail,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(1);
|
||||
});
|
||||
@@ -31,21 +31,18 @@ export function useAnalytics() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture an exception event.
|
||||
* Capture an analytic event.
|
||||
*
|
||||
* @param error The error to capture.
|
||||
* @param properties Properties to attach to the event, such as `source`, `location`,
|
||||
* `recipientId` or `envelopeId`. Never attach recipient tokens.
|
||||
* @param properties Properties to attach to the event.
|
||||
*/
|
||||
const captureException = (error: unknown, properties?: Record<string, unknown>) => {
|
||||
const captureException = (error: Error, properties?: Record<string, unknown>) => {
|
||||
if (!isPostHogEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorToCapture = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
void getPosthog().then(({ default: posthog }) => {
|
||||
posthog.captureException(errorToCapture, properties);
|
||||
posthog.captureException(error, properties);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import { useSearchParams } from 'react-router';
|
||||
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
|
||||
import { useAnalytics } from '../hooks/use-analytics';
|
||||
import type { TLocalField } from '../hooks/use-editor-fields';
|
||||
import { useEditorFields } from '../hooks/use-editor-fields';
|
||||
import { useEditorRecipients } from '../hooks/use-editor-recipients';
|
||||
@@ -105,7 +104,6 @@ export const EnvelopeEditorProvider = ({
|
||||
}: EnvelopeEditorProviderProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -243,12 +241,6 @@ export const EnvelopeEditorProvider = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: isEmbedded ? 'embed' : 'editor',
|
||||
location: 'autosave_recipients',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
@@ -312,12 +304,6 @@ export const EnvelopeEditorProvider = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: isEmbedded ? 'embed' : 'editor',
|
||||
location: 'autosave_fields',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
@@ -373,12 +359,6 @@ export const EnvelopeEditorProvider = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: isEmbedded ? 'embed' : 'editor',
|
||||
location: 'autosave_meta',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -53,6 +53,15 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
|
||||
|
||||
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
|
||||
|
||||
/**
|
||||
* Team analytics dashboard rollout flag.
|
||||
*
|
||||
* Acts as a kill-switch: enabled by default and disabled only when the env var
|
||||
* is explicitly set to "false". This keeps the feature available to all teams
|
||||
* while leaving a single lever to gate it off during rollout.
|
||||
*/
|
||||
export const IS_TEAM_ANALYTICS_ENABLED = () => env('NEXT_PUBLIC_FEATURE_TEAM_ANALYTICS_ENABLED') !== 'false';
|
||||
|
||||
/**
|
||||
* Whether this instance is Documenso Cloud (managed SaaS).
|
||||
*
|
||||
@@ -93,8 +102,6 @@ export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () => env('NEXT_PRIVATE_USE_PLAYW
|
||||
|
||||
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () => env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
|
||||
|
||||
export const NEXT_PRIVATE_SIGNING_TRANSPORT = () => env('NEXT_PRIVATE_SIGNING_TRANSPORT') || 'local';
|
||||
|
||||
/**
|
||||
* Whether this Documenso instance is running in CSC (Cloud Signature Consortium) mode.
|
||||
*
|
||||
|
||||
@@ -2,10 +2,12 @@ import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-comp
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { validateBulkSendCsv } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/macro';
|
||||
import { parse } from 'csv-parse/sync';
|
||||
import { createElement } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
@@ -15,6 +17,14 @@ import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TBulkSendTemplateJobDefinition } from './bulk-send-template';
|
||||
|
||||
const ZRecipientRowSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.union([
|
||||
zEmail('Value must be a valid email or empty string'),
|
||||
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
|
||||
]),
|
||||
});
|
||||
|
||||
export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefinition; io: JobRunIO }) => {
|
||||
const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload;
|
||||
|
||||
@@ -31,20 +41,24 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const { recipients } = template;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows = parse<any>(csvContent, { columns: true, skip_empty_lines: true });
|
||||
|
||||
// The CSV is validated upfront when the bulk send is uploaded, this acts as
|
||||
// a final safeguard prior to processing.
|
||||
const csvValidationResult = validateBulkSendCsv({
|
||||
csvContent,
|
||||
recipientCount: recipients.length,
|
||||
});
|
||||
|
||||
if (!csvValidationResult.success) {
|
||||
throw new Error(`Bulk send CSV failed validation: ${JSON.stringify(csvValidationResult.error)}`);
|
||||
if (rows.length > 100) {
|
||||
throw new Error('Maximum 100 rows allowed per upload');
|
||||
}
|
||||
|
||||
const rows = csvValidationResult.data;
|
||||
const { recipients } = template;
|
||||
|
||||
// Validate CSV structure
|
||||
const csvHeaders = Object.keys(rows[0]);
|
||||
const requiredHeaders = recipients.map((_, index) => `recipient_${index + 1}_email`);
|
||||
|
||||
for (const header of requiredHeaders) {
|
||||
if (!csvHeaders.includes(header)) {
|
||||
throw new Error(`Missing required column: ${header}`);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -65,6 +79,22 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
|
||||
// Process each row
|
||||
for (const [rowIndex, row] of rows.entries()) {
|
||||
try {
|
||||
for (const [recipientIndex] of recipients.entries()) {
|
||||
const nameKey = `recipient_${recipientIndex + 1}_name`;
|
||||
const emailKey = `recipient_${recipientIndex + 1}_email`;
|
||||
|
||||
const parsed = ZRecipientRowSchema.safeParse({
|
||||
name: row[nameKey],
|
||||
email: row[emailKey],
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Invalid recipient data provided for ${emailKey}, ${nameKey}: ${parsed.error.issues?.[0]?.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const envelope = await io.runTask(`create-document-${rowIndex}`, async () => {
|
||||
return await createDocumentFromTemplate({
|
||||
id: {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { PostHog } from 'posthog-node';
|
||||
|
||||
import { env } from '../../utils/env';
|
||||
|
||||
/**
|
||||
* Server-side product analytics client.
|
||||
*
|
||||
* Uses the same PostHog project as the client-side integration
|
||||
* (NEXT_PUBLIC_POSTHOG_KEY). Not to be confused with the self-hoster
|
||||
* telemetry client which reports to a separate project.
|
||||
*
|
||||
* Events are sent directly to PostHog rather than through the `/ingest`
|
||||
* reverse proxy used by the browser.
|
||||
*/
|
||||
const POSTHOG_HOST = 'https://eu.i.posthog.com';
|
||||
|
||||
let client: PostHog | null | undefined;
|
||||
|
||||
const getAnalyticsClient = () => {
|
||||
if (client !== undefined) {
|
||||
return client;
|
||||
}
|
||||
|
||||
const apiKey = env('NEXT_PUBLIC_POSTHOG_KEY');
|
||||
|
||||
// Low volume events, flush immediately so nothing is lost on shutdown.
|
||||
client = apiKey ? new PostHog(apiKey, { host: POSTHOG_HOST, flushAt: 1 }) : null;
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
type CaptureServerEventOptions = {
|
||||
event: string;
|
||||
|
||||
/**
|
||||
* The authenticated user this event belongs to, if any.
|
||||
*
|
||||
* Used as the distinct ID so server events line up with client-side
|
||||
* identified users. Anonymous events (e.g. embed signer sessions) get a
|
||||
* random distinct ID instead.
|
||||
*/
|
||||
userId?: number;
|
||||
organisationId?: string;
|
||||
teamId?: number;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture a product analytics event from the server.
|
||||
*
|
||||
* No-ops when NEXT_PUBLIC_POSTHOG_KEY is not configured. Events are captured
|
||||
* as anonymous events (no person profile processing) to keep usage costs low,
|
||||
* cross-referencing is done via the `userId`, `organisationId` and `teamId`
|
||||
* event properties.
|
||||
*
|
||||
* Never pass recipient tokens or document contents as properties.
|
||||
*/
|
||||
export const captureServerEvent = ({
|
||||
event,
|
||||
userId,
|
||||
organisationId,
|
||||
teamId,
|
||||
properties = {},
|
||||
}: CaptureServerEventOptions) => {
|
||||
const posthog = getAnalyticsClient();
|
||||
|
||||
if (!posthog) {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.capture({
|
||||
distinctId: userId ? String(userId) : `anon_${crypto.randomUUID()}`,
|
||||
event,
|
||||
properties: {
|
||||
...properties,
|
||||
userId,
|
||||
organisationId,
|
||||
teamId,
|
||||
$process_person_profile: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,36 +1,26 @@
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { createLocalSigner } from '@documenso/signing/transports/local';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
import { NEXT_PRIVATE_SIGNING_TRANSPORT } from '../../constants/app';
|
||||
|
||||
/**
|
||||
* Whether the local P12 opens with the configured passphrase and is in date.
|
||||
* Skips AIA so this stays offline. gcloud-hsm and csc always report available.
|
||||
*/
|
||||
export const getCertificateStatus = async () => {
|
||||
const transport = NEXT_PRIVATE_SIGNING_TRANSPORT();
|
||||
|
||||
// Cannot inspect a remote HSM or CSC provider from this process.
|
||||
if (transport === 'gcloud-hsm' || transport === 'csc') {
|
||||
export const getCertificateStatus = () => {
|
||||
if (env('NEXT_PRIVATE_SIGNING_TRANSPORT') !== 'local') {
|
||||
return { isAvailable: true };
|
||||
}
|
||||
|
||||
// Anything else (typo, leftover `http`) would throw at seal time.
|
||||
if (transport !== 'local') {
|
||||
return { isAvailable: false };
|
||||
if (env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS')) {
|
||||
return { isAvailable: true };
|
||||
}
|
||||
|
||||
const defaultPath = env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
|
||||
|
||||
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
|
||||
|
||||
try {
|
||||
const signer = await createLocalSigner({ buildChain: false });
|
||||
fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK);
|
||||
|
||||
const certificate = new X509Certificate(Buffer.from(signer.certificate));
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const isWithinValidityPeriod = new Date(certificate.validFrom) <= now && now <= new Date(certificate.validTo);
|
||||
|
||||
return { isAvailable: isWithinValidityPeriod };
|
||||
return { isAvailable: stats.size > 0 };
|
||||
} catch {
|
||||
return { isAvailable: false };
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { hashString } from '../auth/hash';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
|
||||
const LAST_USED_AT_UPDATE_INTERVAL = 60_000; // 1 minute
|
||||
|
||||
type GetApiTokenByTokenOptions = {
|
||||
token: string;
|
||||
|
||||
@@ -99,29 +96,6 @@ export const getApiTokenByToken = async ({ token, bypassRateLimit = false }: Get
|
||||
});
|
||||
}
|
||||
|
||||
// Only update the lastUsedAt after X amount of time has passed to reduce
|
||||
// the number of writes to the database
|
||||
if (!apiToken.lastUsedAt || apiToken.lastUsedAt.getTime() + LAST_USED_AT_UPDATE_INTERVAL < Date.now()) {
|
||||
void prisma.apiToken
|
||||
.updateMany({
|
||||
where: {
|
||||
id: apiToken.id,
|
||||
// Optimistic guard: skip if another request beat us
|
||||
lastUsedAt: apiToken.lastUsedAt,
|
||||
},
|
||||
data: {
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn({
|
||||
msg: 'Failed to update API token lastUsedAt',
|
||||
apiTokenId: apiToken.id,
|
||||
err,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...apiToken,
|
||||
user,
|
||||
|
||||
@@ -22,7 +22,6 @@ export const getApiTokens = async ({ userId, teamId }: GetApiTokensOptions) => {
|
||||
name: true,
|
||||
createdAt: true,
|
||||
expires: true,
|
||||
lastUsedAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resolveAnalyticsPeriod } from './analytics-period';
|
||||
|
||||
const iso = (date: Date) => date.toISOString();
|
||||
|
||||
describe('resolveAnalyticsPeriod', () => {
|
||||
// Friday, 2026-05-15. May 2026 is EDT (UTC-4) in the US.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-15T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('resolves "month" to the current calendar month, half-open, in UTC by default', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'month' });
|
||||
|
||||
expect(iso(start)).toBe('2026-05-01T00:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-06-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('anchors month boundaries to the viewer timezone', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'month', timezone: 'America/New_York' });
|
||||
|
||||
// Local midnight in EDT is 04:00 UTC.
|
||||
expect(iso(start)).toBe('2026-05-01T04:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-06-01T04:00:00.000Z');
|
||||
});
|
||||
|
||||
it('shifts the window for a zone ahead of UTC', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'month', timezone: 'Asia/Tokyo' });
|
||||
|
||||
// JST is UTC+9, so local midnight is the previous day at 15:00 UTC.
|
||||
expect(iso(start)).toBe('2026-04-30T15:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-05-31T15:00:00.000Z');
|
||||
});
|
||||
|
||||
it('falls back to UTC for an invalid timezone', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'month', timezone: 'Not/AZone' });
|
||||
|
||||
expect(iso(start)).toBe('2026-05-01T00:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-06-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('resolves "lastMonth" contiguous with the start of the current month', () => {
|
||||
const lastMonth = resolveAnalyticsPeriod({ period: 'lastMonth' });
|
||||
const thisMonth = resolveAnalyticsPeriod({ period: 'month' });
|
||||
|
||||
expect(iso(lastMonth.start)).toBe('2026-04-01T00:00:00.000Z');
|
||||
expect(iso(lastMonth.end)).toBe('2026-05-01T00:00:00.000Z');
|
||||
expect(iso(lastMonth.end)).toBe(iso(thisMonth.start));
|
||||
});
|
||||
|
||||
it('resolves "quarter" to the current calendar quarter', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'quarter' });
|
||||
|
||||
expect(iso(start)).toBe('2026-04-01T00:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-07-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('resolves "year" to the current calendar year', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'year' });
|
||||
|
||||
expect(iso(start)).toBe('2026-01-01T00:00:00.000Z');
|
||||
expect(iso(end)).toBe('2027-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('resolves "last30Days" as a trailing 30-day window including today', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'last30Days' });
|
||||
|
||||
expect(iso(start)).toBe('2026-04-16T00:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-05-16T00:00:00.000Z');
|
||||
expect(end.getTime() - start.getTime()).toBe(30 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('resolves "last7Days" as a trailing 7-day window including today', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'last7Days' });
|
||||
|
||||
expect(iso(start)).toBe('2026-05-09T00:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-05-16T00:00:00.000Z');
|
||||
expect(end.getTime() - start.getTime()).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('resolves "week" to a 7-day ISO week starting Monday', () => {
|
||||
const { start, end } = resolveAnalyticsPeriod({ period: 'week' });
|
||||
|
||||
// 2026-05-15 is a Friday; the ISO week starts Monday 2026-05-11.
|
||||
expect(iso(start)).toBe('2026-05-11T00:00:00.000Z');
|
||||
expect(iso(end)).toBe('2026-05-18T00:00:00.000Z');
|
||||
expect(end.getTime() - start.getTime()).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Calendar period presets for the team analytics dashboard. Each resolves to a
|
||||
* half-open `[start, end)` instant range in the viewer's timezone.
|
||||
*
|
||||
* Pure (zod + luxon, no Prisma) so it can be unit-tested without a database and
|
||||
* shared with the request schema without pulling server-only code anywhere it
|
||||
* should not go.
|
||||
*/
|
||||
export const ZAnalyticsPeriodSchema = z.enum([
|
||||
'week',
|
||||
'month',
|
||||
'quarter',
|
||||
'year',
|
||||
'lastMonth',
|
||||
'last7Days',
|
||||
'last30Days',
|
||||
]);
|
||||
|
||||
export type AnalyticsPeriod = z.infer<typeof ZAnalyticsPeriodSchema>;
|
||||
|
||||
export const DEFAULT_ANALYTICS_PERIOD: AnalyticsPeriod = 'month';
|
||||
|
||||
export type AnalyticsPeriodRange = {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a calendar preset into a half-open `[start, end)` instant range,
|
||||
* anchored to the viewer's timezone.
|
||||
*
|
||||
* Falls back to UTC when the zone is missing or invalid so boundaries never
|
||||
* silently drift to the server's local zone.
|
||||
*/
|
||||
export const resolveAnalyticsPeriod = ({
|
||||
period,
|
||||
timezone,
|
||||
}: {
|
||||
period: AnalyticsPeriod;
|
||||
timezone?: string;
|
||||
}): AnalyticsPeriodRange => {
|
||||
const zoned = DateTime.now().setZone(timezone ?? 'utc');
|
||||
const now = zoned.isValid ? zoned : DateTime.now().setZone('utc');
|
||||
|
||||
const { start, end } = match(period)
|
||||
.with('week', () => ({
|
||||
start: now.startOf('week'),
|
||||
end: now.startOf('week').plus({ weeks: 1 }),
|
||||
}))
|
||||
.with('month', () => ({
|
||||
start: now.startOf('month'),
|
||||
end: now.startOf('month').plus({ months: 1 }),
|
||||
}))
|
||||
.with('quarter', () => ({
|
||||
start: now.startOf('quarter'),
|
||||
end: now.startOf('quarter').plus({ quarters: 1 }),
|
||||
}))
|
||||
.with('year', () => ({
|
||||
start: now.startOf('year'),
|
||||
end: now.startOf('year').plus({ years: 1 }),
|
||||
}))
|
||||
.with('lastMonth', () => ({
|
||||
start: now.startOf('month').minus({ months: 1 }),
|
||||
end: now.startOf('month'),
|
||||
}))
|
||||
.with('last7Days', () => ({
|
||||
start: now.startOf('day').minus({ days: 6 }),
|
||||
end: now.startOf('day').plus({ days: 1 }),
|
||||
}))
|
||||
.with('last30Days', () => ({
|
||||
start: now.startOf('day').minus({ days: 29 }),
|
||||
end: now.startOf('day').plus({ days: 1 }),
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
return {
|
||||
start: start.toJSDate(),
|
||||
end: end.toJSDate(),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
|
||||
import type { DB } from '@documenso/prisma/generated/types';
|
||||
import { DocumentStatus, EnvelopeType, TeamMemberRole } from '@prisma/client';
|
||||
import type { ExpressionBuilder, SelectQueryBuilder } from 'kysely';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { getTeamById } from './get-team';
|
||||
|
||||
// Kysely query builder type for Envelope queries.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type EnvelopeQueryBuilder = SelectQueryBuilder<DB, 'Envelope', any>;
|
||||
|
||||
// Expression builder scoped to the Envelope table context.
|
||||
type EnvelopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
|
||||
|
||||
export type GetTeamAnalyticsOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
senderIds?: number[];
|
||||
};
|
||||
|
||||
export type TeamAnalytics = {
|
||||
sent: number;
|
||||
draft: number;
|
||||
pending: number;
|
||||
completed: number;
|
||||
declined: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute team document-usage analytics for a `[periodStart, periodEnd)` range.
|
||||
*
|
||||
* Each metric counts documents that ENTERED its state during the period, on its
|
||||
* own date axis (see the Documenso team analytics spec):
|
||||
*
|
||||
* - `sent` — non-draft documents created in the period.
|
||||
* - `draft` — documents still in draft, created in the period.
|
||||
* - `pending` — documents still pending, created in the period.
|
||||
* - `completed` — completed documents whose `completedAt` falls in the period.
|
||||
* - `declined` — rejected documents with a `DOCUMENT_RECIPIENT_REJECTED` audit
|
||||
* log entry in the period (there is no `Envelope.rejectedAt`).
|
||||
*
|
||||
* The tiles do NOT sum to `sent`: a document sent in one month but completed the
|
||||
* next lands in that next month's `completed`, never the first month's `sent`.
|
||||
*
|
||||
* Scope mirrors the established team document patterns (`EnvelopeType.DOCUMENT`,
|
||||
* `deletedAt IS NULL`, team `visibilityFilter`) but is limited to documents the
|
||||
* team PRODUCES (`teamId` + owner attribution). Inbox / documents received via a
|
||||
* team email are intentionally excluded. All folders are aggregated. Counts are
|
||||
* exact `COUNT(*)` — the `STATS_COUNT_CAP` used by `getStats` is not applied.
|
||||
*/
|
||||
export const getTeamAnalytics = async ({
|
||||
userId,
|
||||
teamId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
senderIds,
|
||||
}: GetTeamAnalyticsOptions): Promise<TeamAnalytics> => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const currentTeamRole = team.currentTeamRole ?? TeamMemberRole.MEMBER;
|
||||
const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[currentTeamRole];
|
||||
|
||||
// Visibility: the viewer can see documents within their allowed visibilities,
|
||||
// documents they own, or documents they are a recipient of.
|
||||
const visibilityFilter = (eb: EnvelopeExpressionBuilder) =>
|
||||
eb.or([
|
||||
eb(
|
||||
'Envelope.visibility',
|
||||
'in',
|
||||
allowedVisibilities.map((visibility) => sql.lit(visibility)),
|
||||
),
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Recipient')
|
||||
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
|
||||
.where('Recipient.email', '=', user.email)
|
||||
.select(sql.lit(1).as('one')),
|
||||
),
|
||||
]);
|
||||
|
||||
// Base query: team-produced, non-deleted documents across all folders.
|
||||
const buildBaseQuery = (): EnvelopeQueryBuilder => {
|
||||
let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely
|
||||
.selectFrom('Envelope')
|
||||
.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.where('Envelope.teamId', '=', team.id)
|
||||
.where('Envelope.deletedAt', 'is', null)
|
||||
.where(visibilityFilter);
|
||||
|
||||
if (senderIds && senderIds.length > 0) {
|
||||
qb = qb.where('Envelope.userId', 'in', senderIds);
|
||||
}
|
||||
|
||||
return qb;
|
||||
};
|
||||
|
||||
const countEnvelopes = async (qb: EnvelopeQueryBuilder): Promise<number> => {
|
||||
const result = await qb.select(({ fn }) => fn.count<number>('Envelope.id').as('count')).executeTakeFirstOrThrow();
|
||||
|
||||
return Number(result.count ?? 0);
|
||||
};
|
||||
|
||||
// Documents Sent: any non-draft document created in the period.
|
||||
const sentQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT))
|
||||
.where('Envelope.createdAt', '>=', periodStart)
|
||||
.where('Envelope.createdAt', '<', periodEnd);
|
||||
|
||||
// Draft: created in the period, still a draft.
|
||||
const draftQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT))
|
||||
.where('Envelope.createdAt', '>=', periodStart)
|
||||
.where('Envelope.createdAt', '<', periodEnd);
|
||||
|
||||
// Pending: created in the period, still pending.
|
||||
const pendingQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
|
||||
.where('Envelope.createdAt', '>=', periodStart)
|
||||
.where('Envelope.createdAt', '<', periodEnd);
|
||||
|
||||
// Completed: completed in the period (completedAt is a distinct date axis).
|
||||
const completedQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.where('Envelope.completedAt', '>=', periodStart)
|
||||
.where('Envelope.completedAt', '<', periodEnd);
|
||||
|
||||
// Declined: rejected documents whose rejection was logged in the period.
|
||||
const declinedQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED))
|
||||
.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('DocumentAuditLog')
|
||||
.whereRef('DocumentAuditLog.envelopeId', '=', 'Envelope.id')
|
||||
.where('DocumentAuditLog.type', '=', DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED)
|
||||
.where('DocumentAuditLog.createdAt', '>=', periodStart)
|
||||
.where('DocumentAuditLog.createdAt', '<', periodEnd)
|
||||
.select(sql.lit(1).as('one')),
|
||||
),
|
||||
);
|
||||
|
||||
const [sent, draft, pending, completed, declined] = await Promise.all([
|
||||
countEnvelopes(sentQuery),
|
||||
countEnvelopes(draftQuery),
|
||||
countEnvelopes(pendingQuery),
|
||||
countEnvelopes(completedQuery),
|
||||
countEnvelopes(declinedQuery),
|
||||
]);
|
||||
|
||||
return {
|
||||
sent,
|
||||
draft,
|
||||
pending,
|
||||
completed,
|
||||
declined,
|
||||
};
|
||||
};
|
||||
@@ -1,208 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TBulkSendCsvError, TValidateBulkSendCsvResult } from './validate-bulk-send-csv';
|
||||
import { validateBulkSendCsv } from './validate-bulk-send-csv';
|
||||
|
||||
const buildCsv = (headers: string[], rows: string[][]) =>
|
||||
[headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
|
||||
|
||||
const expectFailure = (result: TValidateBulkSendCsvResult): TBulkSendCsvError => {
|
||||
if (result.success) {
|
||||
throw new Error('Expected validation to fail, but it passed');
|
||||
}
|
||||
|
||||
return result.error;
|
||||
};
|
||||
|
||||
describe('validateBulkSendCsv', () => {
|
||||
describe('valid CSVs', () => {
|
||||
it('returns the parsed rows for a valid CSV', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email', 'recipient_1_name'],
|
||||
[
|
||||
['alice@example.com', 'Alice'],
|
||||
['bob@example.com', 'Bob'],
|
||||
],
|
||||
);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: [
|
||||
{ recipient_1_email: 'alice@example.com', recipient_1_name: 'Alice' },
|
||||
{ recipient_1_email: 'bob@example.com', recipient_1_name: 'Bob' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows an empty string email so template defaults can be used', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email', 'recipient_1_name'], [['', 'Alice']]);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows the optional name column to be omitted entirely', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com']]);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows unknown extra columns', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email', 'unrelated_column'], [['alice@example.com', 'anything']]);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates columns for every configured recipient', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email', 'recipient_2_email'],
|
||||
[['alice@example.com', 'bob@example.com']],
|
||||
);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 2 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows exactly the maximum number of rows', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email'],
|
||||
Array.from({ length: 100 }, (_, index) => [`user${index}@example.com`]),
|
||||
);
|
||||
|
||||
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PARSE_ERROR', () => {
|
||||
it('rejects a CSV that cannot be parsed', () => {
|
||||
const csvContent = 'recipient_1_email\n"unclosed quote';
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'PARSE_ERROR' });
|
||||
});
|
||||
|
||||
it('rejects a CSV with inconsistent column counts', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email', 'recipient_1_name'],
|
||||
[['alice@example.com', 'Alice', 'unexpected-extra-value']],
|
||||
);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'PARSE_ERROR' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('EMPTY', () => {
|
||||
it('rejects an empty file', () => {
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent: '', recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'EMPTY' });
|
||||
});
|
||||
|
||||
it('rejects a CSV containing only a header row', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email', 'recipient_1_name'], []);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'EMPTY' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ROW_LIMIT_EXCEEDED', () => {
|
||||
it('rejects a CSV exceeding the default limit of 100 rows', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email'],
|
||||
Array.from({ length: 101 }, (_, index) => [`user${index}@example.com`]),
|
||||
);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'ROW_LIMIT_EXCEEDED', rowCount: 101, maxRows: 100 });
|
||||
});
|
||||
|
||||
it('respects a custom maxRows option', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com'], ['bob@example.com']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1, maxRows: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'ROW_LIMIT_EXCEEDED', rowCount: 2, maxRows: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('MISSING_COLUMNS', () => {
|
||||
it('rejects a CSV missing a required email column', () => {
|
||||
const csvContent = buildCsv(['recipient_1_name'], [['Alice']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toEqual({ type: 'MISSING_COLUMNS', missingColumns: ['recipient_1_email'] });
|
||||
});
|
||||
|
||||
it('reports every missing column', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 3 }));
|
||||
|
||||
expect(error).toEqual({
|
||||
type: 'MISSING_COLUMNS',
|
||||
missingColumns: ['recipient_2_email', 'recipient_3_email'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('INVALID_RECIPIENTS', () => {
|
||||
it('rejects a CSV containing an invalid email', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['not-an-email']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toMatchObject({
|
||||
type: 'INVALID_RECIPIENTS',
|
||||
rowErrors: [{ row: 1, column: 'recipient_1_email' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('references the offending row and column', () => {
|
||||
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com'], ['not-an-email']]);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
|
||||
|
||||
expect(error).toMatchObject({
|
||||
type: 'INVALID_RECIPIENTS',
|
||||
rowErrors: [{ row: 2, column: 'recipient_1_email' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates errors across multiple rows and recipients', () => {
|
||||
const csvContent = buildCsv(
|
||||
['recipient_1_email', 'recipient_2_email'],
|
||||
[
|
||||
['not-an-email', 'bob@example.com'],
|
||||
['alice@example.com', 'also-not-an-email'],
|
||||
],
|
||||
);
|
||||
|
||||
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 2 }));
|
||||
|
||||
expect(error).toMatchObject({
|
||||
type: 'INVALID_RECIPIENTS',
|
||||
rowErrors: [
|
||||
{ row: 1, column: 'recipient_1_email' },
|
||||
{ row: 2, column: 'recipient_2_email' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { parse } from 'csv-parse/sync';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { zEmail } from '../../utils/zod';
|
||||
|
||||
const ZRecipientRowSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.union([
|
||||
zEmail('Value must be a valid email or empty string'),
|
||||
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
|
||||
]),
|
||||
});
|
||||
|
||||
export type TBulkSendCsvRow = Record<string, string | undefined>;
|
||||
|
||||
export type TBulkSendCsvRowError = {
|
||||
/**
|
||||
* The 1-indexed row number the error occurred on, excluding the header row.
|
||||
*/
|
||||
row: number;
|
||||
|
||||
/**
|
||||
* The column the error occurred in, such as `recipient_1_email`.
|
||||
*/
|
||||
column: string;
|
||||
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type TBulkSendCsvError =
|
||||
| { type: 'PARSE_ERROR' }
|
||||
| { type: 'EMPTY' }
|
||||
| { type: 'ROW_LIMIT_EXCEEDED'; rowCount: number; maxRows: number }
|
||||
| { type: 'MISSING_COLUMNS'; missingColumns: string[] }
|
||||
| { type: 'INVALID_RECIPIENTS'; rowErrors: TBulkSendCsvRowError[] };
|
||||
|
||||
export type TValidateBulkSendCsvResult =
|
||||
| { success: true; data: TBulkSendCsvRow[] }
|
||||
| { success: false; error: TBulkSendCsvError };
|
||||
|
||||
export type ValidateBulkSendCsvOptions = {
|
||||
csvContent: string;
|
||||
|
||||
/**
|
||||
* The number of recipients configured on the template, used to derive the
|
||||
* required `recipient_N_email` columns.
|
||||
*/
|
||||
recipientCount: number;
|
||||
|
||||
maxRows?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the CSV provided for a template bulk send.
|
||||
*
|
||||
* Returns a discriminated union so callers can surface structured error
|
||||
* details, such as which rows contain invalid recipients.
|
||||
*/
|
||||
export const validateBulkSendCsv = ({
|
||||
csvContent,
|
||||
recipientCount,
|
||||
maxRows = 100,
|
||||
}: ValidateBulkSendCsvOptions): TValidateBulkSendCsvResult => {
|
||||
let rows: TBulkSendCsvRow[];
|
||||
|
||||
try {
|
||||
rows = parse(csvContent, { columns: true, skip_empty_lines: true });
|
||||
} catch {
|
||||
return { success: false, error: { type: 'PARSE_ERROR' } };
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { success: false, error: { type: 'EMPTY' } };
|
||||
}
|
||||
|
||||
if (rows.length > maxRows) {
|
||||
return { success: false, error: { type: 'ROW_LIMIT_EXCEEDED', rowCount: rows.length, maxRows } };
|
||||
}
|
||||
|
||||
const csvHeaders = Object.keys(rows[0]);
|
||||
|
||||
const requiredHeaders = Array.from({ length: recipientCount }, (_, index) => `recipient_${index + 1}_email`);
|
||||
|
||||
const missingColumns = requiredHeaders.filter((header) => !csvHeaders.includes(header));
|
||||
|
||||
if (missingColumns.length > 0) {
|
||||
return { success: false, error: { type: 'MISSING_COLUMNS', missingColumns } };
|
||||
}
|
||||
|
||||
const rowErrors: TBulkSendCsvRowError[] = [];
|
||||
|
||||
for (const [rowIndex, row] of rows.entries()) {
|
||||
for (let recipientIndex = 0; recipientIndex < recipientCount; recipientIndex += 1) {
|
||||
const nameKey = `recipient_${recipientIndex + 1}_name`;
|
||||
const emailKey = `recipient_${recipientIndex + 1}_email`;
|
||||
|
||||
const parsed = ZRecipientRowSchema.safeParse({
|
||||
name: row[nameKey],
|
||||
email: row[emailKey],
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
rowErrors.push({
|
||||
row: rowIndex + 1,
|
||||
column: emailKey,
|
||||
message: parsed.error.issues?.[0]?.message ?? 'Invalid value',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rowErrors.length > 0) {
|
||||
return { success: false, error: { type: 'INVALID_RECIPIENTS', rowErrors } };
|
||||
}
|
||||
|
||||
return { success: true, data: rows };
|
||||
};
|
||||
@@ -35,6 +35,33 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
|
||||
},
|
||||
});
|
||||
|
||||
// Todo: (RR7) Migrate to use this after RR7.
|
||||
// Note: If we actually ever proceed with this, there are multiple
|
||||
// locations where we will need to update this.
|
||||
// const user = await prisma.$transaction(async (tx) => {
|
||||
// const user = await tx.user.create({
|
||||
// data: {
|
||||
// name,
|
||||
// email: email.toLowerCase(),
|
||||
// password: hashedPassword, // Todo: (RR7) Drop password.
|
||||
// signature,
|
||||
// },
|
||||
// });
|
||||
|
||||
// await tx.account.create({
|
||||
// data: {
|
||||
// userId: user.id,
|
||||
// type: 'emailPassword', // Todo: (RR7)
|
||||
// provider: 'DOCUMENSO', // Todo: (RR7) Enums
|
||||
// providerAccountId: user.id.toString(),
|
||||
// password: hashedPassword,
|
||||
// },
|
||||
// });
|
||||
|
||||
// return user;
|
||||
// });
|
||||
|
||||
// Not used at the moment, uncomment if required.
|
||||
await onCreateUserHook(user).catch((err) => {
|
||||
// Todo: (RR7) Add logging.
|
||||
console.error(err);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user