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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
122 changes: 122 additions & 0 deletions .agents/plans/sharp-gold-mountain-custom-brand-logo-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
---
date: 2026-05-28
title: Custom Brand Logo Url
---

# Problem

`brandingUrl` (the configured "Brand Website") is persisted and editable in branding
settings, but historically it was never consumed anywhere. It flowed into the database,
the settings form, and the admin read-only view, but never affected any rendered output.

We want `brandingUrl` to actually do something, with deliberately different behavior per
surface.

# Relationship we're going for

`brandingUrl` is an **email-only** linking concept. It is intentionally **not** used on
in-app signing surfaces.

| Surface | Custom branding logo configured | `brandingUrl` behavior |
| --- | --- | --- |
| Transactional emails (logo) | Logo shown | Logo links to `brandingUrl` when it is a safe http(s) URL; otherwise plain image |
| Transactional emails (footer) | n/a | `brandingUrl` rendered as a link in the footer when it is a safe http(s) URL |
| Signing pages (V1 + V2, normal + direct-template) | Logo shown | Ignored — logo is a plain image with no link |
| Signing pages (no custom logo) | Documenso fallback shown | Fallback keeps its internal `/` link |
| Embedded signing | Logo shown | Ignored (logo not linked) |
| Embedded authoring/editor | Logo shown | Ignored |
| Settings / admin branding previews | n/a | Unchanged (display only) |

Rationale:

- On signing pages the recipient is mid-task; sending them off to an external marketing
site via the logo is undesirable, so the custom logo is a plain image there.
- In emails the logo and a footer link to the brand's own site are a normal, expected
pattern and reinforce that the email is legitimately from that brand.

# Decisions

## Scope

- Use `brandingUrl` only in transactional email rendering:
- The shared email logo component links the custom branding logo to `brandingUrl`.
- The shared email footer renders `brandingUrl` as a link.
- On signing surfaces, render a configured custom branding logo as a plain image with no
link wrapper. Leave the Documenso fallback logo's internal `/` link untouched.
- Do not change embedded signing, embedded authoring/editor, or settings/admin previews.
- No Prisma schema or database migration. `brandingUrl` already exists and is editable.

## URL safety

Rendering must be defensive because old/imported data can bypass the branding form's URL
validation. Only treat the stored value as a usable Brand Website when it parses as an
absolute `http:` or `https:` URL.

- Empty, missing, invalid, relative, or non-http(s) values are treated as "no Brand
Website" and produce a plain logo / no footer link.
- Do not mutate stored settings or run a cleanup migration.
- Factored into a single shared helper so both email logo and footer apply identical rules:
- `packages/email/utils/branding-url.ts` -> `getSafeBrandingUrl(value): string | null`.

## Email rendering

- New shared component `packages/email/template-components/template-branding-logo.tsx`
(`TemplateBrandingLogo`) renders either:
- the custom branding logo, wrapped in a `Link` to the safe `brandingUrl` with
`target="_blank"` when one exists, or a plain `Img` when not; or
- the Documenso fallback logo (`/static/logo.png`) when custom branding is disabled or
no logo is set.
- This component replaced the duplicated `brandingEnabled && brandingLogo ? <Img/> : <fallback/>`
ternary that was copy-pasted across all transactional email templates.
- `packages/email/template-components/template-footer.tsx` renders `brandingUrl` as a
footer link (via `getSafeBrandingUrl`) when branding is enabled and the URL is safe.

The branding context already exposes `brandingUrl` (`packages/email/providers/branding.tsx`),
populated by `teamGlobalSettingsToBranding` / `organisationGlobalSettingsToBranding`
(which spread `...settings`), so no additional plumbing into the email branding context was
required.

## Signing rendering

- `apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx`:
custom logo renders as a bare `<img>`. `brandingUrl` is not read; the local branding type
and loader payload no longer carry it.
- `apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx` (V2,
shared by normal and direct-template signing): custom logo renders as a bare `<img>`; the
Documenso fallback keeps its `<Link to="/">`.
- `apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx`: V1 loader branding payload no
longer includes `brandingUrl`.
- `packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts` and
`get-envelope-for-direct-template-signing.ts`: `brandingUrl` removed from the V2
`EnvelopeForSigningResponse.settings` schema/payload since it is not consumed there.

