From f2f7ba28784b11fdea214a19f78e05eca5b3cecb Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Thu, 24 Sep 2026 12:02:06 +0000 Subject: [PATCH] feat(inbox): add partially approved status filter --- .../general/document/document-status.tsx | 13 ++++-- .../tables/documents-table-status-filter.tsx | 14 ++++-- .../app/components/tables/inbox-table.tsx | 29 ++++++++++-- .../app/routes/_authenticated+/inbox.tsx | 4 +- apps/remix/app/utils/inbox-search-params.ts | 27 +++-------- .../app-tests/e2e/api/trpc/find-inbox.spec.ts | 16 ++++++- .../app-tests/e2e/documents/inbox.spec.ts | 38 ++++++++++++++- .../trpc/server/document-router/find-inbox.ts | 46 ++++++++++++++----- .../document-router/find-inbox.types.ts | 5 ++ 9 files changed, 142 insertions(+), 50 deletions(-) diff --git a/apps/remix/app/components/general/document/document-status.tsx b/apps/remix/app/components/general/document/document-status.tsx index 933e955af..eacebaec1 100644 --- a/apps/remix/app/components/general/document/document-status.tsx +++ b/apps/remix/app/components/general/document/document-status.tsx @@ -1,10 +1,11 @@ import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; +import type { TInboxStatus } from '@documenso/trpc/server/document-router/find-inbox.types'; import { SignatureIcon } from '@documenso/ui/icons/signature'; import { cn } from '@documenso/ui/lib/utils'; import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { CheckCircle2, Clock, File, TimerOff, XCircle } from 'lucide-react'; +import { CheckCircle2, Clock, ClockCheck, File, TimerOff, XCircle } from 'lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { HTMLAttributes } from 'react'; @@ -15,13 +16,19 @@ type FriendlyStatus = { color: string; }; -export const FRIENDLY_STATUS_MAP: Record = { +export const FRIENDLY_STATUS_MAP: Record = { PENDING: { label: msg`Pending`, labelExtended: msg`Document pending`, icon: Clock, color: 'text-blue-600 dark:text-blue-300', }, + PARTIALLY_APPROVED: { + label: msg`Partially Approved`, + labelExtended: msg`Document partially approved`, + icon: ClockCheck, + color: 'text-blue-600 dark:text-blue-300', + }, COMPLETED: { label: msg`Completed`, labelExtended: msg`Document completed`, @@ -66,7 +73,7 @@ export const FRIENDLY_STATUS_MAP: Record }; export type DocumentStatusProps = HTMLAttributes & { - status: ExtendedDocumentStatus; + status: ExtendedDocumentStatus | TInboxStatus; inheritColor?: boolean; }; diff --git a/apps/remix/app/components/tables/documents-table-status-filter.tsx b/apps/remix/app/components/tables/documents-table-status-filter.tsx index 0d5f23ddc..e97ab8e08 100644 --- a/apps/remix/app/components/tables/documents-table-status-filter.tsx +++ b/apps/remix/app/components/tables/documents-table-status-filter.tsx @@ -1,14 +1,16 @@ import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { STATS_COUNT_CAP } from '@documenso/lib/constants/document'; +import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { OrganisationType } from '@prisma/client'; import { ListFilterIcon } from 'lucide-react'; -import { useQueryStates } from 'nuqs'; +import { parseAsString, useQueryStates } from 'nuqs'; import { useMemo } from 'react'; +import type { DocumentStatusProps } from '~/components/general/document/document-status'; import { DocumentStatus, FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status'; import { FilterPill } from '~/components/general/filter-pill'; import { documentsSearchParams } from '~/utils/documents-search-params'; @@ -24,7 +26,7 @@ type DocumentsTableStatusFilterProps = { * The statuses available for selection. Defaults to every status that * makes sense for the documents page. */ - statuses?: ExtendedDocumentStatus[]; + statuses?: readonly DocumentStatusProps['status'][]; }; export const DocumentsTableStatusFilter = ({ @@ -35,9 +37,11 @@ export const DocumentsTableStatusFilter = ({ const organisation = useOptionalCurrentOrganisation(); + // Any string is read here since each page offers its own statuses, which + // are matched against the selectable statuses below. const [{ status }, setSearchParams] = useQueryStates( { - status: documentsSearchParams.status, + status: parseAsString, page: documentsSearchParams.page, }, { history: 'push' }, @@ -78,7 +82,7 @@ export const DocumentsTableStatusFilter = ({ options={selectableStatuses.map((value) => ({ value, label: , - trailing: stats ? formatStatsCount(stats[value]) : undefined, + trailing: stats && isExtendedDocumentStatus(value) ? formatStatsCount(stats[value]) : undefined, }))} testId="documents-table-status-filter" /> @@ -86,7 +90,7 @@ export const DocumentsTableStatusFilter = ({ {/* Visually hidden document counts, for screen readers and tests. */} {stats && ( - {[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => ( + {[...selectableStatuses, ExtendedDocumentStatus.ALL].filter(isExtendedDocumentStatus).map((value) => ( {_(FRIENDLY_STATUS_MAP[value].label)}:{' '} {stats[value]} diff --git a/apps/remix/app/components/tables/inbox-table.tsx b/apps/remix/app/components/tables/inbox-table.tsx index 0aec5bf22..83a0fbcee 100644 --- a/apps/remix/app/components/tables/inbox-table.tsx +++ b/apps/remix/app/components/tables/inbox-table.tsx @@ -22,7 +22,7 @@ import { match } from 'ts-pattern'; import { DocumentStatus } from '~/components/general/document/document-status'; import { useOptionalCurrentTeam } from '~/providers/team'; -import { inboxSearchParams, resolveInboxStatus } from '~/utils/inbox-search-params'; +import { inboxSearchParams } from '~/utils/inbox-search-params'; import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog'; import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip'; @@ -32,6 +32,7 @@ type DocumentsTableRow = TFindInboxResponse['data'][number]; export const InboxTable = () => { const { _, i18n } = useLingui(); + const { user } = useSession(); const team = useOptionalCurrentTeam(); const [isPending, startTransition] = useTransition(); @@ -41,7 +42,7 @@ export const InboxTable = () => { history: 'push', }); - const status = resolveInboxStatus(findInboxSearchParams.status); + const status = findInboxSearchParams.status ?? undefined; const query = findInboxSearchParams.query ?? ''; const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery({ @@ -81,7 +82,7 @@ export const InboxTable = () => { { header: _(msg`Status`), accessorKey: 'status', - cell: ({ row }) => , + cell: ({ row }) => , size: 140, }, { @@ -89,7 +90,7 @@ export const InboxTable = () => { cell: ({ row }) => , }, ] satisfies DataTableColumnDef[]; - }, [team]); + }, [team, user.email]); const onPaginationChange = (page: number, perPage: number) => { startTransition(() => { @@ -130,6 +131,9 @@ export const InboxTable = () => { .with({ status: DocumentStatusEnum.COMPLETED }, () => ( Documents that you have completed will appear here )) + .with({ status: 'PARTIALLY_APPROVED' }, () => ( + Documents that are waiting on other recipients will appear here + )) .with({ status: DocumentStatusEnum.REJECTED }, () => ( Documents that have been rejected will appear here )) @@ -259,3 +263,20 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) => )) .otherwise(() =>
); }; + +/** + * Shows a pending document as partially approved once the user has completed + * their part, which matches the inbox status filter. + */ +const getInboxStatus = (row: DocumentsTableRow, email: string) => { + const isWaitingOnOthers = + row.status === DocumentStatusEnum.PENDING && + row.recipients.every( + (recipient) => + recipient.email !== email || + recipient.role === RecipientRole.CC || + recipient.signingStatus !== SigningStatus.NOT_SIGNED, + ); + + return isWaitingOnOthers ? 'PARTIALLY_APPROVED' : row.status; +}; diff --git a/apps/remix/app/routes/_authenticated+/inbox.tsx b/apps/remix/app/routes/_authenticated+/inbox.tsx index fe1b49b2e..5282a6acd 100644 --- a/apps/remix/app/routes/_authenticated+/inbox.tsx +++ b/apps/remix/app/routes/_authenticated+/inbox.tsx @@ -1,3 +1,4 @@ +import { INBOX_STATUSES } from '@documenso/trpc/server/document-router/find-inbox.types'; import { msg } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import { InboxIcon } from 'lucide-react'; @@ -6,7 +7,6 @@ import { DocumentSearch } from '~/components/general/document/document-search'; import { OrganisationInvitations } from '~/components/general/organisations/organisation-invitations'; import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter'; import { InboxTable } from '~/components/tables/inbox-table'; -import { INBOX_SELECTABLE_STATUSES } from '~/utils/inbox-search-params'; import { appMetaTags } from '~/utils/meta'; export function meta() { @@ -34,7 +34,7 @@ export default function InboxPage() { - + diff --git a/apps/remix/app/utils/inbox-search-params.ts b/apps/remix/app/utils/inbox-search-params.ts index e2c900cbe..bc4230121 100644 --- a/apps/remix/app/utils/inbox-search-params.ts +++ b/apps/remix/app/utils/inbox-search-params.ts @@ -1,33 +1,18 @@ -import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; -import { INBOX_STATUSES, type TInboxStatus } from '@documenso/trpc/server/document-router/find-inbox.types'; +import { INBOX_STATUSES } from '@documenso/trpc/server/document-router/find-inbox.types'; +import { parseAsStringLiteral } from 'nuqs'; import { documentsSearchParams } from './documents-search-params'; -/** - * The statuses that can be selected from the inbox status filter. - */ -export const INBOX_SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [...INBOX_STATUSES]; - /** * Shared nuqs parsers for the inbox page URL state. * - * Reuses the documents parsers so the shared filter components - * (`DocumentSearch`, `DocumentsTableStatusFilter`) read and write the same - * params on both pages. + * Reuses the documents parsers so the shared `DocumentSearch` component reads + * and writes the same params on both pages. An unsupported `status` parses to + * `null`, which shows every non-draft document. */ export const inboxSearchParams = { - status: documentsSearchParams.status, + status: parseAsStringLiteral(INBOX_STATUSES), page: documentsSearchParams.page, perPage: documentsSearchParams.perPage, query: documentsSearchParams.query, }; - -/** - * Narrows the URL `status` param to a status supported by the inbox. - * - * Returns `undefined` when it is missing or not selectable, which shows every - * non-draft document. - */ -export const resolveInboxStatus = (status: ExtendedDocumentStatus | null): TInboxStatus | undefined => { - return INBOX_STATUSES.find((value) => value === status); -}; diff --git a/packages/app-tests/e2e/api/trpc/find-inbox.spec.ts b/packages/app-tests/e2e/api/trpc/find-inbox.spec.ts index b5c47177e..9645f9012 100644 --- a/packages/app-tests/e2e/api/trpc/find-inbox.spec.ts +++ b/packages/app-tests/e2e/api/trpc/find-inbox.spec.ts @@ -10,7 +10,7 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; import { seedUser } from '@documenso/prisma/seed/users'; import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; -import { DocumentStatus, EnvelopeType, RecipientRole, TeamMemberRole } from '@prisma/client'; +import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus, TeamMemberRole } from '@prisma/client'; import { apiSignin, apiSignout } from '../../fixtures/authentication'; @@ -345,7 +345,7 @@ test.describe('Inbox Find - Status Filter Hardening', () => { } // Sanity check that the valid filters, and the unfiltered view, never include the draft. - for (const status of ['PENDING', 'COMPLETED', 'REJECTED', 'CANCELLED', undefined]) { + for (const status of ['PENDING', 'PARTIALLY_APPROVED', 'COMPLETED', 'REJECTED', 'CANCELLED', undefined]) { const { res, data } = await trpcInboxFind(page, { status }); expect(res.ok(), `status "${status}" should be accepted`).toBeTruthy(); @@ -358,6 +358,7 @@ test.describe('Inbox Find - Status Filter Hardening', () => { test('should scope each status filter to exactly that status', async ({ page }) => { const { user: sender, team: senderTeam } = await seedUser(); const { user: recipient } = await seedUser(); + const { user: otherRecipient } = await seedUser(); await seedPendingDocument(sender, senderTeam.id, [recipient], { createDocumentOptions: { title: 'Scoped Pending Document' }, @@ -375,10 +376,21 @@ test.describe('Inbox Find - Status Filter Hardening', () => { createDocumentOptions: { title: 'Scoped Rejected Document', status: DocumentStatus.REJECTED }, }); + // The recipient has signed, but the other recipient has not. + const partiallyApproved = await seedPendingDocument(sender, senderTeam.id, [recipient, otherRecipient], { + createDocumentOptions: { title: 'Scoped Partially Approved Document' }, + }); + + await prisma.recipient.updateMany({ + where: { envelopeId: partiallyApproved.id, email: recipient.email }, + data: { signingStatus: SigningStatus.SIGNED }, + }); + await apiSignin({ page, email: recipient.email }); const expectations = [ { status: 'PENDING', expected: ['Scoped Pending Document'] }, + { status: 'PARTIALLY_APPROVED', expected: ['Scoped Partially Approved Document'] }, { status: 'COMPLETED', expected: ['Scoped Completed Document'] }, { status: 'CANCELLED', expected: ['Scoped Cancelled Document'] }, { status: 'REJECTED', expected: ['Scoped Rejected Document'] }, diff --git a/packages/app-tests/e2e/documents/inbox.spec.ts b/packages/app-tests/e2e/documents/inbox.spec.ts index e88027028..ec67b261d 100644 --- a/packages/app-tests/e2e/documents/inbox.spec.ts +++ b/packages/app-tests/e2e/documents/inbox.spec.ts @@ -10,7 +10,7 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; import { seedUser } from '@documenso/prisma/seed/users'; import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; -import { DocumentStatus, RecipientRole, TeamMemberRole } from '@prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus, TeamMemberRole } from '@prisma/client'; import { apiSignin } from '../fixtures/authentication'; @@ -37,6 +37,7 @@ const searchInbox = async (page: Page, query: string) => { // spelling "Canceled" for the `Cancelled` source string. const INBOX_STATUS_LABELS = { [DocumentStatus.PENDING]: 'Pending', + PARTIALLY_APPROVED: 'Partially Approved', [DocumentStatus.COMPLETED]: 'Completed', [DocumentStatus.REJECTED]: 'Rejected', [DocumentStatus.CANCELLED]: 'Canceled', @@ -125,6 +126,41 @@ test.describe('Inbox - Search & Status Filter', () => { } }); + test('should show signed pending documents as partially approved', async ({ page }) => { + const { user: sender, team: senderTeam } = await seedUser(); + const { user: recipient } = await seedUser(); + const { user: otherRecipient } = await seedUser(); + + await seedPendingDocument(sender, senderTeam.id, [recipient, otherRecipient], { + createDocumentOptions: { title: 'Inbox Unsigned Document' }, + }); + + const signed = await seedPendingDocument(sender, senderTeam.id, [recipient, otherRecipient], { + createDocumentOptions: { title: 'Inbox Signed Document' }, + }); + + await prisma.recipient.updateMany({ + where: { envelopeId: signed.id, email: recipient.email }, + data: { signingStatus: SigningStatus.SIGNED }, + }); + + await apiSignin({ page, email: recipient.email, redirectPath: '/inbox' }); + + await expect(inboxRow(page, 'Inbox Unsigned Document')).toContainText('Pending'); + await expect(inboxRow(page, 'Inbox Signed Document')).toContainText('Partially Approved'); + + await selectInboxStatus(page, DocumentStatus.PENDING); + + await expect(inboxRow(page, 'Inbox Unsigned Document')).toBeVisible(); + await expect(inboxRow(page, 'Inbox Signed Document')).not.toBeVisible(); + + await selectInboxStatus(page, 'PARTIALLY_APPROVED'); + + await expect(page.getByTestId('documents-table-status-filter')).toContainText('Partially Approved'); + await expect(inboxRow(page, 'Inbox Signed Document')).toBeVisible(); + await expect(inboxRow(page, 'Inbox Unsigned Document')).not.toBeVisible(); + }); + test('should filter documents by title', async ({ page }) => { const { user: sender, team: senderTeam } = await seedUser(); const { user: recipient } = await seedUser(); diff --git a/packages/trpc/server/document-router/find-inbox.ts b/packages/trpc/server/document-router/find-inbox.ts index 72d7a39a3..9d2a7efcc 100644 --- a/packages/trpc/server/document-router/find-inbox.ts +++ b/packages/trpc/server/document-router/find-inbox.ts @@ -2,7 +2,8 @@ import type { FindResultResponse } from '@documenso/lib/types/search-params'; import { mapEnvelopesToDocumentMany } from '@documenso/lib/utils/document'; import { prisma } from '@documenso/prisma'; import type { Envelope, Prisma } from '@prisma/client'; -import { DocumentStatus, EnvelopeType, RecipientRole } from '@prisma/client'; +import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client'; +import { match, P } from 'ts-pattern'; import { authenticatedProcedure } from '../trpc'; import { type TInboxStatus, ZFindInboxRequestSchema, ZFindInboxResponseSchema } from './find-inbox.types'; @@ -63,20 +64,41 @@ export const findInbox = async ({ userId, page = 1, perPage = 10, query = '', st const orderByDirection = orderBy?.direction ?? 'desc'; const searchQuery = query.trim(); + const userRecipient = { + email: user.email, + role: { + not: RecipientRole.CC, + }, + } satisfies Prisma.RecipientWhereInput; + + const unsignedUserRecipient = { + ...userRecipient, + signingStatus: SigningStatus.NOT_SIGNED, + } satisfies Prisma.RecipientWhereInput; + + const statusWhere = match(status) + .with(undefined, () => ({ + status: { not: DocumentStatus.DRAFT }, + recipients: { some: userRecipient }, + })) + .with(DocumentStatus.PENDING, () => ({ + status: DocumentStatus.PENDING, + recipients: { some: unsignedUserRecipient }, + })) + .with('PARTIALLY_APPROVED', () => ({ + status: DocumentStatus.PENDING, + recipients: { some: userRecipient, none: unsignedUserRecipient }, + })) + .with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED, DocumentStatus.CANCELLED), (value) => ({ + status: value, + recipients: { some: userRecipient }, + })) + .exhaustive() satisfies Prisma.EnvelopeWhereInput; + const whereClause: Prisma.EnvelopeWhereInput = { type: EnvelopeType.DOCUMENT, - status: status ?? { - not: DocumentStatus.DRAFT, - }, deletedAt: null, - recipients: { - some: { - email: user.email, - role: { - not: RecipientRole.CC, - }, - }, - }, + ...statusWhere, }; if (searchQuery.length > 0) { diff --git a/packages/trpc/server/document-router/find-inbox.types.ts b/packages/trpc/server/document-router/find-inbox.types.ts index ddbcebb55..0b26f1d9b 100644 --- a/packages/trpc/server/document-router/find-inbox.types.ts +++ b/packages/trpc/server/document-router/find-inbox.types.ts @@ -10,9 +10,14 @@ import { z } from 'zod'; * * Every document status except DRAFT, since drafts have not been sent to * recipients yet and must never be visible in the inbox. + * + * PENDING only covers documents that still need the user to act. + * PARTIALLY_APPROVED covers pending documents that the user has completed and + * that wait on other recipients. */ export const INBOX_STATUSES = [ DocumentStatus.PENDING, + 'PARTIALLY_APPROVED', DocumentStatus.COMPLETED, DocumentStatus.REJECTED, DocumentStatus.CANCELLED,