From a99efdc9164d4fece386db03e82a9703d2849668 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 7 Aug 2023 23:10:27 +1000 Subject: [PATCH] feat: add inbox --- apps/web/package.json | 2 + apps/web/src/app/(dashboard)/inbox/page.tsx | 14 + .../(dashboard)/inbox/inbox-content.tsx | 82 ++++ .../(dashboard)/inbox/inbox.actions.ts | 24 ++ .../components/(dashboard)/inbox/inbox.tsx | 352 ++++++++++++++++++ .../(dashboard)/inbox/inbox.utils.ts | 23 ++ .../(dashboard)/layout/desktop-nav.tsx | 22 +- apps/web/src/hooks/use-debounced-value.ts | 18 + apps/web/tailwind.config.js | 1 + package-lock.json | 2 + .../template-document-completed.tsx | 71 ++++ .../template-document-invite.tsx | 59 +++ .../template-document-pending.tsx | 52 +++ .../template-components/template-footer.tsx | 22 ++ .../email/templates/document-completed.tsx | 91 +---- packages/email/templates/document-invite.tsx | 73 ++-- packages/email/templates/document-pending.tsx | 66 +--- .../server-only/document/find-documents.ts | 111 +++++- packages/prisma/types/document.ts | 12 + packages/tailwind-config/index.cjs | 5 + .../trpc/server/document-router/router.ts | 28 ++ .../trpc/server/document-router/schema.ts | 13 + 22 files changed, 966 insertions(+), 177 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/inbox/page.tsx create mode 100644 apps/web/src/components/(dashboard)/inbox/inbox-content.tsx create mode 100644 apps/web/src/components/(dashboard)/inbox/inbox.actions.ts create mode 100644 apps/web/src/components/(dashboard)/inbox/inbox.tsx create mode 100644 apps/web/src/components/(dashboard)/inbox/inbox.utils.ts create mode 100644 apps/web/src/hooks/use-debounced-value.ts create mode 100644 packages/email/template-components/template-document-completed.tsx create mode 100644 packages/email/template-components/template-document-invite.tsx create mode 100644 packages/email/template-components/template-document-pending.tsx create mode 100644 packages/email/template-components/template-footer.tsx create mode 100644 packages/prisma/types/document.ts diff --git a/apps/web/package.json b/apps/web/package.json index c0a3035ea..22a75128f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "formidable": "^2.1.1", "framer-motion": "^10.12.8", "lucide-react": "^0.214.0", + "luxon": "^3.4.0", "micro": "^10.0.1", "nanoid": "^4.0.2", "next": "13.4.12", @@ -43,6 +44,7 @@ }, "devDependencies": { "@types/formidable": "^2.0.6", + "@types/luxon": "^3.3.1", "@types/node": "20.1.0", "@types/react": "18.2.18", "@types/react-dom": "18.2.7" diff --git a/apps/web/src/app/(dashboard)/inbox/page.tsx b/apps/web/src/app/(dashboard)/inbox/page.tsx new file mode 100644 index 000000000..badb421c9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/inbox/page.tsx @@ -0,0 +1,14 @@ +import Inbox from '~/components/(dashboard)/inbox/inbox'; + +export default function InboxPage() { + return ( +
+

Inbox

+

Documents which you have been requested to sign.

+ +
+ +
+
+ ); +} diff --git a/apps/web/src/components/(dashboard)/inbox/inbox-content.tsx b/apps/web/src/components/(dashboard)/inbox/inbox-content.tsx new file mode 100644 index 000000000..f7e263f1f --- /dev/null +++ b/apps/web/src/components/(dashboard)/inbox/inbox-content.tsx @@ -0,0 +1,82 @@ +import { TemplateDocumentCompleted } from '@documenso/email/template-components/template-document-completed'; +import { TemplateDocumentInvite } from '@documenso/email/template-components/template-document-invite'; +import { DocumentWithRecipientAndSender } from '@documenso/prisma/types/document'; +import { cn } from '@documenso/ui/lib/utils'; + +import { formatInboxDate } from './inbox.utils'; + +export type InboxContentProps = { + document: DocumentWithRecipientAndSender; +}; + +export default function InboxContent({ document }: InboxContentProps) { + const inboxDocumentStatusIndicator = ( + +
+ + {document.recipient.signingStatus === 'SIGNED' ? 'Signed' : 'Pending'} +
+ ); + + return ( +
+
+
+

{document.subject}

+

+ {document.sender.name} <{document.sender.email}> +

+
+ +
+ {/* Todo: This needs to be updated to when the document was sent to the recipient when that value is available. */} +

{formatInboxDate(document.created)}

+ + {inboxDocumentStatusIndicator} +
+
+ +
+ {inboxDocumentStatusIndicator} +
+ + {/* Todo: get correct URLs */} +
+ {document.recipient.signingStatus === 'NOT_SIGNED' && ( + + )} + + {document.recipient.signingStatus === 'SIGNED' && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/(dashboard)/inbox/inbox.actions.ts b/apps/web/src/components/(dashboard)/inbox/inbox.actions.ts new file mode 100644 index 000000000..38b50a8b3 --- /dev/null +++ b/apps/web/src/components/(dashboard)/inbox/inbox.actions.ts @@ -0,0 +1,24 @@ +'use server'; + +import { z } from 'zod'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { prisma } from '@documenso/prisma'; + +export async function updateRecipientReadStatus(recipientId: number, documentId: number) { + z.number().parse(recipientId); + z.number().parse(documentId); + + const { email } = await getRequiredServerComponentSession(); + + await prisma.recipient.update({ + where: { + id: recipientId, + documentId, + email, + }, + data: { + readStatus: 'OPENED', + }, + }); +} diff --git a/apps/web/src/components/(dashboard)/inbox/inbox.tsx b/apps/web/src/components/(dashboard)/inbox/inbox.tsx new file mode 100644 index 000000000..c76ba3d94 --- /dev/null +++ b/apps/web/src/components/(dashboard)/inbox/inbox.tsx @@ -0,0 +1,352 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +import { Inbox as InboxIcon } from 'lucide-react'; +import { z } from 'zod'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { SigningStatus } from '@documenso/prisma/client'; +import { DocumentWithRecipientAndSender } from '@documenso/prisma/types/document'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Input } from '@documenso/ui/primitives/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; + +import { useDebouncedValue } from '~/hooks/use-debounced-value'; + +import InboxContent from './inbox-content'; +import { updateRecipientReadStatus } from './inbox.actions'; +import { formatInboxDate } from './inbox.utils'; + +export const ZInboxSearchParamsSchema = z.object({ + filter: z + .union([z.literal('SIGNED'), z.literal('NOT_SIGNED'), z.undefined()]) + .catch(() => undefined), + id: z + .string() + .optional() + .catch(() => undefined), + query: z + .string() + .optional() + .catch(() => undefined), +}); + +export type InboxProps = { + className?: string; +}; + +const numberOfSkeletons = 3; + +export default function Inbox(props: InboxProps) { + const { className } = props; + + const pathname = usePathname(); + const searchParams = useSearchParams(); + const router = useRouter(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZInboxSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); + + const [searchQuery, setSearchQuery] = useState(() => parsedSearchParams.query || ''); + + const [readStatusState, setReadStatusState] = useState<{ + [documentId: string]: 'ERROR' | 'UPDATED' | 'UPDATING'; + }>({}); + + const [isInitialLoad, setIsInitialLoad] = useState(true); + + const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); + + const { + data, + error, + fetchNextPage, + fetchPreviousPage, + hasNextPage, + hasPreviousPage, + isFetching, + isFetchingNextPage, + isFetchingPreviousPage, + refetch, + } = trpc.document.searchInboxDocuments.useInfiniteQuery( + { + query: parsedSearchParams.query, + filter: parsedSearchParams.filter, + }, + { + getPreviousPageParam: (firstPage) => + firstPage.currentPage > 1 ? firstPage.currentPage - 1 : undefined, + getNextPageParam: (lastPage) => + lastPage.currentPage < lastPage.totalPages ? lastPage.currentPage + 1 : undefined, + }, + ); + + /** + * The current documents in the inbox after filters and queries have been applied. + */ + const inboxDocuments = (data?.pages ?? []).flatMap((page) => page.data); + + /** + * The currently selected document in the inbox. + */ + const selectedDocument: DocumentWithRecipientAndSender | null = + inboxDocuments.find((item) => item.id.toString() === parsedSearchParams.id) ?? null; + + /** + * Remove the ID from the query if it is not found in the result. + */ + useEffect(() => { + if (!selectedDocument && parsedSearchParams.id && data) { + updateSearchParams({ + id: null, + }); + } + }, [data, selectedDocument, parsedSearchParams.id]); + + /** + * Handle debouncing the seach query. + */ + useEffect(() => { + if (!pathname) { + return; + } + + const params = new URLSearchParams(searchParams?.toString()); + + params.set('query', debouncedSearchQuery); + + if (debouncedSearchQuery === '') { + params.delete('query'); + } + + router.push(`${pathname}?${params.toString()}`); + }, [debouncedSearchQuery]); + + useEffect(() => { + if (!isFetching) { + setIsInitialLoad(false); + } + }, [isFetching]); + + const updateReadStatusState = (documentId: number, value: (typeof readStatusState)[string]) => { + setReadStatusState({ + ...readStatusState, + [documentId]: value, + }); + }; + + /** + * Handle selecting the selected document to display and updating the read status if required. + */ + const onSelectedDocumentChange = (value: DocumentWithRecipientAndSender) => { + if (!pathname) { + return; + } + + // Update the read status. + if ( + value.recipient.readStatus === 'NOT_OPENED' && + readStatusState[value.id] !== 'UPDATED' && + readStatusState[value.id] !== 'UPDATING' + ) { + updateReadStatusState(value.id, 'UPDATING'); + + updateRecipientReadStatus(value.recipient.id, value.id) + .then(() => { + updateReadStatusState(value.id, 'UPDATED'); + }) + .catch(() => { + updateReadStatusState(value.id, 'ERROR'); + }); + } + + const params = new URLSearchParams(searchParams?.toString()); + + params.set('id', value.id.toString()); + + router.push(`${pathname}?${params.toString()}`); + }; + + if (error) { + return ( +
+

Something went wrong while loading your inbox.

+ +
+ ); + } + + return ( +
+
+ {/* Header with search and filter options. */} +
+ setSearchQuery(e.target.value)} + /> + +
+ +
+
+ +
+ {/* Handle rendering no items found. */} + {!isFetching && inboxDocuments.length === 0 && ( +
+

No documents found.

+
+ )} + + {hasPreviousPage && !isFetchingPreviousPage && ( + + )} + +
    + {/* Handle rendering skeleton on first load. */} + {isFetching && + isInitialLoad && + !data && + Array.from({ length: numberOfSkeletons }).map((_, i) => ( +
  • + + +
    +
    + + +
    + + + + +
    +
  • + ))} + + {/* Handle rendering list of inbox documents. */} + {inboxDocuments.map((item, i) => ( +
  • + + + {/* Mobile inbox content. */} + {selectedDocument?.id === item.id && ( +
    + +
    + )} +
  • + ))} +
+ + {hasNextPage && !isFetchingNextPage && ( + + )} +
+
+ + {/* Desktop inbox content. */} +
+ {selectedDocument ? ( + + ) : ( +
+ +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/(dashboard)/inbox/inbox.utils.ts b/apps/web/src/components/(dashboard)/inbox/inbox.utils.ts new file mode 100644 index 000000000..9a6aff9f3 --- /dev/null +++ b/apps/web/src/components/(dashboard)/inbox/inbox.utils.ts @@ -0,0 +1,23 @@ +import { DateTime } from 'luxon'; + +/** + * Format the provided date into a readable string for inboxes. + * + * @param dateValue The date or date string + * @returns The date in the current locale, or the date formatted as HH:MM AM/PM if the provided date is after 12:00AM of the current date + */ +export const formatInboxDate = (dateValue: string | Date): string => { + const date = + typeof dateValue === 'string' ? DateTime.fromISO(dateValue) : DateTime.fromJSDate(dateValue); + + const startOfTheDay = DateTime.now().startOf('day'); + + if (date >= startOfTheDay) { + return date.toFormat('h:mma'); + } + + return date.toLocaleString({ + ...DateTime.DATE_SHORT, + year: '2-digit', + }); +}; diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index f07d2baf4..6d0e629b4 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -2,15 +2,19 @@ import { HTMLAttributes } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + import { cn } from '@documenso/ui/lib/utils'; export type DesktopNavProps = HTMLAttributes; export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + const pathname = usePathname(); + return ( ); }; diff --git a/apps/web/src/hooks/use-debounced-value.ts b/apps/web/src/hooks/use-debounced-value.ts new file mode 100644 index 000000000..3c57f4aa0 --- /dev/null +++ b/apps/web/src/hooks/use-debounced-value.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +export function useDebouncedValue(value: T, delay: number) { + // State and setters for debounced value + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index c7f6f1a19..fcc9f7032 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -7,5 +7,6 @@ module.exports = { content: [ ...baseConfig.content, `${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`, + `${path.join(require.resolve('@documenso/email'), '..')}/**/*.{ts,tsx}`, ], }; diff --git a/package-lock.json b/package-lock.json index 736a9e0e1..0b6f72f42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,6 +79,7 @@ "formidable": "^2.1.1", "framer-motion": "^10.12.8", "lucide-react": "^0.214.0", + "luxon": "^3.4.0", "micro": "^10.0.1", "nanoid": "^4.0.2", "next": "13.4.12", @@ -101,6 +102,7 @@ }, "devDependencies": { "@types/formidable": "^2.0.6", + "@types/luxon": "^3.3.1", "@types/node": "20.1.0", "@types/react": "18.2.18", "@types/react-dom": "18.2.7" diff --git a/packages/email/template-components/template-document-completed.tsx b/packages/email/template-components/template-document-completed.tsx new file mode 100644 index 000000000..b64b13cff --- /dev/null +++ b/packages/email/template-components/template-document-completed.tsx @@ -0,0 +1,71 @@ +import { Button, Img, Section, Tailwind, Text } from '@react-email/components'; + +import * as config from '@documenso/tailwind-config'; + +export interface TemplateDocumentCompletedProps { + downloadLink: string; + reviewLink: string; + documentName: string; + assetBaseUrl: string; +} + +export const TemplateDocumentCompleted = ({ + downloadLink, + reviewLink, + documentName, + assetBaseUrl, +}: TemplateDocumentCompletedProps) => { + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + +
+
+ Documenso +
+ + + + Completed + + + + “{documentName}” was signed by all signers + + + + Continue by downloading or reviewing the document. + + +
+ + +
+
+
+ ); +}; + +export default TemplateDocumentCompleted; diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx new file mode 100644 index 000000000..bf2fb905e --- /dev/null +++ b/packages/email/template-components/template-document-invite.tsx @@ -0,0 +1,59 @@ +import { Button, Img, Section, Tailwind, Text } from '@react-email/components'; + +import * as config from '@documenso/tailwind-config'; + +export interface TemplateDocumentInviteProps { + inviterName: string; + inviterEmail: string; + documentName: string; + signDocumentLink: string; + assetBaseUrl: string; +} + +export const TemplateDocumentInvite = ({ + inviterName, + documentName, + signDocumentLink, + assetBaseUrl, +}: TemplateDocumentInviteProps) => { + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + +
+
+ Documenso +
+ + + {inviterName} has invited you to sign "{documentName}" + + + + Continue by signing the document. + + +
+ +
+
+
+ ); +}; + +export default TemplateDocumentInvite; diff --git a/packages/email/template-components/template-document-pending.tsx b/packages/email/template-components/template-document-pending.tsx new file mode 100644 index 000000000..80387b783 --- /dev/null +++ b/packages/email/template-components/template-document-pending.tsx @@ -0,0 +1,52 @@ +import { Img, Section, Tailwind, Text } from '@react-email/components'; + +import * as config from '@documenso/tailwind-config'; + +export interface TemplateDocumentPendingProps { + documentName: string; + assetBaseUrl: string; +} + +export const TemplateDocumentPending = ({ + documentName, + assetBaseUrl, +}: TemplateDocumentPendingProps) => { + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + +
+
+ Documenso +
+ + + + Waiting for others + + + + “{documentName}” has been signed + + + + We're still waiting for other signers to sign this document. +
+ We'll notify you as soon as it's ready. +
+
+
+ ); +}; + +export default TemplateDocumentPending; diff --git a/packages/email/template-components/template-footer.tsx b/packages/email/template-components/template-footer.tsx new file mode 100644 index 000000000..ee395a1e9 --- /dev/null +++ b/packages/email/template-components/template-footer.tsx @@ -0,0 +1,22 @@ +import { Link, Section, Text } from '@react-email/components'; + +export const TemplateFooter = () => { + return ( +
+ + This document was sent using{' '} + + Documenso. + + + + + Documenso +
+ 2261 Market Street, #5211, San Francisco, CA 94114, USA +
+
+ ); +}; + +export default TemplateFooter; diff --git a/packages/email/templates/document-completed.tsx b/packages/email/templates/document-completed.tsx index 0d4f70601..9152d5822 100644 --- a/packages/email/templates/document-completed.tsx +++ b/packages/email/templates/document-completed.tsx @@ -1,25 +1,23 @@ import { Body, - Button, Container, Head, Html, Img, - Link, Preview, Section, Tailwind, - Text, } from '@react-email/components'; import config from '@documenso/tailwind-config'; -interface DocumentCompletedEmailTemplateProps { - downloadLink?: string; - reviewLink?: string; - documentName?: string; - assetBaseUrl?: string; -} +import { + TemplateDocumentCompleted, + TemplateDocumentCompletedProps, +} from '../template-components/template-document-completed'; +import TemplateFooter from '../template-components/template-footer'; + +export type DocumentCompletedEmailTemplateProps = Partial; export const DocumentCompletedEmailTemplate = ({ downloadLink = 'https://documenso.com', @@ -50,74 +48,23 @@ export const DocumentCompletedEmailTemplate = ({
- Documenso Logo + Documenso Logo -
-
- Documenso -
- - - - Completed - - - - “{documentName}” was signed by all signers - - - - Continue by downloading or reviewing the document. - - -
- - -
-
+
-
- - This document was sent using{' '} - - Documenso. - - - - - Documenso -
- 2261 Market Street, #5211, San Francisco, CA 94114, USA -
-
+
diff --git a/packages/email/templates/document-invite.tsx b/packages/email/templates/document-invite.tsx index 90a32b7bf..465685649 100644 --- a/packages/email/templates/document-invite.tsx +++ b/packages/email/templates/document-invite.tsx @@ -1,6 +1,5 @@ import { Body, - Button, Container, Head, Hr, @@ -15,13 +14,13 @@ import { import config from '@documenso/tailwind-config'; -interface DocumentInviteEmailTemplateProps { - inviterName?: string; - inviterEmail?: string; - documentName?: string; - signDocumentLink?: string; - assetBaseUrl?: string; -} +import { + TemplateDocumentInvite, + TemplateDocumentInviteProps, +} from '../template-components/template-document-invite'; +import TemplateFooter from '../template-components/template-footer'; + +export type DocumentInviteEmailTemplateProps = Partial; export const DocumentInviteEmailTemplate = ({ inviterName = 'Lucas Smith', @@ -51,36 +50,21 @@ export const DocumentInviteEmailTemplate = ({ >
- -
- Documenso Logo + +
+ Documenso Logo -
-
- Documenso -
- - - {inviterName} has invited you to sign "{documentName}" - - - - Continue by signing the document. - - -
- -
-
+
@@ -102,20 +86,7 @@ export const DocumentInviteEmailTemplate = ({
-
- - This document was sent using{' '} - - Documenso. - - - - - Documenso -
- 2261 Market Street, #5211, San Francisco, CA 94114, USA -
-
+
diff --git a/packages/email/templates/document-pending.tsx b/packages/email/templates/document-pending.tsx index 03a69554f..0ed768747 100644 --- a/packages/email/templates/document-pending.tsx +++ b/packages/email/templates/document-pending.tsx @@ -4,19 +4,20 @@ import { Head, Html, Img, - Link, Preview, Section, Tailwind, - Text, } from '@react-email/components'; import config from '@documenso/tailwind-config'; -interface DocumentPendingEmailTemplateProps { - documentName?: string; - assetBaseUrl?: string; -} +import { + TemplateDocumentPending, + TemplateDocumentPendingProps, +} from '../template-components/template-document-pending'; +import { TemplateFooter } from '../template-components/template-footer'; + +export type DocumentPendingEmailTemplateProps = Partial; export const DocumentPendingEmailTemplate = ({ documentName = 'Open Source Pledge.pdf', @@ -43,55 +44,20 @@ export const DocumentPendingEmailTemplate = ({ >
- -
- Documenso Logo + +
+ Documenso Logo -
-
- Documenso -
- - - - Waiting for others - - - - “{documentName}” has been signed - - - - We're still waiting for other signers to sign this document. -
- We'll notify you as soon as it's ready. -
-
+
-
- - This document was sent using{' '} - - Documenso. - - - - - Documenso -
- 2261 Market Street, #5211, San Francisco, CA 94114, USA -
-
+
diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 41e9c858a..4b209f611 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -1,5 +1,6 @@ import { prisma } from '@documenso/prisma'; -import { Document, DocumentStatus, Prisma } from '@documenso/prisma/client'; +import { Document, DocumentStatus, Prisma, SigningStatus } from '@documenso/prisma/client'; +import { DocumentWithRecipientAndSender } from '@documenso/prisma/types/document'; import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient'; import { FindResultSet } from '../../types/find-result-set'; @@ -68,3 +69,111 @@ export const findDocuments = async ({ totalPages: Math.ceil(count / perPage), }; }; + +export interface FindDocumentsWithRecipientAndSenderOptions { + email: string; + query?: string; + signingStatus?: SigningStatus; + page?: number; + perPage?: number; + orderBy?: { + column: keyof Omit; + direction: 'asc' | 'desc'; + }; +} + +export const findDocumentsWithRecipientAndSender = async ({ + email, + query, + signingStatus, + page = 1, + perPage = 20, + orderBy, +}: FindDocumentsWithRecipientAndSenderOptions): Promise< + FindResultSet +> => { + const orderByColumn = orderBy?.column ?? 'created'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + const filters: Prisma.DocumentWhereInput = { + Recipient: { + some: { + email, + signingStatus, + }, + }, + }; + + if (query) { + filters.OR = [ + { + User: { + email: { + contains: query, + mode: 'insensitive', + }, + }, + // Todo: Add filter for `Subject`. + }, + ]; + } + + const [data, count] = await Promise.all([ + prisma.document.findMany({ + select: { + id: true, + created: true, + title: true, + status: true, + userId: true, + User: { + select: { + id: true, + name: true, + email: true, + }, + }, + Recipient: { + where: { + email, + signingStatus, + }, + }, + }, + where: { + ...filters, + }, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + }), + prisma.document.count({ + where: { + ...filters, + }, + }), + ]); + + return { + data: data.map((item) => { + const { User, Recipient, ...rest } = item; + + const subject = undefined; // Todo. + const description = undefined; // Todo. + + return { + ...rest, + sender: User, + recipient: Recipient[0], + subject: subject ?? 'Please sign this document', + description: description ?? `${User.name} has invited you to sign "${item.title}"`, + }; + }), + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + }; +}; diff --git a/packages/prisma/types/document.ts b/packages/prisma/types/document.ts new file mode 100644 index 000000000..35a6a33b5 --- /dev/null +++ b/packages/prisma/types/document.ts @@ -0,0 +1,12 @@ +import { Document, Recipient } from '@documenso/prisma/client'; + +export type DocumentWithRecipientAndSender = Omit & { + recipient: Recipient; + sender: { + id: number; + name: string | null; + email: string; + }; + subject: string; + description: string; +}; diff --git a/packages/tailwind-config/index.cjs b/packages/tailwind-config/index.cjs index d2892f69f..1564454d8 100644 --- a/packages/tailwind-config/index.cjs +++ b/packages/tailwind-config/index.cjs @@ -115,6 +115,11 @@ module.exports = { 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out', }, + screens: { + '3xl': '1920px', + '4xl': '2560px', + '5xl': '3840px', + }, }, }, plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')], diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index f20643327..1f790dc24 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -1,17 +1,45 @@ import { TRPCError } from '@trpc/server'; +import { findDocumentsWithRecipientAndSender } from '@documenso/lib/server-only/document/find-documents'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { authenticatedProcedure, router } from '../trpc'; import { + ZSearchInboxDocumentsParamsSchema, ZSendDocumentMutationSchema, ZSetFieldsForDocumentMutationSchema, ZSetRecipientsForDocumentMutationSchema, } from './schema'; export const documentRouter = router({ + searchInboxDocuments: authenticatedProcedure + .input(ZSearchInboxDocumentsParamsSchema) + .query(async ({ input, ctx }) => { + try { + const { filter, query, cursor: page } = input; + + return await findDocumentsWithRecipientAndSender({ + email: ctx.session.email, + query, + signingStatus: filter, + orderBy: { + column: 'created', + direction: 'desc', + }, + page, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Something went wrong. Please try again later.', + }); + } + }), + setRecipientsForDocument: authenticatedProcedure .input(ZSetRecipientsForDocumentMutationSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 18c3a93ae..8ea107de3 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -2,6 +2,19 @@ import { z } from 'zod'; import { FieldType } from '@documenso/prisma/client'; +export const ZSearchInboxDocumentsParamsSchema = z.object({ + filter: z + .union([z.literal('SIGNED'), z.literal('NOT_SIGNED'), z.undefined()]) + .catch(() => undefined), + cursor: z.number().default(1), + query: z + .string() + .optional() + .catch(() => undefined), +}); + +export type TSearchInboxDocumentsParamsSchema = z.infer; + export const ZSetRecipientsForDocumentMutationSchema = z.object({ documentId: z.number(), recipients: z.array(