# History

An earlier iteration of this plan wired `brandingUrl` into the in-app signing pages so a
custom logo linked to the Brand Website (external `<a target="_blank">`, internal `/`
fallback otherwise) and added `brandingUrl` to the V1/V2 signing payloads. That direction
was reversed: signing-page logos are now plain images and `brandingUrl` is email-only. The
signing payload additions were removed.

# Test coverage

`packages/app-tests/e2e/signing-branding.spec.ts`:

- V1 normal `/sign/:token`: custom logo is a plain image, not inside a link, and no
`brandingUrl` link is present.
- V2 normal `/sign/:token` and V2 direct-template: same plain-image assertions.
- V2 with no custom logo: Documenso fallback still links to `/`.
- Embedded signing: no custom-logo Brand Website link is rendered.

# Acceptance criteria

- A custom branding logo on any signing surface (V1, V2 normal, V2 direct-template, embedded)
renders as a plain image with no link, and `brandingUrl` is never rendered as a link there.
- Documenso fallback logos continue linking to `/`.
- In transactional emails, when a custom logo and a safe `brandingUrl` are configured, the
email logo links to `brandingUrl` (new tab) and the footer shows the Brand Website link.
- In transactional emails, when `brandingUrl` is empty/invalid/relative/non-http(s), the logo
is a plain image and no footer Brand Website link is shown.
- URL safety is enforced through the single shared `getSafeBrandingUrl` helper.
- Settings/admin branding previews are unchanged.
- No schema or migration changes.
146 changes: 146 additions & 0 deletions .agents/plans/wild-indigo-wave-rejected-expired-recipient-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
date: 2026-05-28
title: Rejected Expired Recipient Filters
---

## Context

Customers need to find (a) envelopes/documents in the `REJECTED` state and (b) envelopes
with at least one recipient whose signing link has **expired**. Today the UI only exposes
`INBOX / PENDING / COMPLETED / DRAFT / ALL` tabs, and the public API has no way to filter by
expired recipient links — forcing a fetch-all-`PENDING`-then-inspect-each-recipient workaround.

Two key facts from exploration shaped this plan:

- **`REJECTED` is already fully wired in the backend** — the where-clause (`find-documents.ts`),
stats counts (`get-stats.ts`), tRPC response schema, `ExtendedDocumentStatus` enum, and the
`FRIENDLY_STATUS_MAP` display all handle it. It is simply absent from the UI tab array.
- **Renewing expired links already works.** `resendDocument` refreshes `expiresAt` and clears
`expirationNotifiedAt` for unsigned, non-CC recipients (`resend-document.ts:98-121`), exposed
publicly via `POST /api/v2/document/redistribute` and `/api/v2/envelope/redistribute` and via the
resend/redistribute UI dialogs. No new renew mechanism is needed — only documentation/wording.

Expiration is a per-recipient condition (not an envelope status). The approved design models it
in the UI as an `EXPIRED` **pseudo-status tab** (reusing the existing tab machinery, mirroring how
`REJECTED` works) and in the public API as an orthogonal boolean `hasExpiredRecipients`. Both share
one EXISTS predicate.

Definition of "expired recipient" (matches `isRecipientExpired`, `packages/lib/utils/recipients.ts:118`):
a `Recipient` with `expiresAt IS NOT NULL AND expiresAt <= now() AND signingStatus = NOT_SIGNED AND role != CC`.

## Approach

### A. Shared EXISTS predicate (reused 4x, justified)
Add a local `hasExpiredRecipient(eb)` helper — modeled on the existing per-file `recipientExists` /
`senderEmailIs` helpers — to `find-documents.ts`, `get-stats.ts`, and `find-envelopes.ts`. It is the
single source of truth for the expired condition above (using `new Date()` for `now`, matching the
`period` filter's `.toJSDate()` style).

### B. REJECTED tab (UI only — backend already done)
- `apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx`: add
`ExtendedDocumentStatus.REJECTED` to the tab array (lines 149-155). Count badge, highlight, and
`?status=REJECTED` filtering already work via existing machinery.

### C. EXPIRED pseudo-status (UI + internal stats)
1. `packages/prisma/types/extended-document-status.ts`: add `EXPIRED: 'EXPIRED'`. Internal-only —
the public `DocumentStatus` enum is unaffected. This intentionally surfaces TS errors at the three
exhaustive/`Record<ExtendedDocumentStatus>` sites below, forcing them to be handled.
2. `packages/lib/server-only/document/find-documents.ts`:
- Add `.with(ExtendedDocumentStatus.EXPIRED, ...)` to **both** `applyPersonalFilters` and
`applyTeamFilters`, mirroring the `COMPLETED` branch's access control (deleted + visibility +
owner/recipient access) with `hasExpiredRecipient(eb)` AND-ed in. Do **not** constrain
`Envelope.status` — the EXISTS already restricts to unsigned recipients.
3. `packages/lib/server-only/document/get-stats.ts`:
- Add an `expiredQuery` mirroring `pendingQuery`'s access control + `hasExpiredRecipient(eb)`.
- Add it to the `Promise.all`, add `[ExtendedDocumentStatus.EXPIRED]: expired` to the `stats`
record. **Do not** add `expired` to the `all` sum (it overlaps `PENDING`).
4. `packages/trpc/server/document-router/find-documents-internal.types.ts`: add
`[ExtendedDocumentStatus.EXPIRED]: z.number()` to the `stats` response object. (`status` already
accepts the extended enum via `z.nativeEnum(ExtendedDocumentStatus)`.)
5. `apps/remix/app/components/general/document/document-status.tsx`: add an `EXPIRED` entry to
`FRIENDLY_STATUS_MAP` — `label: msg` Expired, an icon (e.g. lucide `TimerOff`, matching the
`/sign/$token/expired` page), and a distinct color (e.g. `text-orange-500`) to differentiate from
`REJECTED` (red).
6. `documents._index.tsx`: add `[ExtendedDocumentStatus.EXPIRED]: 0` to the `stats` `useState`
initializer and `ExtendedDocumentStatus.EXPIRED` to the tab array. Final order:
`INBOX, PENDING, COMPLETED, DRAFT, REJECTED, EXPIRED, ALL`.
7. (Optional, recommended) `apps/remix/app/components/tables/documents-table-empty-state.tsx`: add
tailored `EXPIRED` and `REJECTED` empty-state copy (currently both fall through to `.otherwise()`).

### D. Public API boolean `hasExpiredRecipients` (document + envelope, v2)
1. `packages/lib/server-only/document/find-documents.ts`: add `hasExpiredRecipients?: boolean` to
`FindDocumentsOptions`; when true, apply `.where((eb) => hasExpiredRecipient(eb))` inside
`buildBaseQuery` (orthogonal/additive to any `status`).
2. `packages/trpc/server/document-router/find-documents.types.ts`: add a query-safe boolean
`hasExpiredRecipients` to `ZFindDocumentsRequestSchema` with a `.describe(...)`. Mirror the
existing boolean-query-param handling in `find-document-audit-logs.types.ts`
(`filterForRecentActivity`) — avoid raw `z.coerce.boolean()` (the "false" -> true footgun); use a
string transform if needed. Pass it through in `find-documents.ts` (public handler).
3. `packages/lib/server-only/envelope/find-envelopes.ts`: add `hasExpiredRecipients?: boolean` to
`FindEnvelopesOptions` + the `hasExpiredRecipient(eb)` helper + the additive `.where`.
4. `packages/trpc/server/envelope-router/find-envelopes.types.ts`: add the same param to
`ZFindEnvelopesRequestSchema`; pass it through in the envelope-router find handler.
The param auto-appears in the generated `/api/v2/openapi.json`.

Note: REST v1 `GET /api/v1/documents` is deprecated and lacks status filtering — left unchanged.
`REJECTED` is already a valid public `status` value (`DocumentStatus.REJECTED`), so no API change is
needed for rejected filtering.

### E. Renew expired links — documentation only
No functional change. Document that resending renews expired links:
- Update the `.description` in `packages/trpc/server/document-router/redistribute-document.types.ts`
and `packages/trpc/server/envelope-router/redistribute-envelope.types.ts` to state that
redistributing refreshes the signing-link expiration for unsigned recipients.
- Optionally adjust resend/redistribute dialog copy
(`apps/remix/app/components/dialogs/document-resend-dialog.tsx`,
`envelope-redistribute-dialog.tsx`) to mention it renews expired links.

## Files To Modify (summary)

| Area | File |
|------|------|
| Enum | `packages/prisma/types/extended-document-status.ts` |
| Where-clause + API option | `packages/lib/server-only/document/find-documents.ts` |
| Stats counts | `packages/lib/server-only/document/get-stats.ts` |
| Envelope find (API) | `packages/lib/server-only/envelope/find-envelopes.ts` |
| Internal tRPC stats schema | `packages/trpc/server/document-router/find-documents-internal.types.ts` |
| Public doc API schema + handler | `packages/trpc/server/document-router/find-documents.types.ts`, `find-documents.ts` |
| Public envelope API schema + handler | `packages/trpc/server/envelope-router/find-envelopes.types.ts`, `find-envelopes.ts` |
| Status display | `apps/remix/app/components/general/document/document-status.tsx` |
| Tabs + stats init | `apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx` |
| Empty state (optional) | `apps/remix/app/components/tables/documents-table-empty-state.tsx` |
| Renew docs | `redistribute-document.types.ts`, `redistribute-envelope.types.ts` (+ resend dialogs, optional) |

## Reused Utilities / Patterns
- `recipientExists` / `senderEmailIs` (per-file Kysely EXISTS helpers) — the template for the new
`hasExpiredRecipient` helper.
- `REJECTED` branches in `find-documents.ts` (lines 279, 416) and `rejectedQuery` in `get-stats.ts`
(line 227) — the template for the `EXPIRED` branches / `expiredQuery`.
- `isRecipientExpired` (`packages/lib/utils/recipients.ts:118`) — defines the `expiresAt <= now`
semantics to match.
- Existing tab machinery in `documents._index.tsx` (`getTabHref`, count badge, personal-org `.filter`)
— works unchanged for the new tabs.
- `resendDocument` / `trpc.document.redistribute` / `trpc.envelope.redistribute` — existing renew path.

## Verification
1. **Typecheck** (the enum change forces all exhaustive/Record sites): `npm run typecheck -w @documenso/remix`.
2. **Seed + UI** (dev server already running): seed a team via `seedTeam`, send a document, then:
- Reject one as a recipient -> it appears under the new **Rejected** tab with a count.
- Force expiry (set a recipient `expiresAt` in the past, e.g. via Prisma Studio or a short
`envelopeExpirationPeriod`) -> the doc appears under the new **Expired** tab with a count, and the
count excludes signed/CC recipients.
3. **Public API**: `GET /api/v2/document?hasExpiredRecipients=true` and
`GET /api/v2/envelope?hasExpiredRecipients=true` (Bearer API token) return only envelopes with >=1
expired unsigned recipient; confirm `GET /api/v2/document?status=REJECTED` works. Verify the param
appears in `/api/v2/openapi.json`.
4. **Renew**: on an expired doc, run resend/redistribute (UI dialog or
`POST /api/v2/document/redistribute`) -> recipient `expiresAt` is refreshed, the doc leaves the
Expired tab, and the signing link no longer redirects to `/sign/$token/expired`.
5. **E2E** (optional): extend `packages/app-tests/e2e/envelopes/envelope-expiration-send.spec.ts`
with an Expired-tab assertion.
6. Do **not** modify/commit `packages/lib/translations/*.po`; run `npm run translate` only if needed
for new `msg`/`Trans` strings, and keep generated `.po` files out of the branch.

## Open Questions
- Exact icon/color for the `EXPIRED` tab (proposed: `TimerOff`, `text-orange-500`).
- Whether to add the optional tailored empty-state copy now or defer.
56 changes: 0 additions & 56 deletions .agents/skills/create-justification/SKILL.md

This file was deleted.

56 changes: 0 additions & 56 deletions .agents/skills/create-plan/SKILL.md

This file was deleted.

Loading
Loading