Compare commits

..

25 Commits

Author SHA1 Message Date
98b2da5018 v1.9.0-rc.5 2024-12-26 13:41:04 +11:00
fc1f76b543 fix: checkbox logic (#1537)
## Description

<!--- Describe the changes introduced by this pull request. -->
<!--- Explain what problem it solves or what feature/fix it adds. -->

## Related Issue

<!--- If this pull request is related to a specific issue, reference it
here using #issue_number. -->
<!--- For example, "Fixes #123" or "Addresses #456". -->

## Changes Made

<!--- Provide a summary of the changes made in this pull request. -->
<!--- Include any relevant technical details or architecture changes.
-->

- Change 1
- Change 2
- ...

## Testing Performed

<!--- Describe the testing that you have performed to validate these
changes. -->
<!--- Include information about test cases, testing environments, and
results. -->

- Tested feature X in scenario Y.
- Ran unit tests for component Z.
- Tested on browsers A, B, and C.
- ...

## Checklist

<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->

- [ ] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [ ] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.

## Additional Notes

<!--- Provide any additional context or notes for the reviewers. -->
<!--- This might include details about design decisions, potential
concerns, or anything else relevant. -->
2024-12-23 12:06:47 +02:00
22c9fb777b fix: perf improvements 2024-12-18 15:01:57 +11:00
2da051a7f9 v1.9.0-rc.4 2024-12-18 08:14:50 +11:00
390a317bd3 fix: normalize pdf on the server 2024-12-18 08:14:14 +11:00
c161553d1d feat: add disabled property for user (#1546) 2024-12-17 22:35:59 +11:00
c960a48b4f fix: z-index of field settings (#1469) 2024-12-17 17:09:58 +11:00
9502f4361d fix: fieldtooltip blocking the field click (#1538)
## Before (error)


https://github.com/user-attachments/assets/525e6c04-fc03-41a7-8299-2a753e9e9fa6

## After (fixed)


https://github.com/user-attachments/assets/67f7e592-c5ca-47f4-962c-e4a848522d43
2024-12-17 17:04:20 +11:00
82deab41f4 fix: move permission check outside the document visibility component (#1543)
PR created because of this comment
https://github.com/documenso/documenso/pull/1521#discussion_r1881895305.
2024-12-17 17:03:08 +11:00
2245812f0b fix: document visibility logic (#1521)
Update the logic of document visibility logic and added some tests &
updated some existing ones.
2024-12-16 09:10:40 +02:00
861e9c976b v1.9.0-rc.3 2024-12-16 09:35:33 +11:00
f55808199b feat: make enterprise billing dynamic (#1539) 2024-12-14 13:44:25 +09:00
b4a7f1887d feat: add trpc openapi (#1535) 2024-12-14 01:23:35 +09:00
f73441ee85 chore: prevent user selection within signature pad (#1530)
adds a `select-none` class to the signature pad in order to
prevent iPadOS from becoming too trigger happy with the context menu and
auto corrects. Please ensure this doesn't break anything by accident.
2024-12-13 16:02:26 +11:00
d7de3b08c1 fix: darkmode on radio button and checkbox labels (#1518)
Fixed Radio Button and Checkbox Appearance in Dark Mode
2024-12-13 15:55:40 +11:00
7d201f05d9 fix: admin leaderboard query (#1522)
Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2024-12-13 15:50:52 +11:00
a21ee2cea6 v1.9.0-rc.2 2024-12-13 15:16:26 +11:00
4ad46b81c9 fix: prevent hidden layers from being toggled in pdf viewers (#1528)
https://github.com/user-attachments/assets/e10194ca-212b-40ee-b9a1-85ef54829a40

---------

Co-authored-by: Mythie <me@lucasjamessmith.me>
2024-12-13 14:19:55 +11:00
10b8e785e0 fix: clear invalid drawn signature when switching to typed signature (#1536) 2024-12-13 10:40:22 +11:00
5fbed783fc feat: add controls for sending completion emails to document owners (#1534)
Adds a new `ownerDocumentCompleted` to the email settings that controls
whether a document will be sent to the owner upon completion.

This was previously the only email you couldn't disable and didn't
account for users integrating with just the API and Webhooks.

Also adds a flag to the public `sendDocument` endpoint which will adjust
this setting while sendint the document for users who aren't using
`emailSettings` on the `createDocument` endpoint.
2024-12-12 14:24:07 +11:00
c9fe134852 v1.9.0-rc.1 2024-12-12 10:31:44 +11:00
f2149719e3 fix: resolve issue with embed css injection 2024-12-12 10:14:23 +11:00
161d40cde7 fix: secure passkey cookies (#1533) 2024-12-12 01:16:29 +09:00
76028771b8 fix: add billing leeway (#1532) 2024-12-12 01:10:01 +09:00
5df1a6602e fix: refactor search routes (#1529)
Refactor find endpoints to be consistent in terms of input and output.
2024-12-11 19:39:50 +09:00
129 changed files with 3077 additions and 1609 deletions

View File

@ -17,23 +17,25 @@ The default document visibility option allows you to control who can view and ac
The default document visibility is set to "_EVERYONE_" by default. You can change this setting by going to the [team's general preferences page](/users/teams/preferences) and selecting a different visibility option.
<Callout type="warning">
If the team member uploading the document has a role lower than the default document visibility,
the document visibility will be set to a lower visibility level matching the team member's role.
</Callout>
Here's how it works:
- If a user with the "_Member_" role creates a document and the default document visibility is set to "_Admin_" or "_Managers and above_", the document's visibility is set to "_Everyone_".
- If a user with the "_Manager_" role creates a document and the default document visibility is set to "_Admin_", the document's visibility is set to "_Managers and above_".
- Otherwise, the document's visibility is set to the default document visibility.
- If a user with the "_Member_" role creates a document and the default document visibility is set to "_Everyone_", the document's visibility is set to "_EVERYONE_".
- The user can't change the visibility of the document in the document editor.
- If a user with the "_Member_" role creates a document and the default document visibility is set to "_Admin_" or "_Managers and above_", the document's visibility is set to the default document visibility ("_Admin_" or "_Managers and above_" in this case).
- The user can't change the visibility of the document in the document editor.
- If a user with the "_Manager_" role creates a document and the default document visibility is set to "_Everyone_" or "_Managers and above_", the document's visibility is set to the default document visibility ("_Everyone_" or "_Managers and above_" in this case).
- The user can change the visibility of the document to any of these options, except "_Admin_", in the document editor.
- If a user with the "_Manager_" role creates a document and the default document visibility is set to "_Admin_", the document's visibility is set to "_Admin_".
- The user can't change the visibility of the document in the document editor.
- If a user with the "_Admin_" role creates a document, and the default document visibility is set to "_Everyone_", "_Managers and above_", or "_Admin_", the document's visibility is set to the default document visibility.
- The user can change the visibility of the document to any of these options in the document editor.
You can change the visibility of a document at any time by editing the document and selecting a different visibility option.
![A screenshot of the Documenso's document editor page where you can update the document visibility](/teams/document-visibility-settings.webp)
<Callout type="warning">
Updating the default document visibility in the team's general settings will not affect the
Updating the default document visibility in the team's general preferences will not affect the
visibility of existing documents. You will need to update the visibility of each document
individually.
</Callout>

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/marketing",
"version": "1.9.0-rc.0",
"version": "1.9.0-rc.5",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@ -47,7 +47,7 @@
"recharts": "^2.7.2",
"sharp": "0.32.6",
"typescript": "5.2.2",
"zod": "^3.23.8"
"zod": "3.24.1"
},
"devDependencies": {
"@lingui/loader": "^4.11.3",

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.9.0-rc.0",
"version": "1.9.0-rc.5",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@ -56,10 +56,11 @@
"recharts": "^2.7.2",
"remeda": "^2.17.3",
"sharp": "0.32.6",
"trpc-openapi": "^1.2.0",
"ts-pattern": "^5.0.5",
"ua-parser-js": "^1.0.37",
"uqr": "^0.1.2",
"zod": "^3.23.8"
"zod": "3.24.1"
},
"devDependencies": {
"@documenso/tailwind-config": "*",

View File

@ -40,7 +40,7 @@ export const AdminDocumentResults = () => {
const { data: findDocumentsData, isLoading: isFindDocumentsLoading } =
trpc.admin.findDocuments.useQuery(
{
term: debouncedTerm,
query: debouncedTerm,
page: page || 1,
perPage: perPage || 20,
},

View File

@ -134,7 +134,7 @@ export const LeaderboardTable = ({
startTransition(() => {
updateSearchParams({
sortBy: column,
sortOrder: sortOrder === 'asc' ? 'desc' : 'asc',
sortOrder: sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc',
});
});
};

View File

@ -37,10 +37,8 @@ export const DocumentPageViewRecentActivity = ({
{
documentId,
filterForRecentActivity: true,
orderBy: {
column: 'createdAt',
direction: 'asc',
},
orderByColumn: 'createdAt',
orderByDirection: 'asc',
perPage: 10,
},
{

View File

@ -12,8 +12,8 @@ import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import type { TGetDocumentWithDetailsByIdResponse } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -35,7 +35,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
export type EditDocumentFormProps = {
className?: string;
initialDocument: DocumentWithDetails;
initialDocument: TGetDocumentWithDetailsByIdResponse;
documentRootPath: string;
isDocumentEnterprise: boolean;
};
@ -103,7 +103,7 @@ export const EditDocumentForm = ({
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newFields) => {
onSuccess: ({ fields: newFields }) => {
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
@ -134,7 +134,7 @@ export const EditDocumentForm = ({
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newRecipients) => {
onSuccess: ({ recipients: newRecipients }) => {
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,

View File

@ -11,7 +11,7 @@ import type { DateTimeFormatOptions } from 'luxon';
import { UAParser } from 'ua-parser-js';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { trpc } from '@documenso/trpc/react';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
@ -35,9 +35,7 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isInitialLoading, isLoadingError } =
trpc.document.findDocumentAuditLogs.useQuery(

View File

@ -9,8 +9,8 @@ import { DateTime } from 'luxon';
import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import type { TFindDocumentsResponse } from '@documenso/lib/server-only/document/find-documents';
import type { Team } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
@ -24,13 +24,7 @@ import { DataTableActionDropdown } from './data-table-action-dropdown';
import { DataTableTitle } from './data-table-title';
export type DocumentsDataTableProps = {
results: FindResultSet<
Document & {
Recipient: Recipient[];
User: Pick<User, 'id' | 'name' | 'email'>;
team: Pick<Team, 'id' | 'url'> | null;
}
>;
results: TFindDocumentsResponse;
showSenderColumn?: boolean;
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
};

View File

@ -83,7 +83,7 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
perPage,
period,
senderIds,
search,
query: search,
});
const getTabHref = (value: typeof status) => {

View File

@ -75,7 +75,7 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
const enabledPrivateDirectTemplates = useMemo(
() =>
(data?.templates ?? []).filter(
(data?.data ?? []).filter(
(template): template is DirectTemplate =>
template.directLink?.enabled === true && template.type !== TemplateType.PUBLIC,
),

View File

@ -52,7 +52,7 @@ export const PublicTemplatesDataTable = () => {
);
const { directTemplates, publicDirectTemplates, privateDirectTemplates } = useMemo(() => {
const directTemplates = (data?.templates ?? []).filter(
const directTemplates = (data?.data ?? []).filter(
(template): template is DirectTemplate => template.directLink?.enabled === true,
);

View File

@ -12,7 +12,7 @@ import { UAParser } from 'ua-parser-js';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
@ -33,9 +33,7 @@ export const UserSecurityActivityDataTable = () => {
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isInitialLoading, isLoadingError } =
trpc.profile.findUserSecurityAuditLogs.useQuery(

View File

@ -9,7 +9,7 @@ import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
@ -27,9 +27,7 @@ export const UserPasskeysDataTable = () => {
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery(
{

View File

@ -12,7 +12,7 @@ import { DateTime } from 'luxon';
import { z } from 'zod';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import type { Team } from '@documenso/prisma/client';
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@ -40,7 +40,7 @@ const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
TEMPLATE_DIRECT_LINK: msg`Direct link`,
};
const ZTemplateSearchParamsSchema = ZBaseTableSearchParamsSchema.extend({
const ZDocumentSearchParamsSchema = ZUrlSearchParamsSchema.extend({
source: z
.nativeEnum(DocumentSource)
.optional()
@ -49,10 +49,6 @@ const ZTemplateSearchParamsSchema = ZBaseTableSearchParamsSchema.extend({
.nativeEnum(DocumentStatusEnum)
.optional()
.catch(() => undefined),
search: z.coerce
.string()
.optional()
.catch(() => undefined),
});
type TemplatePageViewDocumentsTableProps = {
@ -69,7 +65,7 @@ export const TemplatePageViewDocumentsTable = ({
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZTemplateSearchParamsSchema.parse(
const parsedSearchParams = ZDocumentSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
@ -80,7 +76,7 @@ export const TemplatePageViewDocumentsTable = ({
teamId: team?.id,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
search: parsedSearchParams.search,
query: parsedSearchParams.query,
source: parsedSearchParams.source,
status: parsedSearchParams.status,
},

View File

@ -26,10 +26,8 @@ export const TemplatePageViewRecentActivity = ({
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({
templateId,
teamId,
orderBy: {
column: 'createdAt',
direction: 'asc',
},
orderByColumn: 'createdAt',
orderByDirection: 'asc',
perPage: 5,
});

View File

@ -4,8 +4,10 @@ import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
@ -51,10 +53,20 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
});
onOpenChange(false);
},
onError: (error) => {
onError: (err) => {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(
AppErrorCode.NOT_FOUND,
() => msg`Template not found or already associated with a team.`,
)
.with(AppErrorCode.UNAUTHORIZED, () => msg`You are not a member of this team.`)
.otherwise(() => msg`An error occurred while moving the template.`);
toast({
title: _(msg`Error`),
description: error.message || _(msg`An error occurred while moving the template.`),
description: _(errorMessage),
variant: 'destructive',
duration: 7500,
});

View File

@ -29,7 +29,7 @@ export const TemplatesPageView = async ({ searchParams = {}, team }: TemplatesPa
const documentRootPath = formatDocumentsPath(team?.url);
const templateRootPath = formatTemplatesPath(team?.url);
const { templates, totalPages } = await findTemplates({
const { data: templates, totalPages } = await findTemplates({
userId: user.id,
teamId: team?.id,
page: page,

View File

@ -12,6 +12,7 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import { fromCheckboxValue, toCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react';
@ -54,6 +55,7 @@ export const CheckboxField = ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const [checkedValues, setCheckedValues] = useState(
values
?.map((item) =>
@ -97,7 +99,7 @@ export const CheckboxField = ({
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: checkedValues.join(','),
value: toCheckboxValue(checkedValues),
isBase64: true,
authOptions,
};
@ -191,7 +193,7 @@ export const CheckboxField = ({
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: updatedValues.join(','),
value: toCheckboxValue(checkedValues),
isBase64: true,
});
}
@ -228,6 +230,11 @@ export const CheckboxField = ({
}
}, [checkedValues, isLengthConditionMet, field.inserted]);
const parsedCheckedValues = useMemo(
() => fromCheckboxValue(field.customText),
[field.customText],
);
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Checkbox">
{isLoading && (
@ -277,9 +284,7 @@ export const CheckboxField = ({
className="h-3 w-3"
checkClassName="text-white"
id={`checkbox-${index}`}
checked={field.customText
.split(',')
.some((customValue) => customValue === itemValue)}
checked={parsedCheckedValues.includes(itemValue)}
disabled={isLoading}
onCheckedChange={() => void handleCheckboxOptionClick(item)}
/>

View File

@ -11,7 +11,7 @@ import { useLingui } from '@lingui/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { NEXT_PUBLIC_WEBAPP_URL, WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@ -30,13 +30,11 @@ export const CurrentUserTeamsDataTable = () => {
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeams.useQuery(
{
term: parsedSearchParams.query,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},

View File

@ -9,7 +9,7 @@ import { useLingui } from '@lingui/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
@ -27,15 +27,13 @@ export const PendingUserTeamsDataTable = () => {
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const [checkoutPendingTeamId, setCheckoutPendingTeamId] = useState<number | null>(null);
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamsPending.useQuery(
{
term: parsedSearchParams.query,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},

View File

@ -10,7 +10,7 @@ import { History, MoreHorizontal, Trash2 } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
@ -38,15 +38,13 @@ export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTabl
const { _, i18n } = useLingui();
const { toast } = useToast();
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isInitialLoading, isLoadingError } =
trpc.team.findTeamMemberInvites.useQuery(
{
teamId,
term: parsedSearchParams.query,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},

View File

@ -10,7 +10,7 @@ import { Edit, MoreHorizontal, Trash2 } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import type { TeamMemberRole } from '@documenso/prisma/client';
@ -50,14 +50,12 @@ export const TeamMembersDataTable = ({
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamMembers.useQuery(
{
teamId,
term: parsedSearchParams.query,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},

View File

@ -53,6 +53,7 @@ const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect',
[ErrorCode.UNVERIFIED_EMAIL]:
'This account has not been verified. Please verify your account before signing in.',
[ErrorCode.ACCOUNT_DISABLED]: 'This account has been disabled. Please contact support.',
};
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;

View File

@ -0,0 +1,47 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { createOpenApiNextHandler } from 'trpc-openapi';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildLogger } from '@documenso/lib/utils/logger';
import type { TRPCError } from '@documenso/trpc/server';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
const logger = buildLogger();
export default createOpenApiNextHandler<typeof appRouter>({
router: appRouter,
createContext: async ({ req, res }: { req: NextApiRequest; res: NextApiResponse }) =>
createTrpcContext({ req, res }),
onError: ({ error, path }: { error: TRPCError; path?: string }) => {
// Always log the error for now.
console.error(error.message);
const appError = AppError.parseError(error.cause || error);
const isAppError = error.cause instanceof AppError;
// Only log AppErrors that are explicitly set to 500 or the error code
// is in the errorCodesToAlertOn list.
const isLoggableAppError =
isAppError && (appError.statusCode === 500 || errorCodesToAlertOn.includes(appError.code));
// Only log TRPC errors that are in the `errorCodesToAlertOn` list and is
// not an AppError.
const isLoggableTrpcError = !isAppError && errorCodesToAlertOn.includes(error.code);
if (isLoggableAppError || isLoggableTrpcError) {
logger.error(error, {
method: path,
context: {
source: '/v2/api',
appError: AppError.toJSON(appError),
},
});
}
},
responseMeta: () => {},
});
const errorCodesToAlertOn = [AppErrorCode.UNKNOWN_ERROR, 'INTERNAL_SERVER_ERROR'];

View File

@ -0,0 +1,9 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { openApiDocument } from '@documenso/trpc/server/open-api';
const handler = (_req: NextApiRequest, res: NextApiResponse) => {
res.status(200).send(openApiDocument);
};
export default handler;

View File

@ -45,6 +45,7 @@ export default trpcNext.createNextApiHandler({
logger.error(error, {
method: path,
context: {
source: 'trpc',
appError: AppError.toJSON(appError),
},
});

388
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.9.0-rc.0",
"version": "1.9.0-rc.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.9.0-rc.0",
"version": "1.9.0-rc.5",
"workspaces": [
"apps/*",
"packages/*"
@ -17,8 +17,10 @@
"@lingui/core": "^4.11.3",
"inngest-cli": "^0.29.1",
"luxon": "^3.5.0",
"mupdf": "^1.0.0",
"next-runtime-env": "^3.2.0",
"react": "^18"
"react": "^18",
"zod": "3.24.1"
},
"devDependencies": {
"@commitlint/cli": "^17.7.1",
@ -79,7 +81,7 @@
},
"apps/marketing": {
"name": "@documenso/marketing",
"version": "1.9.0-rc.0",
"version": "1.9.0-rc.5",
"license": "AGPL-3.0",
"dependencies": {
"@documenso/assets": "*",
@ -115,7 +117,7 @@
"recharts": "^2.7.2",
"sharp": "0.32.6",
"typescript": "5.2.2",
"zod": "^3.23.8"
"zod": "3.24.1"
},
"devDependencies": {
"@lingui/loader": "^4.11.3",
@ -492,7 +494,7 @@
},
"apps/web": {
"name": "@documenso/web",
"version": "1.9.0-rc.0",
"version": "1.9.0-rc.5",
"license": "AGPL-3.0",
"dependencies": {
"@documenso/api": "*",
@ -536,10 +538,11 @@
"recharts": "^2.7.2",
"remeda": "^2.17.3",
"sharp": "0.32.6",
"trpc-openapi": "^1.2.0",
"ts-pattern": "^5.0.5",
"ua-parser-js": "^1.0.37",
"uqr": "^0.1.2",
"zod": "^3.23.8"
"zod": "3.24.1"
},
"devDependencies": {
"@documenso/tailwind-config": "*",
@ -3494,6 +3497,11 @@
"node": ">=6"
}
},
"node_modules/@hapi/bourne": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz",
"integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w=="
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@ -10717,15 +10725,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@trigger.dev/cli/node_modules/zod": {
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz",
"integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@trigger.dev/core": {
"version": "2.3.18",
"resolved": "https://registry.npmjs.org/@trigger.dev/core/-/core-2.3.18.tgz",
@ -10747,14 +10746,6 @@
"node": ">=18.0.0"
}
},
"node_modules/@trigger.dev/core/node_modules/zod": {
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz",
"integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@trigger.dev/nextjs": {
"version": "2.3.18",
"resolved": "https://registry.npmjs.org/@trigger.dev/nextjs/-/nextjs-2.3.18.tgz",
@ -10818,14 +10809,6 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/@trigger.dev/sdk/node_modules/zod": {
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz",
"integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@trigger.dev/yalt": {
"version": "2.3.18",
"resolved": "https://registry.npmjs.org/@trigger.dev/yalt/-/yalt-2.3.18.tgz",
@ -10842,15 +10825,6 @@
"node": ">=18.0.0"
}
},
"node_modules/@trigger.dev/yalt/node_modules/zod": {
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz",
"integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@trivago/prettier-plugin-sort-imports": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.3.0.tgz",
@ -12007,6 +11981,18 @@
"node": ">=6.5"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/acorn": {
"version": "8.11.3",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
@ -14311,6 +14297,21 @@
}
}
},
"node_modules/co-body": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/co-body/-/co-body-6.2.0.tgz",
"integrity": "sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==",
"dependencies": {
"@hapi/bourne": "^3.0.0",
"inflation": "^2.0.0",
"qs": "^6.5.2",
"raw-body": "^2.3.3",
"type-is": "^1.6.16"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/code-block-writer": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-12.0.0.tgz",
@ -14612,6 +14613,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/consola": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz",
"integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
@ -14627,6 +14636,17 @@
"simple-wcswidth": "^1.0.1"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
@ -14712,9 +14732,9 @@
}
},
"node_modules/cookie-es": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.0.0.tgz",
"integrity": "sha512-mWYvfOLrfEc996hlKcdABeIiPHUPC6DM2QYZdGGOvhOTbA3tjm2eBwqlJpoFdjC89NI4Qt6h0Pu06Mp+1Pj5OQ=="
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz",
"integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="
},
"node_modules/copy-anything": {
"version": "3.0.5",
@ -14880,6 +14900,14 @@
"node": ">= 8"
}
},
"node_modules/crossws": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.1.tgz",
"integrity": "sha512-HsZgeVYaG+b5zA+9PbIPGq4+J/CJynJuearykPsXx4V/eMhyQ5EDVg3Ak2FBZtVXCiOLu/U7IiwDHTr9MA+IKw==",
"dependencies": {
"uncrypto": "^0.1.3"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
@ -15587,6 +15615,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="
},
"node_modules/degenerator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
@ -15693,6 +15726,11 @@
"node": ">=6"
}
},
"node_modules/destr": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.3.tgz",
"integrity": "sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ=="
},
"node_modules/detect-indent": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz",
@ -18123,6 +18161,14 @@
}
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/from": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
@ -18793,6 +18839,23 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/h3": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.13.0.tgz",
"integrity": "sha512-vFEAu/yf8UMUcB4s43OaDaigcqpQd14yanmOsn+NcRX3/guSKncyE2rOYhq8RIchgJrPSs/QiIddnTTR1ddiAg==",
"dependencies": {
"cookie-es": "^1.2.2",
"crossws": ">=0.2.0 <0.4.0",
"defu": "^6.1.4",
"destr": "^2.0.3",
"iron-webcrypto": "^1.2.1",
"ohash": "^1.1.4",
"radix3": "^1.1.2",
"ufo": "^1.5.4",
"uncrypto": "^0.1.3",
"unenv": "^1.10.0"
}
},
"node_modules/hard-rejection": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz",
@ -19844,6 +19907,14 @@
"integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==",
"dev": true
},
"node_modules/inflation": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/inflation/-/inflation-2.1.0.tgz",
"integrity": "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/inflection": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/inflection/-/inflection-2.0.1.tgz",
@ -20000,14 +20071,6 @@
"node": ">=6"
}
},
"node_modules/inngest/node_modules/zod": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.5.tgz",
"integrity": "sha512-HqnGsCdVZ2xc0qWPLdO25WnseXThh0kEYKIdV5F/hTHO75hNZFp8thxSeHhiPrHZKrFTo1SOgkAj9po5bexZlw==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/input-otp": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.2.4.tgz",
@ -20219,6 +20282,14 @@
"node": ">= 10"
}
},
"node_modules/iron-webcrypto": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
"integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==",
"funding": {
"url": "https://github.com/sponsors/brc-dd"
}
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@ -22035,8 +22106,7 @@
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"dev": true
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
@ -22863,6 +22933,14 @@
"esbuild": "0.*"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/memfs": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz",
@ -22917,6 +22995,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge-refs": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-1.2.2.tgz",
@ -22990,6 +23076,14 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/micro": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/micro/-/micro-10.0.1.tgz",
@ -23733,6 +23827,17 @@
"node": ">=8.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@ -24139,6 +24244,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/mupdf": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/mupdf/-/mupdf-1.0.0.tgz",
"integrity": "sha512-AWT27abYSX5gQmWs7+jDEtmGJpWyZrqdxROpYf5BDAJBA+iYqlNztk2EMlKvuRLBzajj00kf4PtFiergDSKDTg==",
"license": "AGPL-3.0-or-later"
},
"node_modules/mute-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz",
@ -24196,7 +24307,6 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
@ -24665,6 +24775,11 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-fetch-native": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.4.tgz",
"integrity": "sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ=="
},
"node_modules/node-gyp": {
"version": "9.4.1",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz",
@ -24993,6 +25108,38 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/node-mocks-http": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/node-mocks-http/-/node-mocks-http-1.16.2.tgz",
"integrity": "sha512-2Sh6YItRp1oqewZNlck3LaFp5vbyW2u51HX2p1VLxQ9U/bG90XV8JY9O7Nk+HDd6OOn/oV3nA5Tx5k4Rki0qlg==",
"dependencies": {
"accepts": "^1.3.7",
"content-disposition": "^0.5.3",
"depd": "^1.1.0",
"fresh": "^0.5.2",
"merge-descriptors": "^1.0.1",
"methods": "^1.1.2",
"mime": "^1.3.4",
"parseurl": "^1.3.3",
"range-parser": "^1.2.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"@types/express": "^4.17.21 || ^5.0.0",
"@types/node": "*"
},
"peerDependenciesMeta": {
"@types/express": {
"optional": true
},
"@types/node": {
"optional": true
}
}
},
"node_modules/node-releases": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
@ -25580,6 +25727,11 @@
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
"dev": true
},
"node_modules/ohash": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.4.tgz",
"integrity": "sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g=="
},
"node_modules/oidc-token-hash": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz",
@ -25681,6 +25833,11 @@
}
}
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="
},
"node_modules/openapi3-ts": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-2.0.2.tgz",
@ -26440,6 +26597,14 @@
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/partysocket": {
"version": "0.0.17",
"resolved": "https://registry.npmjs.org/partysocket/-/partysocket-0.0.17.tgz",
@ -26595,8 +26760,7 @@
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="
},
"node_modules/pathval": {
"version": "1.1.1",
@ -27908,6 +28072,11 @@
"node": ">=8"
}
},
"node_modules/radix3": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
"integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="
},
"node_modules/raf-schd": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
@ -27946,6 +28115,14 @@
"safe-buffer": "^5.1.0"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz",
@ -32782,6 +32959,32 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/trpc-openapi": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/trpc-openapi/-/trpc-openapi-1.2.0.tgz",
"integrity": "sha512-pfYoCd/3KYXWXvUPZBKJw455OOwngKN/6SIcj7Yit19OMLJ+8yVZkEvGEeg5wUSwfsiTdRsKuvqkRPXVSwV7ew==",
"workspaces": [
".",
"examples/with-nextjs",
"examples/with-express",
"examples/with-interop",
"examples/with-serverless",
"examples/with-fastify",
"examples/with-nuxtjs"
],
"dependencies": {
"co-body": "^6.1.0",
"h3": "^1.6.4",
"lodash.clonedeep": "^4.5.0",
"node-mocks-http": "^1.12.2",
"openapi-types": "^12.1.1",
"zod-to-json-schema": "^3.21.1"
},
"peerDependencies": {
"@trpc/server": "^10.0.0",
"zod": "^3.14.4"
}
},
"node_modules/ts-api-utils": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz",
@ -33547,6 +33750,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typed-array-buffer": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz",
@ -33665,10 +33880,9 @@
}
},
"node_modules/ufo": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.4.0.tgz",
"integrity": "sha512-Hhy+BhRBleFjpJ2vchUNN40qgkh0366FWJGqVLYBHev0vpHTrXSA0ryT+74UiW6KWsldNurQMKGqCm1M2zBciQ==",
"dev": true
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
"integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ=="
},
"node_modules/ulid": {
"version": "2.3.0",
@ -33703,6 +33917,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/uncrypto": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="
},
"node_modules/undici": {
"version": "5.28.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.2.tgz",
@ -33720,6 +33939,29 @@
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
},
"node_modules/unenv": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/unenv/-/unenv-1.10.0.tgz",
"integrity": "sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==",
"dependencies": {
"consola": "^3.2.3",
"defu": "^6.1.4",
"mime": "^3.0.0",
"node-fetch-native": "^1.6.4",
"pathe": "^1.1.2"
}
},
"node_modules/unenv/node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/unified": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz",
@ -35409,9 +35651,9 @@
}
},
"node_modules/zod": {
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
"version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
"integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@ -35454,6 +35696,14 @@
"@prisma/debug": "5.22.0"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.1.tgz",
"integrity": "sha512-3h08nf3Vw3Wl3PK+q3ow/lIil81IT2Oa7YpQyUUDsEWbXveMesdfK1xBd2RhCkynwZndAxixji/7SYJJowr62w==",
"peerDependencies": {
"zod": "^3.24.1"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
@ -35478,7 +35728,7 @@
"superjson": "^1.13.1",
"swagger-ui-react": "^5.11.0",
"ts-pattern": "^5.0.5",
"zod": "^3.23.8"
"zod": "3.24.1"
}
},
"packages/api/node_modules/@ts-rest/next": {
@ -35543,7 +35793,7 @@
"next-auth": "4.24.5",
"react": "^18",
"ts-pattern": "^5.0.5",
"zod": "^3.23.8"
"zod": "3.24.1"
}
},
"packages/email": {
@ -36752,7 +37002,7 @@
"sharp": "0.32.6",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",
"zod": "^3.23.8"
"zod": "3.24.1"
},
"devDependencies": {
"@playwright/browser-chromium": "1.43.0",
@ -36890,7 +37140,7 @@
"luxon": "^3.4.0",
"superjson": "^1.13.1",
"ts-pattern": "^5.0.5",
"zod": "^3.23.8"
"zod": "3.24.1"
}
},
"packages/trpc/node_modules/@ts-rest/next": {
@ -36970,7 +37220,7 @@
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5",
"ts-pattern": "^5.0.5",
"zod": "^3.23.8"
"zod": "3.24.1"
},
"devDependencies": {
"@documenso/tailwind-config": "*",

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "1.9.0-rc.0",
"version": "1.9.0-rc.5",
"scripts": {
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",
@ -68,11 +68,14 @@
"@lingui/core": "^4.11.3",
"inngest-cli": "^0.29.1",
"luxon": "^3.5.0",
"mupdf": "^1.0.0",
"next-runtime-env": "^3.2.0",
"react": "^18"
"react": "^18",
"zod": "3.24.1"
},
"overrides": {
"next": "14.2.6"
"next": "14.2.6",
"zod": "3.24.1"
},
"trigger.dev": {
"endpointId": "documenso-app"

View File

@ -25,6 +25,6 @@
"superjson": "^1.13.1",
"swagger-ui-react": "^5.11.0",
"ts-pattern": "^5.0.5",
"zod": "^3.23.8"
"zod": "3.24.1"
}
}
}

View File

@ -29,12 +29,13 @@ import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/s
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
import { createTeamMemberInvites } from '@documenso/lib/server-only/team/create-team-member-invites';
import { deleteTeamMembers } from '@documenso/lib/server-only/team/delete-team-members';
import type { CreateDocumentFromTemplateResponse } from '@documenso/lib/server-only/template/create-document-from-template';
import type { TCreateDocumentFromTemplateResponse } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { extractDerivedDocumentEmailSettings } from '@documenso/lib/types/document-email';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import {
ZCheckboxFieldMeta,
@ -344,7 +345,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
});
}
const recipients = await setRecipientsForDocument({
const { recipients } = await setRecipientsForDocument({
userId: user.id,
teamId: team?.id,
documentId: document.id,
@ -427,7 +428,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const perPage = Number(args.query.perPage) || 10;
try {
const { templates, totalPages } = await findTemplates({
const { data: templates, totalPages } = await findTemplates({
page,
perPage,
userId: user.id,
@ -559,7 +560,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const templateId = Number(params.templateId);
let document: CreateDocumentFromTemplateResponse | null = null;
let document: TCreateDocumentFromTemplateResponse | null = null;
try {
document = await createDocumentFromTemplate({
@ -629,7 +630,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
token: recipient.token,
role: recipient.role,
signingOrder: recipient.signingOrder,
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
})),
},
@ -637,69 +637,52 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}),
sendDocument: authenticatedMiddleware(async (args, user, team) => {
const { id } = args.params;
const { sendEmail = true } = args.body ?? {};
const document = await getDocumentById({
documentId: Number(id),
userId: user.id,
teamId: team?.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already complete',
},
};
}
const { id: documentId } = args.params;
const { sendEmail, sendCompletionEmails } = args.body;
try {
// await setRecipientsForDocument({
// userId: user.id,
// documentId: Number(id),
// recipients: [
// {
// email: body.signerEmail,
// name: body.signerName ?? '',
// },
// ],
// });
const document = await getDocumentById({
documentId: Number(documentId),
userId: user.id,
teamId: team?.id,
});
// await setFieldsForDocument({
// documentId: Number(id),
// userId: user.id,
// fields: body.fields.map((field) => ({
// signerEmail: body.signerEmail,
// type: field.fieldType,
// pageNumber: field.pageNumber,
// pageX: field.pageX,
// pageY: field.pageY,
// pageWidth: field.pageWidth,
// pageHeight: field.pageHeight,
// })),
// });
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
// if (body.emailBody || body.emailSubject) {
// await upsertDocumentMeta({
// documentId: Number(id),
// subject: body.emailSubject ?? '',
// message: body.emailBody ?? '',
// });
// }
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already complete',
},
};
}
const emailSettings = extractDerivedDocumentEmailSettings(document.documentMeta);
// Update document email settings if sendCompletionEmails is provided
if (typeof sendCompletionEmails === 'boolean') {
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
emailSettings: {
...emailSettings,
documentCompleted: sendCompletionEmails,
ownerDocumentCompleted: sendCompletionEmails,
},
requestMetadata: extractNextApiRequestMetadata(args.req),
});
}
const { Recipient: recipients, ...sentDocument } = await sendDocument({
documentId: Number(id),
documentId: document.id,
userId: user.id,
teamId: team?.id,
sendEmail,
@ -802,7 +785,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
try {
const newRecipients = await setRecipientsForDocument({
const { recipients: newRecipients } = await setRecipientsForDocument({
documentId: Number(documentId),
userId: user.id,
teamId: team?.id,

View File

@ -88,8 +88,12 @@ export const ZSendDocumentForSigningMutationSchema = z
description:
'Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links.',
}),
sendCompletionEmails: z.boolean().optional().openapi({
description:
'Whether to send completion emails when the document is fully signed. This will override the document email settings.',
}),
})
.or(z.literal('').transform(() => ({ sendEmail: true })));
.or(z.literal('').transform(() => ({ sendEmail: true, sendCompletionEmails: undefined })));
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;

View File

@ -0,0 +1,137 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
test.describe('Document API', () => {
test('sendDocument: should respect sendCompletionEmails setting', async ({ request }) => {
const user = await seedUser();
const { document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['signer@example.com'],
});
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test',
expiresIn: null,
});
// Test with sendCompletionEmails: false
const response = await request.post(`${WEBAPP_BASE_URL}/api/v1/documents/${document.id}/send`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
sendCompletionEmails: false,
},
});
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
// Verify email settings were updated
const updatedDocument = await prisma.document.findUnique({
where: { id: document.id },
include: { documentMeta: true },
});
expect(updatedDocument?.documentMeta?.emailSettings).toMatchObject({
documentCompleted: false,
ownerDocumentCompleted: false,
});
// Test with sendCompletionEmails: true
const response2 = await request.post(
`${WEBAPP_BASE_URL}/api/v1/documents/${document.id}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
sendCompletionEmails: true,
},
},
);
expect(response2.ok()).toBeTruthy();
expect(response2.status()).toBe(200);
// Verify email settings were updated
const updatedDocument2 = await prisma.document.findUnique({
where: { id: document.id },
include: { documentMeta: true },
});
expect(updatedDocument2?.documentMeta?.emailSettings ?? {}).toMatchObject({
documentCompleted: true,
ownerDocumentCompleted: true,
});
});
test('sendDocument: should not modify email settings when sendCompletionEmails is not provided', async ({
request,
}) => {
const user = await seedUser();
const { document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['signer@example.com'],
});
// Set initial email settings
await prisma.documentMeta.upsert({
where: { documentId: document.id },
create: {
documentId: document.id,
emailSettings: {
documentCompleted: true,
ownerDocumentCompleted: false,
},
},
update: {
documentId: document.id,
emailSettings: {
documentCompleted: true,
ownerDocumentCompleted: false,
},
},
});
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.post(`${WEBAPP_BASE_URL}/api/v1/documents/${document.id}/send`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
sendEmail: true,
},
});
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
// Verify email settings were not modified
const updatedDocument = await prisma.document.findUnique({
where: { id: document.id },
include: { documentMeta: true },
});
expect(updatedDocument?.documentMeta?.emailSettings ?? {}).toMatchObject({
documentCompleted: true,
ownerDocumentCompleted: false,
});
});
});

View File

@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
@ -538,7 +539,7 @@ test('[TEAMS]: ensure recipient can see document regardless of visibility', asyn
await apiSignout({ page });
});
test('[TEAMS]: check that members cannot see ADMIN-only documents', async ({ page }) => {
test('[TEAMS]: check that MEMBER role cannot see ADMIN-only documents', async ({ page }) => {
const team = await seedTeam();
// Seed a member user
@ -575,7 +576,46 @@ test('[TEAMS]: check that members cannot see ADMIN-only documents', async ({ pag
await apiSignout({ page });
});
test('[TEAMS]: check that managers cannot see ADMIN-only documents', async ({ page }) => {
test('[TEAMS]: check that MEMBER role cannot see MANAGER_AND_ABOVE-only documents', async ({
page,
}) => {
const team = await seedTeam();
// Seed a member user
const memberUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
// Seed an ADMIN-only document
await seedDocuments([
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'MANAGER_AND_ABOVE',
title: 'Manager and Above Only Document',
},
},
]);
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
// Check that the member user cannot see the ADMIN-only document
await expect(
page.getByRole('link', { name: 'Admin Only Document', exact: true }),
).not.toBeVisible();
await apiSignout({ page });
});
test('[TEAMS]: check that MANAGER role cannot see ADMIN-only documents', async ({ page }) => {
const team = await seedTeam();
// Seed a manager user
@ -612,7 +652,7 @@ test('[TEAMS]: check that managers cannot see ADMIN-only documents', async ({ pa
await apiSignout({ page });
});
test('[TEAMS]: check that admin can see MANAGER_AND_ABOVE documents', async ({ page }) => {
test('[TEAMS]: check that ADMIN role can see MANAGER_AND_ABOVE documents', async ({ page }) => {
const team = await seedTeam();
// Seed an admin user
@ -649,6 +689,187 @@ test('[TEAMS]: check that admin can see MANAGER_AND_ABOVE documents', async ({ p
await apiSignout({ page });
});
test('[TEAMS]: check that ADMIN role can change document visibility', async ({ page }) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
},
},
});
const adminUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.ADMIN,
});
const document = await seedBlankDocument(adminUser, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
},
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await page.getByTestId('documentVisibilitySelectValue').click();
await page.getByLabel('Admins only').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Admins only');
});
test('[TEAMS]: check that MEMBER role cannot change visibility of EVERYONE documents', async ({
page,
}) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.EVERYONE,
},
},
},
});
const teamMember = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
const document = await seedBlankDocument(teamMember, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
},
});
await apiSignin({
page,
email: teamMember.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByTestId('documentVisibilitySelectValue')).toHaveText('Everyone');
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
});
test('[TEAMS]: check that MEMBER role cannot change visibility of MANAGER_AND_ABOVE documents', async ({
page,
}) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
},
},
});
const teamMember = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
const document = await seedBlankDocument(teamMember, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
},
});
await apiSignin({
page,
email: teamMember.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByTestId('documentVisibilitySelectValue')).toHaveText('Managers and above');
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
});
test('[TEAMS]: check that MEMBER role cannot change visibility of ADMIN documents', async ({
page,
}) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.ADMIN,
},
},
},
});
const teamMember = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
const document = await seedBlankDocument(teamMember, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
},
});
await apiSignin({
page,
email: teamMember.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByTestId('documentVisibilitySelectValue')).toHaveText('Admins only');
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
});
test('[TEAMS]: check that MANAGER role cannot change visibility of ADMIN documents', async ({
page,
}) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.ADMIN,
},
},
},
});
const teamManager = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MANAGER,
});
const document = await seedBlankDocument(teamManager, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
},
});
await apiSignin({
page,
email: teamManager.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByTestId('documentVisibilitySelectValue')).toHaveText('Admins only');
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
});
test('[TEAMS]: users cannot see documents from other teams', async ({ page }) => {
// Seed two teams with documents
const { team: teamA, teamMember2: teamAMember } = await seedTeamDocuments();

View File

@ -21,6 +21,6 @@
"next-auth": "4.24.5",
"react": "^18",
"ts-pattern": "^5.0.5",
"zod": "^3.23.8"
"zod": "3.24.1"
}
}
}

View File

@ -1,8 +1,10 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { subscriptionsContainActiveEnterprisePlan } from '@documenso/lib/utils/billing';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import type { Subscription } from '@documenso/prisma/client';
import { getEnterprisePlanPriceIds } from '../stripe/get-enterprise-plan-prices';
export type IsUserEnterpriseOptions = {
userId: number;
teamId?: number;
@ -52,5 +54,11 @@ export const isUserEnterprise = async ({
.then((user) => user.Subscription);
}
return subscriptionsContainActiveEnterprisePlan(subscriptions);
if (subscriptions.length === 0) {
return false;
}
const enterprisePlanPriceIds = await getEnterprisePlanPriceIds();
return subscriptionsContainsActivePlan(subscriptions, enterprisePlanPriceIds, true);
};

View File

@ -46,3 +46,10 @@ export const PASSKEY_TIMEOUT = 60000;
* The maximum number of passkeys are user can have.
*/
export const MAXIMUM_PASSKEYS = 50;
export const useSecureCookies =
process.env.NODE_ENV === 'production' && String(process.env.NEXTAUTH_URL).startsWith('https://');
const secureCookiePrefix = useSecureCookies ? '__Secure-' : '';
export const formatSecureCookieName = (name: string) => `${secureCookiePrefix}${name}`;

View File

@ -0,0 +1,9 @@
import { sendConfirmationToken } from '../../../server-only/user/send-confirmation-token';
import type { TSendConfirmationEmailJobDefinition } from './send-confirmation-email';
export const run = async ({ payload }: { payload: TSendConfirmationEmailJobDefinition }) => {
await sendConfirmationToken({
email: payload.email,
force: payload.force,
});
};

View File

@ -1,6 +1,5 @@
import { z } from 'zod';
import { sendConfirmationToken } from '../../../server-only/user/send-confirmation-token';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID = 'send.signup.confirmation.email';
@ -10,6 +9,10 @@ const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
force: z.boolean().optional(),
});
export type TSendConfirmationEmailJobDefinition = z.infer<
typeof SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION = {
id: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
name: 'Send Confirmation Email',
@ -19,12 +22,11 @@ export const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION = {
schema: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload }) => {
await sendConfirmationToken({
email: payload.email,
force: payload.force,
});
const handler = await import('./send-confirmation-email.handler');
await handler.run({ payload });
},
} as const satisfies JobDefinition<
typeof SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID,
z.infer<typeof SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA>
TSendConfirmationEmailJobDefinition
>;

View File

@ -0,0 +1,156 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed';
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import { formatDocumentsPath } from '../../../utils/teams';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendSigningRejectionEmailsJobDefinition } from './send-rejection-emails';
export const run = async ({
payload,
io,
}: {
payload: TSendSigningRejectionEmailsJobDefinition;
io: JobRunIO;
}) => {
const { documentId, recipientId } = payload;
const [document, recipient] = await Promise.all([
prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
User: true,
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
url: true,
teamGlobalSettings: true,
},
},
},
}),
prisma.recipient.findFirstOrThrow({
where: {
id: recipientId,
signingStatus: SigningStatus.REJECTED,
},
}),
]);
const { documentMeta, team, User: documentOwner } = document;
const isEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
if (!isEmailEnabled) {
return;
}
const i18n = await getI18nInstance(documentMeta?.language);
// Send confirmation email to the recipient who rejected
await io.runTask('send-rejection-confirmation-email', async () => {
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
recipientName: recipient.name,
documentName: document.title,
documentOwnerName: document.User.name || document.User.email,
reason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(recipientTemplate, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(recipientTemplate, {
lang: documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`Document "${document.title}" - Rejection Confirmed`),
html,
text,
});
});
// Send notification email to document owner
await io.runTask('send-owner-notification-email', async () => {
const ownerTemplate = createElement(DocumentRejectedEmail, {
recipientName: recipient.name,
documentName: document.title,
documentUrl: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(document.team?.url)}/${
document.id
}`,
rejectionReason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(ownerTemplate, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(ownerTemplate, {
lang: documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: documentOwner.name || '',
address: documentOwner.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`Document "${document.title}" - Rejected by ${recipient.name}`),
html,
text,
});
});
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
});
};

View File

@ -1,21 +1,5 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed';
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import { formatDocumentsPath } from '../../../utils/teams';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_ID = 'send.signing.rejected.emails';
@ -25,6 +9,10 @@ const SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
recipientId: z.number(),
});
export type TSendSigningRejectionEmailsJobDefinition = z.infer<
typeof SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_SCHEMA
>;
export const SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION = {
id: SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_ID,
name: 'Send Rejection Emails',
@ -34,136 +22,11 @@ export const SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION = {
schema: SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const { documentId, recipientId } = payload;
const handler = await import('./send-rejection-emails.handler');
const [document, recipient] = await Promise.all([
prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
User: true,
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
url: true,
teamGlobalSettings: true,
},
},
},
}),
prisma.recipient.findFirstOrThrow({
where: {
id: recipientId,
signingStatus: SigningStatus.REJECTED,
},
}),
]);
const { documentMeta, team, User: documentOwner } = document;
const isEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
if (!isEmailEnabled) {
return;
}
const i18n = await getI18nInstance(documentMeta?.language);
// Send confirmation email to the recipient who rejected
await io.runTask('send-rejection-confirmation-email', async () => {
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
recipientName: recipient.name,
documentName: document.title,
documentOwnerName: document.User.name || document.User.email,
reason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(recipientTemplate, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(recipientTemplate, {
lang: documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`Document "${document.title}" - Rejection Confirmed`),
html,
text,
});
});
// Send notification email to document owner
await io.runTask('send-owner-notification-email', async () => {
const ownerTemplate = createElement(DocumentRejectedEmail, {
recipientName: recipient.name,
documentName: document.title,
documentUrl: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(document.team?.url)}/${
document.id
}`,
rejectionReason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(ownerTemplate, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(ownerTemplate, {
lang: documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: documentOwner.name || '',
address: documentOwner.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`Document "${document.title}" - Rejected by ${recipient.name}`),
html,
text,
});
});
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
});
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_ID,
z.infer<typeof SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_SCHEMA>
TSendSigningRejectionEmailsJobDefinition
>;

View File

@ -0,0 +1,215 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
import { prisma } from '@documenso/prisma';
import {
DocumentSource,
DocumentStatus,
RecipientRole,
SendStatus,
} from '@documenso/prisma/client';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../../constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendSigningEmailJobDefinition } from './send-signing-email';
export const run = async ({
payload,
io,
}: {
payload: TSendSigningEmailJobDefinition;
io: JobRunIO;
}) => {
const { userId, documentId, recipientId, requestMetadata } = payload;
const [user, document, recipient] = await Promise.all([
prisma.user.findFirstOrThrow({
where: {
id: userId,
},
}),
prisma.document.findFirstOrThrow({
where: {
id: documentId,
status: DocumentStatus.PENDING,
},
include: {
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
teamGlobalSettings: true,
},
},
},
}),
prisma.recipient.findFirstOrThrow({
where: {
id: recipientId,
},
}),
]);
const { documentMeta, team } = document;
if (recipient.role === RecipientRole.CC) {
return;
}
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
if (!isRecipientSigningRequestEmailEnabled) {
return;
}
const customEmail = document?.documentMeta;
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
const isTeamDocument = document.teamId !== null;
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const i18n = await getI18nInstance(documentMeta?.language);
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailMessage = customEmail?.message || '';
let emailSubject = i18n._(msg`Please ${recipientActionVerb} this document`);
if (selfSigner) {
emailMessage = i18n._(
msg`You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(msg`Please ${recipientActionVerb} your document`);
}
if (isDirectTemplate) {
emailMessage = i18n._(
msg`A document was created by your direct template that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(
msg`Please ${recipientActionVerb} this document created by your direct template`,
);
}
if (isTeamDocument && team) {
emailSubject = i18n._(msg`${team.name} invited you to ${recipientActionVerb} a document`);
emailMessage = customEmail?.message ?? '';
if (!emailMessage) {
emailMessage = i18n._(
team.teamGlobalSettings?.includeSenderDetails
? msg`${user.name} on behalf of "${team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".`
: msg`${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
);
}
}
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': document.title,
};
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: isTeamDocument ? team?.teamEmail?.email || user.email : user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
role: recipient.role,
selfSigner,
isTeamInvite: isTeamDocument,
teamName: team?.name,
teamEmail: team?.teamEmail?.email,
includeSenderDetails: team?.teamGlobalSettings?.includeSenderDetails,
});
await io.runTask('send-signing-email', async () => {
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: renderCustomEmailTemplate(
documentMeta?.subject || emailSubject,
customEmailTemplate,
),
html,
text,
});
});
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
});
await io.runTask('store-audit-log', async () => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
recipientRole: recipient.role,
isResending: false,
},
}),
});
});
};

View File

@ -1,32 +1,6 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
import { prisma } from '@documenso/prisma';
import {
DocumentSource,
DocumentStatus,
RecipientRole,
SendStatus,
} from '@documenso/prisma/client';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../../constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_SIGNING_EMAIL_JOB_DEFINITION_ID = 'send.signing.requested.email';
@ -38,6 +12,10 @@ const SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
requestMetadata: ZRequestMetadataSchema.optional(),
});
export type TSendSigningEmailJobDefinition = z.infer<
typeof SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
id: SEND_SIGNING_EMAIL_JOB_DEFINITION_ID,
name: 'Send Signing Email',
@ -47,185 +25,11 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
schema: SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const { userId, documentId, recipientId, requestMetadata } = payload;
const handler = await import('./send-signing-email.handler');
const [user, document, recipient] = await Promise.all([
prisma.user.findFirstOrThrow({
where: {
id: userId,
},
}),
prisma.document.findFirstOrThrow({
where: {
id: documentId,
status: DocumentStatus.PENDING,
},
include: {
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
teamGlobalSettings: true,
},
},
},
}),
prisma.recipient.findFirstOrThrow({
where: {
id: recipientId,
},
}),
]);
const { documentMeta, team } = document;
if (recipient.role === RecipientRole.CC) {
return;
}
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
if (!isRecipientSigningRequestEmailEnabled) {
return;
}
const customEmail = document?.documentMeta;
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
const isTeamDocument = document.teamId !== null;
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const i18n = await getI18nInstance(documentMeta?.language);
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailMessage = customEmail?.message || '';
let emailSubject = i18n._(msg`Please ${recipientActionVerb} this document`);
if (selfSigner) {
emailMessage = i18n._(
msg`You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(msg`Please ${recipientActionVerb} your document`);
}
if (isDirectTemplate) {
emailMessage = i18n._(
msg`A document was created by your direct template that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(
msg`Please ${recipientActionVerb} this document created by your direct template`,
);
}
if (isTeamDocument && team) {
emailSubject = i18n._(msg`${team.name} invited you to ${recipientActionVerb} a document`);
emailMessage = customEmail?.message ?? '';
if (!emailMessage) {
emailMessage = i18n._(
team.teamGlobalSettings?.includeSenderDetails
? msg`${user.name} on behalf of "${team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".`
: msg`${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
);
}
}
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': document.title,
};
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: isTeamDocument ? team?.teamEmail?.email || user.email : user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
role: recipient.role,
selfSigner,
isTeamInvite: isTeamDocument,
teamName: team?.name,
teamEmail: team?.teamEmail?.email,
includeSenderDetails: team?.teamGlobalSettings?.includeSenderDetails,
});
await io.runTask('send-signing-email', async () => {
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: renderCustomEmailTemplate(
documentMeta?.subject || emailSubject,
customEmailTemplate,
),
html,
text,
});
});
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
});
await io.runTask('store-audit-log', async () => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
recipientRole: recipient.role,
isResending: false,
},
}),
});
});
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_SIGNING_EMAIL_JOB_DEFINITION_ID,
z.infer<typeof SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA>
TSendSigningEmailJobDefinition
>;

View File

@ -0,0 +1,23 @@
import { sendTeamDeleteEmail } from '../../../server-only/team/delete-team';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendTeamDeletedEmailJobDefinition } from './send-team-deleted-email';
export const run = async ({
payload,
io,
}: {
payload: TSendTeamDeletedEmailJobDefinition;
io: JobRunIO;
}) => {
const { team, members } = payload;
for (const member of members) {
await io.runTask(`send-team-deleted-email--${team.url}_${member.id}`, async () => {
await sendTeamDeleteEmail({
email: member.email,
team,
isOwner: member.id === team.ownerUserId,
});
});
}
};

View File

@ -2,7 +2,6 @@ import { z } from 'zod';
import { DocumentVisibility } from '@documenso/prisma/client';
import { sendTeamDeleteEmail } from '../../../server-only/team/delete-team';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID = 'send.team-deleted.email';
@ -37,6 +36,10 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
),
});
export type TSendTeamDeletedEmailJobDefinition = z.infer<
typeof SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION = {
id: SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Team Deleted Email',
@ -46,19 +49,11 @@ export const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION = {
schema: SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const { team, members } = payload;
const handler = await import('./send-team-deleted-email.handler');
for (const member of members) {
await io.runTask(`send-team-deleted-email--${team.url}_${member.id}`, async () => {
await sendTeamDeleteEmail({
email: member.email,
team,
isOwner: member.id === team.ownerUserId,
});
});
}
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID,
z.infer<typeof SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA>
TSendTeamDeletedEmailJobDefinition
>;

View File

@ -0,0 +1,105 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { WEBAPP_BASE_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendTeamMemberJoinedEmailJobDefinition } from './send-team-member-joined-email';
export const run = async ({
payload,
io,
}: {
payload: TSendTeamMemberJoinedEmailJobDefinition;
io: JobRunIO;
}) => {
const team = await prisma.team.findFirstOrThrow({
where: {
id: payload.teamId,
},
include: {
members: {
where: {
role: {
in: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
},
},
include: {
user: true,
},
},
teamGlobalSettings: true,
},
});
const invitedMember = await prisma.teamMember.findFirstOrThrow({
where: {
id: payload.memberId,
teamId: payload.teamId,
},
include: {
user: true,
},
});
for (const member of team.members) {
if (member.id === invitedMember.id) {
continue;
}
await io.runTask(
`send-team-member-joined-email--${invitedMember.id}_${member.id}`,
async () => {
const emailContent = createElement(TeamJoinEmailTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
memberName: invitedMember.user.name || '',
memberEmail: invitedMember.user.email,
teamName: team.name,
teamUrl: team.url,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = team.teamGlobalSettings?.documentLanguage;
// !: Replace with the actual language of the recipient later
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`A new member has joined your team`),
html,
text,
});
},
);
}
};

View File

@ -1,18 +1,5 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { WEBAPP_BASE_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID = 'send.team-member-joined.email';
@ -22,6 +9,10 @@ const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
memberId: z.number(),
});
export type TSendTeamMemberJoinedEmailJobDefinition = z.infer<
typeof SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION = {
id: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Team Member Joined Email',
@ -31,88 +22,11 @@ export const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION = {
schema: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const team = await prisma.team.findFirstOrThrow({
where: {
id: payload.teamId,
},
include: {
members: {
where: {
role: {
in: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
},
},
include: {
user: true,
},
},
teamGlobalSettings: true,
},
});
const handler = await import('./send-team-member-joined-email.handler');
const invitedMember = await prisma.teamMember.findFirstOrThrow({
where: {
id: payload.memberId,
teamId: payload.teamId,
},
include: {
user: true,
},
});
for (const member of team.members) {
if (member.id === invitedMember.id) {
continue;
}
await io.runTask(
`send-team-member-joined-email--${invitedMember.id}_${member.id}`,
async () => {
const emailContent = createElement(TeamJoinEmailTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
memberName: invitedMember.user.name || '',
memberEmail: invitedMember.user.email,
teamName: team.name,
teamUrl: team.url,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = team.teamGlobalSettings?.documentLanguage;
// !: Replace with the actual language of the recipient later
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`A new member has joined your team`),
html,
text,
});
},
);
}
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
z.infer<typeof SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA>
TSendTeamMemberJoinedEmailJobDefinition
>;

View File

@ -0,0 +1,93 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { WEBAPP_BASE_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendTeamMemberLeftEmailJobDefinition } from './send-team-member-left-email';
export const run = async ({
payload,
io,
}: {
payload: TSendTeamMemberLeftEmailJobDefinition;
io: JobRunIO;
}) => {
const team = await prisma.team.findFirstOrThrow({
where: {
id: payload.teamId,
},
include: {
members: {
where: {
role: {
in: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
},
},
include: {
user: true,
},
},
teamGlobalSettings: true,
},
});
const oldMember = await prisma.user.findFirstOrThrow({
where: {
id: payload.memberUserId,
},
});
for (const member of team.members) {
await io.runTask(`send-team-member-left-email--${oldMember.id}_${member.id}`, async () => {
const emailContent = createElement(TeamJoinEmailTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
memberName: oldMember.name || '',
memberEmail: oldMember.email,
teamName: team.name,
teamUrl: team.url,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = team.teamGlobalSettings?.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`A team member has left ${team.name}`),
html,
text,
});
});
}
};

View File

@ -1,18 +1,5 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { WEBAPP_BASE_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID = 'send.team-member-left.email';
@ -22,6 +9,10 @@ const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
memberUserId: z.number(),
});
export type TSendTeamMemberLeftEmailJobDefinition = z.infer<
typeof SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION = {
id: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
name: 'Send Team Member Left Email',
@ -31,76 +22,11 @@ export const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION = {
schema: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const team = await prisma.team.findFirstOrThrow({
where: {
id: payload.teamId,
},
include: {
members: {
where: {
role: {
in: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
},
},
include: {
user: true,
},
},
teamGlobalSettings: true,
},
});
const handler = await import('./send-team-member-left-email.handler');
const oldMember = await prisma.user.findFirstOrThrow({
where: {
id: payload.memberUserId,
},
});
for (const member of team.members) {
await io.runTask(`send-team-member-left-email--${oldMember.id}_${member.id}`, async () => {
const emailContent = createElement(TeamJoinEmailTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
memberName: oldMember.name || '',
memberEmail: oldMember.email,
teamName: team.name,
teamUrl: team.url,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = team.teamGlobalSettings?.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`A team member has left ${team.name}`),
html,
text,
});
});
}
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
z.infer<typeof SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA>
TSendTeamMemberLeftEmailJobDefinition
>;

View File

@ -0,0 +1,253 @@
import { nanoid } from 'nanoid';
import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
RecipientRole,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { ZWebhookDocumentSchema } from '../../../types/webhook-payload';
import { getFile } from '../../../universal/upload/get-file';
import { putPdfFile } from '../../../universal/upload/put-file';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSealDocumentJobDefinition } from './seal-document';
export const run = async ({
payload,
io,
}: {
payload: TSealDocumentJobDefinition;
io: JobRunIO;
}) => {
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
Recipient: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
},
include: {
documentMeta: true,
Recipient: true,
team: {
select: {
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
},
},
},
},
},
});
// Seems silly but we need to do this in case the job is re-ran
// after it has already run through the update task further below.
// eslint-disable-next-line @typescript-eslint/require-await
const documentStatus = await io.runTask('get-document-status', async () => {
return document.status;
});
// This is the same case as above.
// eslint-disable-next-line @typescript-eslint/require-await
const documentDataId = await io.runTask('get-document-data-id', async () => {
return document.documentDataId;
});
const documentData = await prisma.documentData.findFirst({
where: {
id: documentDataId,
},
});
if (!documentData) {
throw new Error(`Document ${document.id} has no document data`);
}
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
role: {
not: RecipientRole.CC,
},
},
});
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) {
throw new Error(`Document ${document.id} has unsigned recipients`);
}
const fields = await prisma.field.findMany({
where: {
documentId: document.id,
},
include: {
Signature: true,
},
});
if (fields.some((field) => !field.inserted)) {
throw new Error(`Document ${document.id} has unsigned fields`);
}
if (isResealing) {
// If we're resealing we want to use the initial data for the document
// so we aren't placing fields on top of eachother.
documentData.data = documentData.initialData;
}
const pdfData = await getFile(documentData);
const certificateData =
(document.team?.teamGlobalSettings?.includeSigningCertificate ?? true)
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
: null;
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
const pdfDoc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(pdfDoc);
flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
if (certificateData) {
const certificateDoc = await PDFDocument.load(certificateData);
const certificatePages = await pdfDoc.copyPages(
certificateDoc,
certificateDoc.getPageIndices(),
);
certificatePages.forEach((page) => {
pdfDoc.addPage(page);
});
}
for (const field of fields) {
await insertFieldInPDF(pdfDoc, field);
}
// Re-flatten the form to handle our checkbox and radio fields that
// create native arcoFields
flattenForm(pdfDoc);
const pdfBytes = await pdfDoc.save();
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
const { name } = path.parse(document.title);
const documentData = await putPdfFile({
name: `${name}_signed.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),
});
return documentData.id;
});
const postHog = PostHogServerClient();
if (postHog) {
postHog.capture({
distinctId: nanoid(),
event: 'App: Document Sealed',
properties: {
documentId: document.id,
},
});
}
await io.runTask('update-document', async () => {
await prisma.$transaction(async (tx) => {
const newData = await tx.documentData.findFirstOrThrow({
where: {
id: newDataId,
},
});
await tx.document.update({
where: {
id: document.id,
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
await tx.documentData.update({
where: {
id: documentData.id,
},
data: {
data: newData.data,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
documentId: document.id,
requestMetadata,
user: null,
data: {
transactionId: nanoid(),
},
}),
});
});
});
await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing;
if (isResealing && documentStatus !== DocumentStatus.COMPLETED) {
shouldSendCompletedEmail = sendEmail;
}
if (shouldSendCompletedEmail) {
await sendCompletedEmail({ documentId, requestMetadata });
}
});
const updatedDocument = await prisma.document.findFirstOrThrow({
where: {
id: document.id,
},
include: {
documentData: true,
documentMeta: true,
Recipient: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(updatedDocument),
userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined,
});
};

View File

@ -1,31 +1,6 @@
import { nanoid } from 'nanoid';
import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
RecipientRole,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { ZWebhookDocumentSchema } from '../../../types/webhook-payload';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { getFile } from '../../../universal/upload/get-file';
import { putPdfFile } from '../../../universal/upload/put-file';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { type JobDefinition } from '../../client/_internal/job';
const SEAL_DOCUMENT_JOB_DEFINITION_ID = 'internal.seal-document';
@ -37,6 +12,8 @@ const SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA = z.object({
requestMetadata: ZRequestMetadataSchema.optional(),
});
export type TSealDocumentJobDefinition = z.infer<typeof SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA>;
export const SEAL_DOCUMENT_JOB_DEFINITION = {
id: SEAL_DOCUMENT_JOB_DEFINITION_ID,
name: 'Seal Document',
@ -46,223 +23,11 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
schema: SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
const handler = await import('./seal-document.handler');
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
Recipient: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
},
include: {
documentMeta: true,
Recipient: true,
team: {
select: {
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
},
},
},
},
},
});
// Seems silly but we need to do this in case the job is re-ran
// after it has already run through the update task further below.
// eslint-disable-next-line @typescript-eslint/require-await
const documentStatus = await io.runTask('get-document-status', async () => {
return document.status;
});
// This is the same case as above.
// eslint-disable-next-line @typescript-eslint/require-await
const documentDataId = await io.runTask('get-document-data-id', async () => {
return document.documentDataId;
});
const documentData = await prisma.documentData.findFirst({
where: {
id: documentDataId,
},
});
if (!documentData) {
throw new Error(`Document ${document.id} has no document data`);
}
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
role: {
not: RecipientRole.CC,
},
},
});
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) {
throw new Error(`Document ${document.id} has unsigned recipients`);
}
const fields = await prisma.field.findMany({
where: {
documentId: document.id,
},
include: {
Signature: true,
},
});
if (fields.some((field) => !field.inserted)) {
throw new Error(`Document ${document.id} has unsigned fields`);
}
if (isResealing) {
// If we're resealing we want to use the initial data for the document
// so we aren't placing fields on top of eachother.
documentData.data = documentData.initialData;
}
const pdfData = await getFile(documentData);
const certificateData =
(document.team?.teamGlobalSettings?.includeSigningCertificate ?? true)
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
: null;
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
const pdfDoc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(pdfDoc);
flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
if (certificateData) {
const certificateDoc = await PDFDocument.load(certificateData);
const certificatePages = await pdfDoc.copyPages(
certificateDoc,
certificateDoc.getPageIndices(),
);
certificatePages.forEach((page) => {
pdfDoc.addPage(page);
});
}
for (const field of fields) {
await insertFieldInPDF(pdfDoc, field);
}
// Re-flatten the form to handle our checkbox and radio fields that
// create native arcoFields
flattenForm(pdfDoc);
const pdfBytes = await pdfDoc.save();
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
const { name } = path.parse(document.title);
const documentData = await putPdfFile({
name: `${name}_signed.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),
});
return documentData.id;
});
const postHog = PostHogServerClient();
if (postHog) {
postHog.capture({
distinctId: nanoid(),
event: 'App: Document Sealed',
properties: {
documentId: document.id,
},
});
}
await io.runTask('update-document', async () => {
await prisma.$transaction(async (tx) => {
const newData = await tx.documentData.findFirstOrThrow({
where: {
id: newDataId,
},
});
await tx.document.update({
where: {
id: document.id,
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
await tx.documentData.update({
where: {
id: documentData.id,
},
data: {
data: newData.data,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
documentId: document.id,
requestMetadata,
user: null,
data: {
transactionId: nanoid(),
},
}),
});
});
});
await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing;
if (isResealing && documentStatus !== DocumentStatus.COMPLETED) {
shouldSendCompletedEmail = sendEmail;
}
if (shouldSendCompletedEmail) {
await sendCompletedEmail({ documentId, requestMetadata });
}
});
const updatedDocument = await prisma.document.findFirstOrThrow({
where: {
id: document.id,
},
include: {
documentData: true,
documentMeta: true,
Recipient: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(updatedDocument),
userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined,
});
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEAL_DOCUMENT_JOB_DEFINITION_ID,
z.infer<typeof SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA>
TSealDocumentJobDefinition
>;

View File

@ -13,6 +13,7 @@ import { env } from 'next-runtime-env';
import { prisma } from '@documenso/prisma';
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
import { formatSecureCookieName, useSecureCookies } from '../constants/auth';
import { AppError, AppErrorCode } from '../errors/app-error';
import { jobsClient } from '../jobs/client';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
@ -26,10 +27,6 @@ import { extractNextAuthRequestMetadata } from '../universal/extract-request-met
import { getAuthenticatorOptions } from '../utils/authenticator';
import { ErrorCode } from './error-codes';
const useSecureCookies =
process.env.NODE_ENV === 'production' && String(process.env.NEXTAUTH_URL).startsWith('https://');
const cookiePrefix = useSecureCookies ? '__Secure-' : '';
export const NEXT_AUTH_OPTIONS: AuthOptions = {
adapter: PrismaAdapter(prisma),
secret: process.env.NEXTAUTH_SECRET ?? 'secret',
@ -124,6 +121,10 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
throw new Error(ErrorCode.UNVERIFIED_EMAIL);
}
if (user.disabled) {
throw new Error(ErrorCode.ACCOUNT_DISABLED);
}
return {
id: Number(user.id),
email: user.email,
@ -437,7 +438,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
},
cookies: {
sessionToken: {
name: `${cookiePrefix}next-auth.session-token`,
name: formatSecureCookieName('next-auth.session-token'),
options: {
httpOnly: true,
sameSite: useSecureCookies ? 'none' : 'lax',
@ -446,7 +447,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
},
},
callbackUrl: {
name: `${cookiePrefix}next-auth.callback-url`,
name: formatSecureCookieName('next-auth.callback-url'),
options: {
sameSite: useSecureCookies ? 'none' : 'lax',
path: '/',
@ -456,7 +457,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
csrfToken: {
// Default to __Host- for CSRF token for additional protection if using useSecureCookies
// NB: The `__Host-` prefix is stricter than the `__Secure-` prefix.
name: `${cookiePrefix}next-auth.csrf-token`,
name: formatSecureCookieName('next-auth.csrf-token'),
options: {
httpOnly: true,
sameSite: useSecureCookies ? 'none' : 'lax',
@ -465,7 +466,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
},
},
pkceCodeVerifier: {
name: `${cookiePrefix}next-auth.pkce.code_verifier`,
name: formatSecureCookieName('next-auth.pkce.code_verifier'),
options: {
httpOnly: true,
sameSite: useSecureCookies ? 'none' : 'lax',
@ -474,7 +475,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
},
},
state: {
name: `${cookiePrefix}next-auth.state`,
name: formatSecureCookieName('next-auth.state'),
options: {
httpOnly: true,
sameSite: useSecureCookies ? 'none' : 'lax',

View File

@ -20,4 +20,5 @@ export const ErrorCode = {
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
UNVERIFIED_EMAIL: 'UNVERIFIED_EMAIL',
ACCOUNT_DISABLED: 'ACCOUNT_DISABLED',
} as const;

View File

@ -56,11 +56,11 @@
"sharp": "0.32.6",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",
"zod": "^3.23.8"
"zod": "3.24.1"
},
"devDependencies": {
"@playwright/browser-chromium": "1.43.0",
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4"
}
}
}

View File

@ -1,18 +1,20 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import type { FindResultResponse } from '../../types/search-params';
export interface FindDocumentsOptions {
term?: string;
query?: string;
page?: number;
perPage?: number;
}
export const findDocuments = async ({ term, page = 1, perPage = 10 }: FindDocumentsOptions) => {
const termFilters: Prisma.DocumentWhereInput | undefined = !term
export const findDocuments = async ({ query, page = 1, perPage = 10 }: FindDocumentsOptions) => {
const termFilters: Prisma.DocumentWhereInput | undefined = !query
? undefined
: {
title: {
contains: term,
contains: query,
mode: 'insensitive',
},
};
@ -51,5 +53,5 @@ export const findDocuments = async ({ term, page = 1, perPage = 10 }: FindDocume
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
};
} satisfies FindResultResponse<typeof data>;
};

View File

@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
import { DocumentStatus, Prisma } from '@documenso/prisma/client';
export type SigningVolume = {
id: number;
@ -43,34 +43,41 @@ export async function getSigningVolume({
],
});
const orderByClause = getOrderByClause({ sortBy, sortOrder });
const [subscriptions, totalCount] = await Promise.all([
prisma.subscription.findMany({
where: whereClause,
include: {
User: {
include: {
select: {
name: true,
email: true,
Document: {
where: {
status: 'COMPLETED',
status: DocumentStatus.COMPLETED,
deletedAt: null,
teamId: null,
},
},
},
},
team: {
include: {
select: {
name: true,
document: {
where: {
status: 'COMPLETED',
status: DocumentStatus.COMPLETED,
deletedAt: null,
},
},
},
},
},
orderBy: orderByClause,
orderBy:
sortBy === 'name'
? [{ User: { name: sortOrder } }, { team: { name: sortOrder } }, { createdAt: 'desc' }]
: sortBy === 'createdAt'
? [{ createdAt: sortOrder }]
: undefined,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
}),
@ -82,10 +89,8 @@ export async function getSigningVolume({
const leaderboardWithVolume: SigningVolume[] = subscriptions.map((subscription) => {
const name =
subscription.User?.name || subscription.team?.name || subscription.User?.email || 'Unknown';
const userSignedDocs = subscription.User?.Document?.length || 0;
const teamSignedDocs = subscription.team?.document?.length || 0;
return {
id: subscription.id,
name,
@ -95,54 +100,16 @@ export async function getSigningVolume({
};
});
if (sortBy === 'signingVolume') {
leaderboardWithVolume.sort((a, b) => {
return sortOrder === 'desc'
? b.signingVolume - a.signingVolume
: a.signingVolume - b.signingVolume;
});
}
return {
leaderboard: leaderboardWithVolume,
totalPages: Math.ceil(totalCount / perPage),
};
}
function getOrderByClause(options: {
sortBy: string;
sortOrder: 'asc' | 'desc';
}): Prisma.SubscriptionOrderByWithRelationInput | Prisma.SubscriptionOrderByWithRelationInput[] {
const { sortBy, sortOrder } = options;
if (sortBy === 'name') {
return [
{
User: {
name: sortOrder,
},
},
{
team: {
name: sortOrder,
},
},
];
}
if (sortBy === 'createdAt') {
return {
createdAt: sortOrder,
};
}
// Default: sort by signing volume
return [
{
User: {
Document: {
_count: sortOrder,
},
},
},
{
team: {
document: {
_count: sortOrder,
},
},
},
];
}

View File

@ -1,11 +1,12 @@
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { prisma } from '@documenso/prisma';
import type { Passkey } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
import type { FindResultResponse } from '../../types/search-params';
export interface FindPasskeysOptions {
userId: number;
term?: string;
query?: string;
page?: number;
perPage?: number;
orderBy?: {
@ -17,7 +18,7 @@ export interface FindPasskeysOptions {
export const findPasskeys = async ({
userId,
term = '',
query = '',
page = 1,
perPage = 10,
orderBy,
@ -30,9 +31,9 @@ export const findPasskeys = async ({
userId,
};
if (term.length > 0) {
if (query.length > 0) {
whereClause.name = {
contains: term,
contains: query,
mode: Prisma.QueryMode.insensitive,
};
}
@ -72,5 +73,5 @@ export const findPasskeys = async ({
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
} satisfies FindResultResponse<typeof data>;
};

View File

@ -1,6 +1,9 @@
'use server';
import type { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@ -8,8 +11,11 @@ import { prisma } from '@documenso/prisma';
import { DocumentSource, DocumentVisibility, WebhookTriggerEvents } from '@documenso/prisma/client';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import { DocumentSchema } from '@documenso/prisma/generated/zod';
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
@ -19,18 +25,24 @@ export type CreateDocumentOptions = {
teamId?: number;
documentDataId: string;
formValues?: Record<string, string | number | boolean>;
normalizePdf?: boolean;
requestMetadata?: RequestMetadata;
};
export const ZCreateDocumentResponseSchema = DocumentSchema;
export type TCreateDocumentResponse = z.infer<typeof ZCreateDocumentResponseSchema>;
export const createDocument = async ({
userId,
title,
externalId,
documentDataId,
teamId,
normalizePdf,
formValues,
requestMetadata,
}: CreateDocumentOptions) => {
}: CreateDocumentOptions): Promise<TCreateDocumentResponse> => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@ -82,22 +94,44 @@ export const createDocument = async ({
globalVisibility: DocumentVisibility | null | undefined,
userRole: TeamMemberRole,
): DocumentVisibility => {
const defaultVisibility = globalVisibility ?? DocumentVisibility.EVERYONE;
if (globalVisibility) {
return globalVisibility;
}
if (userRole === TeamMemberRole.ADMIN) {
return defaultVisibility;
return DocumentVisibility.ADMIN;
}
if (userRole === TeamMemberRole.MANAGER) {
if (defaultVisibility === DocumentVisibility.ADMIN) {
return DocumentVisibility.MANAGER_AND_ABOVE;
}
return defaultVisibility;
return DocumentVisibility.MANAGER_AND_ABOVE;
}
return DocumentVisibility.EVERYONE;
};
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
id: documentDataId,
},
});
if (documentData) {
const buffer = await getFile(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const newDocumentData = await putPdfFile({
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),
});
// eslint-disable-next-line require-atomic-updates
documentDataId = newDocumentData.id;
}
}
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {

View File

@ -1,19 +1,27 @@
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { DocumentSource, type Prisma } from '@documenso/prisma/client';
import { getDocumentWhereInput } from './get-document-by-id';
export interface DuplicateDocumentByIdOptions {
export interface DuplicateDocumentOptions {
documentId: number;
userId: number;
teamId?: number;
}
export const duplicateDocumentById = async ({
export const ZDuplicateDocumentResponseSchema = z.object({
documentId: z.number(),
});
export type TDuplicateDocumentResponse = z.infer<typeof ZDuplicateDocumentResponseSchema>;
export const duplicateDocument = async ({
documentId,
userId,
teamId,
}: DuplicateDocumentByIdOptions) => {
}: DuplicateDocumentOptions): Promise<TDuplicateDocumentResponse> => {
const documentWhereInput = await getDocumentWhereInput({
documentId,
userId,
@ -78,5 +86,7 @@ export const duplicateDocumentById = async ({
const createdDocument = await prisma.document.create(createDocumentArguments);
return createdDocument.id;
return {
documentId: createdDocument.id,
};
};

View File

@ -1,9 +1,9 @@
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { prisma } from '@documenso/prisma';
import type { DocumentAuditLog } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import type { DocumentAuditLog, Prisma } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { FindResultResponse } from '../../types/search-params';
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
export interface FindDocumentAuditLogsOptions {
@ -31,7 +31,7 @@ export const findDocumentAuditLogs = async ({
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const documentFilter = await prisma.document.findFirstOrThrow({
const document = await prisma.document.findFirst({
where: {
id: documentId,
OR: [
@ -51,6 +51,10 @@ export const findDocumentAuditLogs = async ({
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const whereClause: Prisma.DocumentAuditLogWhereInput = {
documentId,
};
@ -113,5 +117,5 @@ export const findDocumentAuditLogs = async ({
perPage,
totalPages: Math.ceil(count / perPage),
nextCursor,
} satisfies FindResultSet<typeof parsedData> & { nextCursor?: string };
} satisfies FindResultResponse<typeof parsedData> & { nextCursor?: string };
};

View File

@ -1,8 +1,8 @@
import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { RecipientRole, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import type {
Document,
DocumentSource,
@ -11,10 +11,17 @@ import type {
TeamEmail,
User,
} from '@documenso/prisma/client';
import { RecipientRole, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import {
DocumentSchema,
RecipientSchema,
TeamSchema,
UserSchema,
} from '@documenso/prisma/generated/zod';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DocumentVisibility } from '../../types/document-visibility';
import type { FindResultSet } from '../../types/find-result-set';
import { type FindResultResponse, ZFindResultResponse } from '../../types/search-params';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
@ -22,7 +29,6 @@ export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
export type FindDocumentsOptions = {
userId: number;
teamId?: number;
term?: string;
templateId?: number;
source?: DocumentSource;
status?: ExtendedDocumentStatus;
@ -34,13 +40,29 @@ export type FindDocumentsOptions = {
};
period?: PeriodSelectorValue;
senderIds?: number[];
search?: string;
query?: string;
};
export const ZFindDocumentsResponseSchema = ZFindResultResponse.extend({
data: DocumentSchema.extend({
User: UserSchema.pick({
id: true,
name: true,
email: true,
}),
Recipient: RecipientSchema.array(),
team: TeamSchema.pick({
id: true,
url: true,
}).nullable(),
}).array(), // Todo: openapi remap.
});
export type TFindDocumentsResponse = z.infer<typeof ZFindDocumentsResponseSchema>;
export const findDocuments = async ({
userId,
teamId,
term,
templateId,
source,
status = ExtendedDocumentStatus.ALL,
@ -49,8 +71,8 @@ export const findDocuments = async ({
orderBy,
period,
senderIds,
search,
}: FindDocumentsOptions) => {
query,
}: FindDocumentsOptions): Promise<TFindDocumentsResponse> => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@ -87,22 +109,11 @@ export const findDocuments = async ({
const orderByDirection = orderBy?.direction ?? 'desc';
const teamMemberRole = team?.members[0].role ?? null;
const termFilters = match(term)
.with(P.string.minLength(1), () => {
return {
title: {
contains: term,
mode: 'insensitive',
},
} as const;
})
.otherwise(() => undefined);
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
{ Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
{ title: { contains: query, mode: 'insensitive' } },
{ Recipient: { some: { name: { contains: query, mode: 'insensitive' } } } },
{ Recipient: { some: { email: { contains: query, mode: 'insensitive' } } } },
],
};
@ -209,7 +220,6 @@ export const findDocuments = async ({
}
const whereAndClause: Prisma.DocumentWhereInput['AND'] = [
{ ...termFilters },
{ ...filters },
{ ...deletedFilter },
{ ...searchFilter },
@ -290,7 +300,7 @@ export const findDocuments = async ({
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
} satisfies FindResultResponse<typeof data>;
};
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {

View File

@ -1,6 +1,15 @@
import { prisma } from '@documenso/prisma';
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import {
DocumentDataSchema,
DocumentMetaSchema,
DocumentSchema,
FieldSchema,
RecipientSchema,
} from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getDocumentWhereInput } from './get-document-by-id';
export type GetDocumentWithDetailsByIdOptions = {
@ -9,18 +18,29 @@ export type GetDocumentWithDetailsByIdOptions = {
teamId?: number;
};
export const ZGetDocumentWithDetailsByIdResponseSchema = DocumentSchema.extend({
documentData: DocumentDataSchema,
documentMeta: DocumentMetaSchema.nullable(),
Recipient: RecipientSchema.array(),
Field: FieldSchema.array(),
});
export type TGetDocumentWithDetailsByIdResponse = z.infer<
typeof ZGetDocumentWithDetailsByIdResponseSchema
>;
export const getDocumentWithDetailsById = async ({
documentId,
userId,
teamId,
}: GetDocumentWithDetailsByIdOptions): Promise<DocumentWithDetails> => {
}: GetDocumentWithDetailsByIdOptions): Promise<TGetDocumentWithDetailsByIdResponse> => {
const documentWhereInput = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
return await prisma.document.findFirstOrThrow({
const document = await prisma.document.findFirst({
where: documentWhereInput,
include: {
documentData: true,
@ -29,4 +49,12 @@ export const getDocumentWithDetailsById = async ({
Field: true,
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
return document;
};

View File

@ -1,7 +1,9 @@
import { TRPCError } from '@trpc/server';
import type { z } from 'zod';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { DocumentSchema } from '@documenso/prisma/generated/zod';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
@ -13,12 +15,16 @@ export type MoveDocumentToTeamOptions = {
requestMetadata?: RequestMetadata;
};
export const ZMoveDocumentToTeamResponseSchema = DocumentSchema;
export type TMoveDocumentToTeamResponse = z.infer<typeof ZMoveDocumentToTeamResponseSchema>;
export const moveDocumentToTeam = async ({
documentId,
teamId,
userId,
requestMetadata,
}: MoveDocumentToTeamOptions) => {
}: MoveDocumentToTeamOptions): Promise<TMoveDocumentToTeamResponse> => {
return await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({
where: { id: userId },

View File

@ -38,7 +38,7 @@ export const resendDocument = async ({
recipients,
teamId,
requestMetadata,
}: ResendDocumentOptions) => {
}: ResendDocumentOptions): Promise<void> => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,

View File

@ -6,8 +6,12 @@ import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-po
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import {
DocumentStatus,
RecipientRole,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';

View File

@ -72,14 +72,19 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
const i18n = await getI18nInstance(document.documentMeta?.language);
const isDocumentCompletedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentCompleted;
const emailSettings = extractDerivedDocumentEmailSettings(document.documentMeta);
const isDocumentCompletedEmailEnabled = emailSettings.documentCompleted;
const isOwnerDocumentCompletedEmailEnabled = emailSettings.ownerDocumentCompleted;
// If the document owner is not a recipient, OR recipient emails are disabled, then send the email to them separately.
// Send email to document owner if:
// 1. Owner document completed emails are enabled AND
// 2. Either:
// - The owner is not a recipient, OR
// - Recipient emails are disabled
if (
!document.Recipient.find((recipient) => recipient.email === owner.email) ||
!isDocumentCompletedEmailEnabled
isOwnerDocumentCompletedEmailEnabled &&
(!document.Recipient.find((recipient) => recipient.email === owner.email) ||
!isDocumentCompletedEmailEnabled)
) {
const template = createElement(DocumentCompletedEmailTemplate, {
documentName: document.title,

View File

@ -1,3 +1,5 @@
import type { z } from 'zod';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
@ -9,8 +11,13 @@ import {
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import {
DocumentMetaSchema,
DocumentSchema,
RecipientSchema,
} from '@documenso/prisma/generated/zod';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
@ -27,13 +34,20 @@ export type SendDocumentOptions = {
requestMetadata?: RequestMetadata;
};
export const ZSendDocumentResponseSchema = DocumentSchema.extend({
documentMeta: DocumentMetaSchema.nullable(),
Recipient: RecipientSchema.array(),
});
export type TSendDocumentResponse = z.infer<typeof ZSendDocumentResponseSchema>;
export const sendDocument = async ({
documentId,
userId,
teamId,
sendEmail,
requestMetadata,
}: SendDocumentOptions) => {
}: SendDocumentOptions): Promise<TSendDocumentResponse> => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@ -211,6 +225,7 @@ export const sendDocument = async ({
id: documentId,
},
include: {
documentMeta: true,
Recipient: true,
},
});

View File

@ -1,6 +1,7 @@
'use server';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -10,6 +11,7 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '@documenso/prisma/client';
import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client';
import { DocumentSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
@ -29,13 +31,17 @@ export type UpdateDocumentSettingsOptions = {
requestMetadata?: RequestMetadata;
};
export const ZUpdateDocumentSettingsResponseSchema = DocumentSchema;
export type TUpdateDocumentSettingsResponse = z.infer<typeof ZUpdateDocumentSettingsResponseSchema>;
export const updateDocumentSettings = async ({
userId,
teamId,
documentId,
data,
requestMetadata,
}: UpdateDocumentSettingsOptions) => {
}: UpdateDocumentSettingsOptions): Promise<TUpdateDocumentSettingsResponse> => {
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Missing data to update',
@ -85,39 +91,43 @@ export const updateDocumentSettings = async ({
if (teamId) {
const currentUserRole = document.team?.members[0]?.role;
const isDocumentOwner = document.userId === userId;
const requestedVisibility = data.visibility;
match(currentUserRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => {
const allowedVisibilities: DocumentVisibility[] = [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
];
if (!isDocumentOwner) {
match(currentUserRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => {
const allowedVisibilities: DocumentVisibility[] = [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
];
if (
!allowedVisibilities.includes(document.visibility) ||
(data.visibility && !allowedVisibilities.includes(data.visibility))
) {
if (
!allowedVisibilities.includes(document.visibility) ||
(requestedVisibility && !allowedVisibilities.includes(requestedVisibility))
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
});
}
})
.with(TeamMemberRole.MEMBER, () => {
if (
document.visibility !== DocumentVisibility.EVERYONE ||
(requestedVisibility && requestedVisibility !== DocumentVisibility.EVERYONE)
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
});
}
})
.otherwise(() => {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
message: 'You do not have permission to update the document',
});
}
})
.with(TeamMemberRole.MEMBER, () => {
if (
document.visibility !== DocumentVisibility.EVERYONE ||
(data.visibility && data.visibility !== DocumentVisibility.EVERYONE)
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
});
}
})
.otherwise(() => {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document',
});
});
}
}
const { documentAuthOption } = extractDocumentAuthMethods({

View File

@ -1,4 +1,9 @@
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { FieldSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type GetFieldByIdOptions = {
userId: number;
@ -8,13 +13,17 @@ export type GetFieldByIdOptions = {
templateId?: number;
};
export const ZGetFieldByIdResponseSchema = FieldSchema;
export type TGetFieldByIdResponse = z.infer<typeof ZGetFieldByIdResponseSchema>;
export const getFieldById = async ({
userId,
teamId,
fieldId,
documentId,
templateId,
}: GetFieldByIdOptions) => {
}: GetFieldByIdOptions): Promise<TGetFieldByIdResponse> => {
const field = await prisma.field.findFirst({
where: {
id: fieldId,
@ -45,5 +54,11 @@ export const getFieldById = async ({
},
});
if (!field) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Field not found',
});
}
return field;
};

View File

@ -1,4 +1,5 @@
import { isDeepEqual } from 'remeda';
import { z } from 'zod';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
@ -23,6 +24,7 @@ import {
import { prisma } from '@documenso/prisma';
import type { Field } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import { FieldSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
@ -34,12 +36,18 @@ export interface SetFieldsForDocumentOptions {
requestMetadata?: RequestMetadata;
}
export const ZSetFieldsForDocumentResponseSchema = z.object({
fields: z.array(FieldSchema),
});
export type TSetFieldsForDocumentResponse = z.infer<typeof ZSetFieldsForDocumentResponseSchema>;
export const setFieldsForDocument = async ({
userId,
documentId,
fields,
requestMetadata,
}: SetFieldsForDocumentOptions): Promise<Field[]> => {
}: SetFieldsForDocumentOptions): Promise<TSetFieldsForDocumentResponse> => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
@ -75,11 +83,15 @@ export const setFieldsForDocument = async ({
});
if (!document) {
throw new Error('Document not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (document.completedAt) {
throw new Error('Document already complete');
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
});
}
const existingFields = await prisma.field.findMany({
@ -335,7 +347,9 @@ export const setFieldsForDocument = async ({
return !isRemoved && !isUpdated;
});
return [...filteredFields, ...persistedFields];
return {
fields: [...filteredFields, ...persistedFields],
};
};
/**

View File

@ -1,3 +1,5 @@
import { z } from 'zod';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
@ -14,6 +16,7 @@ import {
} from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { FieldType } from '@documenso/prisma/client';
import { FieldSchema } from '@documenso/prisma/generated/zod';
export type SetFieldsForTemplateOptions = {
userId: number;
@ -31,11 +34,17 @@ export type SetFieldsForTemplateOptions = {
}[];
};
export const ZSetFieldsForTemplateResponseSchema = z.object({
fields: z.array(FieldSchema),
});
export type TSetFieldsForTemplateResponse = z.infer<typeof ZSetFieldsForTemplateResponseSchema>;
export const setFieldsForTemplate = async ({
userId,
templateId,
fields,
}: SetFieldsForTemplateOptions) => {
}: SetFieldsForTemplateOptions): Promise<TSetFieldsForTemplateResponse> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
@ -206,5 +215,7 @@ export const setFieldsForTemplate = async ({
return !isRemoved && !isUpdated;
});
return [...filteredFields, ...persistedFields];
return {
fields: [...filteredFields, ...persistedFields],
};
};

View File

@ -8,6 +8,7 @@ import { validateDropdownField } from '@documenso/lib/advanced-fields-validation
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
@ -119,7 +120,8 @@ export const signFieldWithToken = async ({
if (field.type === FieldType.CHECKBOX && field.fieldMeta) {
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkboxFieldValues = value.split(',');
const checkboxFieldValues: string[] = fromCheckboxValue(value);
const errors = validateCheckboxField(checkboxFieldValues, checkboxFieldParsedMeta, true);
if (errors.length > 0) {

View File

@ -1,9 +1,11 @@
import type { PDFField, PDFWidgetAnnotation } from 'pdf-lib';
import { PDFCheckBox, PDFRadioGroup, PDFRef } from 'pdf-lib';
import {
PDFCheckBox,
PDFDict,
type PDFDocument,
PDFName,
PDFRadioGroup,
PDFRef,
drawObject,
popGraphicsState,
pushGraphicsState,
@ -11,7 +13,17 @@ import {
translate,
} from 'pdf-lib';
export const removeOptionalContentGroups = (document: PDFDocument) => {
const context = document.context;
const catalog = context.lookup(context.trailerInfo.Root);
if (catalog instanceof PDFDict) {
catalog.delete(PDFName.of('OCProperties'));
}
};
export const flattenForm = (document: PDFDocument) => {
removeOptionalContentGroups(document);
const form = document.getForm();
form.updateFieldAppearances();

View File

@ -10,6 +10,7 @@ import {
MIN_HANDWRITING_FONT_SIZE,
MIN_STANDARD_FONT_SIZE,
} from '@documenso/lib/constants/pdf';
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import { FieldType } from '@documenso/prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
@ -194,7 +195,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const selected = field.customText.split(',');
const selected: string[] = fromCheckboxValue(field.customText);
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;

View File

@ -0,0 +1,18 @@
import { PDFDocument } from 'pdf-lib';
import { flattenAnnotations } from './flatten-annotations';
import { flattenForm, removeOptionalContentGroups } from './flatten-form';
export const normalizePdf = async (pdf: Buffer) => {
const pdfDoc = await PDFDocument.load(pdf).catch(() => null);
if (!pdfDoc) {
return pdf;
}
removeOptionalContentGroups(pdfDoc);
flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
return Buffer.from(await pdfDoc.save());
};

View File

@ -1,6 +1,7 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { z } from 'zod';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { mailer } from '@documenso/email/mailer';
@ -21,6 +22,7 @@ import { prisma } from '@documenso/prisma';
import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { RecipientSchema } from '@documenso/prisma/generated/zod';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@ -39,13 +41,21 @@ export interface SetRecipientsForDocumentOptions {
requestMetadata?: RequestMetadata;
}
export const ZSetRecipientsForDocumentResponseSchema = z.object({
recipients: RecipientSchema.array(),
});
export type TSetRecipientsForDocumentResponse = z.infer<
typeof ZSetRecipientsForDocumentResponseSchema
>;
export const setRecipientsForDocument = async ({
userId,
teamId,
documentId,
recipients,
requestMetadata,
}: SetRecipientsForDocumentOptions): Promise<Recipient[]> => {
}: SetRecipientsForDocumentOptions): Promise<TSetRecipientsForDocumentResponse> => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
@ -344,7 +354,9 @@ export const setRecipientsForDocument = async ({
return !isRemoved && !isUpdated;
});
return [...filteredRecipients, ...persistedRecipients];
return {
recipients: [...filteredRecipients, ...persistedRecipients],
};
};
/**

View File

@ -1,3 +1,5 @@
import { z } from 'zod';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
@ -6,6 +8,7 @@ import {
import { prisma } from '@documenso/prisma';
import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { RecipientSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
@ -29,12 +32,20 @@ export type SetRecipientsForTemplateOptions = {
}[];
};
export const ZSetRecipientsForTemplateResponseSchema = z.object({
recipients: RecipientSchema.array(),
});
export type TSetRecipientsForTemplateResponse = z.infer<
typeof ZSetRecipientsForTemplateResponseSchema
>;
export const setRecipientsForTemplate = async ({
userId,
teamId,
templateId,
recipients,
}: SetRecipientsForTemplateOptions) => {
}: SetRecipientsForTemplateOptions): Promise<TSetRecipientsForTemplateResponse> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
@ -220,5 +231,7 @@ export const setRecipientsForTemplate = async ({
return !isRemoved && !isUpdated;
});
return [...filteredRecipients, ...persistedRecipients];
return {
recipients: [...filteredRecipients, ...persistedRecipients],
};
};

View File

@ -7,12 +7,12 @@ import { Prisma } from '@documenso/prisma/client';
import { TeamMemberInviteSchema } from '@documenso/prisma/generated/zod';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { type FindResultSet, ZFindResultSet } from '../../types/find-result-set';
import { type FindResultResponse, ZFindResultResponse } from '../../types/search-params';
export interface FindTeamMemberInvitesOptions {
userId: number;
teamId: number;
term?: string;
query?: string;
page?: number;
perPage?: number;
orderBy?: {
@ -21,7 +21,7 @@ export interface FindTeamMemberInvitesOptions {
};
}
export const ZFindTeamMemberInvitesResponseSchema = ZFindResultSet.extend({
export const ZFindTeamMemberInvitesResponseSchema = ZFindResultResponse.extend({
data: TeamMemberInviteSchema.pick({
id: true,
teamId: true,
@ -36,7 +36,7 @@ export type TFindTeamMemberInvitesResponse = z.infer<typeof ZFindTeamMemberInvit
export const findTeamMemberInvites = async ({
userId,
teamId,
term,
query,
page = 1,
perPage = 10,
orderBy,
@ -59,10 +59,10 @@ export const findTeamMemberInvites = async ({
},
});
const termFilters: Prisma.TeamMemberInviteWhereInput | undefined = match(term)
const termFilters: Prisma.TeamMemberInviteWhereInput | undefined = match(query)
.with(P.string.minLength(1), () => ({
email: {
contains: term,
contains: query,
mode: Prisma.QueryMode.insensitive,
},
}))
@ -101,5 +101,5 @@ export const findTeamMemberInvites = async ({
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
} satisfies FindResultResponse<typeof data>;
};

View File

@ -6,12 +6,13 @@ import type { TeamMember } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
import { TeamMemberSchema, UserSchema } from '@documenso/prisma/generated/zod';
import { type FindResultSet, ZFindResultSet } from '../../types/find-result-set';
import type { FindResultResponse } from '../../types/search-params';
import { ZFindResultResponse } from '../../types/search-params';
export interface FindTeamMembersOptions {
userId: number;
teamId: number;
term?: string;
query?: string;
page?: number;
perPage?: number;
orderBy?: {
@ -20,7 +21,7 @@ export interface FindTeamMembersOptions {
};
}
export const ZFindTeamMembersResponseSchema = ZFindResultSet.extend({
export const ZFindTeamMembersResponseSchema = ZFindResultResponse.extend({
data: TeamMemberSchema.extend({
user: UserSchema.pick({
name: true,
@ -34,7 +35,7 @@ export type TFindTeamMembersResponse = z.infer<typeof ZFindTeamMembersResponseSc
export const findTeamMembers = async ({
userId,
teamId,
term,
query,
page = 1,
perPage = 10,
orderBy,
@ -54,11 +55,11 @@ export const findTeamMembers = async ({
},
});
const termFilters: Prisma.TeamMemberWhereInput | undefined = match(term)
const termFilters: Prisma.TeamMemberWhereInput | undefined = match(query)
.with(P.string.minLength(1), () => ({
user: {
name: {
contains: term,
contains: query,
mode: Prisma.QueryMode.insensitive,
},
},
@ -109,5 +110,5 @@ export const findTeamMembers = async ({
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
} satisfies FindResultResponse<typeof data>;
};

View File

@ -5,11 +5,11 @@ import type { Team } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
import { TeamPendingSchema } from '@documenso/prisma/generated/zod';
import { type FindResultSet, ZFindResultSet } from '../../types/find-result-set';
import { type FindResultResponse, ZFindResultResponse } from '../../types/search-params';
export interface FindTeamsPendingOptions {
userId: number;
term?: string;
query?: string;
page?: number;
perPage?: number;
orderBy?: {
@ -18,7 +18,7 @@ export interface FindTeamsPendingOptions {
};
}
export const ZFindTeamsPendingResponseSchema = ZFindResultSet.extend({
export const ZFindTeamsPendingResponseSchema = ZFindResultResponse.extend({
data: TeamPendingSchema.array(),
});
@ -26,7 +26,7 @@ export type TFindTeamsPendingResponse = z.infer<typeof ZFindTeamsPendingResponse
export const findTeamsPending = async ({
userId,
term,
query,
page = 1,
perPage = 10,
orderBy,
@ -38,9 +38,9 @@ export const findTeamsPending = async ({
ownerUserId: userId,
};
if (term && term.length > 0) {
if (query && query.length > 0) {
whereClause.name = {
contains: term,
contains: query,
mode: Prisma.QueryMode.insensitive,
};
}
@ -65,5 +65,5 @@ export const findTeamsPending = async ({
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
} satisfies FindResultResponse<typeof data>;
};

View File

@ -1,11 +1,12 @@
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
import type { FindResultResponse } from '../../types/search-params';
export interface FindTeamsOptions {
userId: number;
term?: string;
query?: string;
page?: number;
perPage?: number;
orderBy?: {
@ -16,7 +17,7 @@ export interface FindTeamsOptions {
export const findTeams = async ({
userId,
term,
query,
page = 1,
perPage = 10,
orderBy,
@ -32,9 +33,9 @@ export const findTeams = async ({
},
};
if (term && term.length > 0) {
if (query && query.length > 0) {
whereClause.name = {
contains: term,
contains: query,
mode: Prisma.QueryMode.insensitive,
};
}
@ -72,5 +73,5 @@ export const findTeams = async ({
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof maskedData>;
} satisfies FindResultResponse<typeof maskedData>;
};

View File

@ -3,6 +3,7 @@ import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
@ -67,6 +68,16 @@ type CreatedDirectRecipientField = {
derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
};
export const ZCreateDocumentFromDirectTemplateResponseSchema = z.object({
token: z.string(),
documentId: z.number(),
recipientId: z.number(),
});
export type TCreateDocumentFromDirectTemplateResponse = z.infer<
typeof ZCreateDocumentFromDirectTemplateResponseSchema
>;
export const createDocumentFromDirectTemplate = async ({
directRecipientName: initialDirectRecipientName,
directRecipientEmail,
@ -76,7 +87,7 @@ export const createDocumentFromDirectTemplate = async ({
templateUpdatedAt,
requestMetadata,
user,
}: CreateDocumentFromDirectTemplateOptions) => {
}: CreateDocumentFromDirectTemplateOptions): Promise<TCreateDocumentFromDirectTemplateResponse> => {
const template = await prisma.template.findFirst({
where: {
directLink: {

View File

@ -1,3 +1,5 @@
import type { z } from 'zod';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { DocumentDistributionMethod } from '@documenso/prisma/client';
@ -11,6 +13,11 @@ import {
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import {
DocumentDataSchema,
DocumentSchema,
RecipientSchema,
} from '@documenso/prisma/generated/zod';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
@ -36,10 +43,6 @@ type FinalRecipient = Pick<
fields: Field[];
};
export type CreateDocumentFromTemplateResponse = Awaited<
ReturnType<typeof createDocumentFromTemplate>
>;
export type CreateDocumentFromTemplateOptions = {
templateId: number;
externalId?: string | null;
@ -72,6 +75,15 @@ export type CreateDocumentFromTemplateOptions = {
requestMetadata?: RequestMetadata;
};
export const ZCreateDocumentFromTemplateResponseSchema = DocumentSchema.extend({
documentData: DocumentDataSchema,
Recipient: RecipientSchema.array(),
});
export type TCreateDocumentFromTemplateResponse = z.infer<
typeof ZCreateDocumentFromTemplateResponseSchema
>;
export const createDocumentFromTemplate = async ({
templateId,
externalId,
@ -80,7 +92,7 @@ export const createDocumentFromTemplate = async ({
recipients,
override,
requestMetadata,
}: CreateDocumentFromTemplateOptions) => {
}: CreateDocumentFromTemplateOptions): Promise<TCreateDocumentFromTemplateResponse> => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,

View File

@ -1,13 +1,15 @@
'use server';
import { nanoid } from 'nanoid';
import type { z } from 'zod';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '@documenso/lib/constants/direct-templates';
import { prisma } from '@documenso/prisma';
import type { Recipient, TemplateDirectLink } from '@documenso/prisma/client';
import type { Recipient } from '@documenso/prisma/client';
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
@ -17,11 +19,17 @@ export type CreateTemplateDirectLinkOptions = {
directRecipientId?: number;
};
export const ZCreateTemplateDirectLinkResponseSchema = TemplateDirectLinkSchema;
export type TCreateTemplateDirectLinkResponse = z.infer<
typeof ZCreateTemplateDirectLinkResponseSchema
>;
export const createTemplateDirectLink = async ({
templateId,
userId,
directRecipientId,
}: CreateTemplateDirectLinkOptions): Promise<TemplateDirectLink> => {
}: CreateTemplateDirectLinkOptions): Promise<TCreateTemplateDirectLinkResponse> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,

View File

@ -1,4 +1,7 @@
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TemplateSchema } from '@documenso/prisma/generated/zod';
import type { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
export type CreateTemplateOptions = TCreateTemplateMutationSchema & {
@ -6,6 +9,10 @@ export type CreateTemplateOptions = TCreateTemplateMutationSchema & {
teamId?: number;
};
export const ZCreateTemplateResponseSchema = TemplateSchema;
export type TCreateTemplateResponse = z.infer<typeof ZCreateTemplateResponseSchema>;
export const createTemplate = async ({
title,
userId,

View File

@ -1,19 +1,25 @@
import { omit } from 'remeda';
import type { z } from 'zod';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import { TemplateSchema } from '@documenso/prisma/generated/zod';
import type { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & {
userId: number;
};
export const ZDuplicateTemplateResponseSchema = TemplateSchema;
export type TDuplicateTemplateResponse = z.infer<typeof ZDuplicateTemplateResponseSchema>;
export const duplicateTemplate = async ({
templateId,
userId,
teamId,
}: DuplicateTemplateOptions) => {
}: DuplicateTemplateOptions): Promise<TDuplicateTemplateResponse> => {
let templateWhereFilter: Prisma.TemplateWhereUniqueInput = {
id: templateId,
userId,

View File

@ -1,5 +1,18 @@
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import type { Prisma, Template } from '@documenso/prisma/client';
import {
DocumentDataSchema,
FieldSchema,
RecipientSchema,
TeamSchema,
TemplateDirectLinkSchema,
TemplateMetaSchema,
TemplateSchema,
} from '@documenso/prisma/generated/zod';
import { type FindResultResponse, ZFindResultResponse } from '../../types/search-params';
export type FindTemplatesOptions = {
userId: number;
@ -9,8 +22,28 @@ export type FindTemplatesOptions = {
perPage?: number;
};
export type FindTemplatesResponse = Awaited<ReturnType<typeof findTemplates>>;
export type FindTemplateRow = FindTemplatesResponse['templates'][number];
export const ZFindTemplatesResponseSchema = ZFindResultResponse.extend({
data: TemplateSchema.extend({
templateDocumentData: DocumentDataSchema,
team: TeamSchema.pick({
id: true,
url: true,
}).nullable(),
Field: FieldSchema.array(),
Recipient: RecipientSchema.array(),
templateMeta: TemplateMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
}).nullable(),
directLink: TemplateDirectLinkSchema.pick({
token: true,
enabled: true,
}).nullable(),
}).array(), // Todo: openapi.
});
export type TFindTemplatesResponse = z.infer<typeof ZFindTemplatesResponseSchema>;
export type FindTemplateRow = TFindTemplatesResponse['data'][number];
export const findTemplates = async ({
userId,
@ -18,7 +51,7 @@ export const findTemplates = async ({
type,
page = 1,
perPage = 10,
}: FindTemplatesOptions) => {
}: FindTemplatesOptions): Promise<TFindTemplatesResponse> => {
let whereFilter: Prisma.TemplateWhereInput = {
userId,
teamId: null,
@ -38,7 +71,7 @@ export const findTemplates = async ({
};
}
const [templates, count] = await Promise.all([
const [data, count] = await Promise.all([
prisma.template.findMany({
where: whereFilter,
include: {
@ -75,7 +108,10 @@ export const findTemplates = async ({
]);
return {
templates,
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
};
} satisfies FindResultResponse<typeof data>;
};

View File

@ -1,6 +1,9 @@
import { TRPCError } from '@trpc/server';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TemplateSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type MoveTemplateToTeamOptions = {
templateId: number;
@ -8,11 +11,15 @@ export type MoveTemplateToTeamOptions = {
userId: number;
};
export const ZMoveTemplateToTeamResponseSchema = TemplateSchema;
export type TMoveTemplateToTeamResponse = z.infer<typeof ZMoveTemplateToTeamResponseSchema>;
export const moveTemplateToTeam = async ({
templateId,
teamId,
userId,
}: MoveTemplateToTeamOptions) => {
}: MoveTemplateToTeamOptions): Promise<TMoveTemplateToTeamResponse> => {
return await prisma.$transaction(async (tx) => {
const template = await tx.template.findFirst({
where: {
@ -23,8 +30,7 @@ export const moveTemplateToTeam = async ({
});
if (!template) {
throw new TRPCError({
code: 'NOT_FOUND',
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found or already associated with a team.',
});
}
@ -41,9 +47,8 @@ export const moveTemplateToTeam = async ({
});
if (!team) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not a member of this team.',
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Team does not exist or you are not a member of this team.',
});
}

View File

@ -1,6 +1,9 @@
'use server';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
@ -10,11 +13,17 @@ export type ToggleTemplateDirectLinkOptions = {
enabled: boolean;
};
export const ZToggleTemplateDirectLinkResponseSchema = TemplateDirectLinkSchema;
export type TToggleTemplateDirectLinkResponse = z.infer<
typeof ZToggleTemplateDirectLinkResponseSchema
>;
export const toggleTemplateDirectLink = async ({
templateId,
userId,
enabled,
}: ToggleTemplateDirectLinkOptions) => {
}: ToggleTemplateDirectLinkOptions): Promise<TToggleTemplateDirectLinkResponse> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,

View File

@ -1,9 +1,12 @@
'use server';
import type { z } from 'zod';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import type { Template, TemplateMeta } from '@documenso/prisma/client';
import { TemplateSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
@ -26,13 +29,17 @@ export type UpdateTemplateSettingsOptions = {
requestMetadata?: RequestMetadata;
};
export const ZUpdateTemplateSettingsResponseSchema = TemplateSchema;
export type TUpdateTemplateSettingsResponse = z.infer<typeof ZUpdateTemplateSettingsResponseSchema>;
export const updateTemplateSettings = async ({
userId,
teamId,
templateId,
meta,
data,
}: UpdateTemplateSettingsOptions) => {
}: UpdateTemplateSettingsOptions): Promise<TUpdateTemplateSettingsResponse> => {
if (Object.values(data).length === 0 && Object.keys(meta ?? {}).length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Missing data to update',

View File

@ -1,7 +1,8 @@
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { prisma } from '@documenso/prisma';
import type { UserSecurityAuditLog, UserSecurityAuditLogType } from '@documenso/prisma/client';
import type { FindResultResponse } from '../../types/search-params';
export type FindUserSecurityAuditLogsOptions = {
userId: number;
type?: UserSecurityAuditLogType;
@ -48,5 +49,5 @@ export const findUserSecurityAuditLogs = async ({
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
} satisfies FindResultResponse<typeof data>;
};

View File

@ -9,6 +9,7 @@ export enum DocumentEmailEvents {
DocumentPending = 'documentPending',
DocumentCompleted = 'documentCompleted',
DocumentDeleted = 'documentDeleted',
OwnerDocumentCompleted = 'ownerDocumentCompleted',
}
export const ZDocumentEmailSettingsSchema = z
@ -18,6 +19,7 @@ export const ZDocumentEmailSettingsSchema = z
documentPending: z.boolean().default(true),
documentCompleted: z.boolean().default(true),
documentDeleted: z.boolean().default(true),
ownerDocumentCompleted: z.boolean().default(true),
})
.strip()
.catch(() => ({
@ -26,6 +28,7 @@ export const ZDocumentEmailSettingsSchema = z
documentPending: true,
documentCompleted: true,
documentDeleted: true,
ownerDocumentCompleted: true,
}));
export type TDocumentEmailSettings = z.infer<typeof ZDocumentEmailSettingsSchema>;
@ -48,5 +51,6 @@ export const extractDerivedDocumentEmailSettings = (
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerDocumentCompleted: emailSettings.ownerDocumentCompleted,
};
};

View File

@ -1,18 +0,0 @@
import { z } from 'zod';
export const ZFindResultSet = z.object({
data: z.union([z.array(z.unknown()), z.unknown()]),
count: z.number(),
currentPage: z.number(),
perPage: z.number(),
totalPages: z.number(),
});
// Can't infer generics from Zod.
export type FindResultSet<T> = {
data: T extends Array<unknown> ? T : T[];
count: number;
currentPage: number;
perPage: number;
totalPages: number;
};

View File

@ -1,6 +1,24 @@
import { z } from 'zod';
export const ZBaseTableSearchParamsSchema = z.object({
/**
* Backend only schema is used for find search params.
*
* Does not catch, because TRPC Open API won't allow catches as a type.
*
* Keep this and `ZUrlSearchParamsSchema` in sync.
*/
export const ZFindSearchParamsSchema = z.object({
query: z.string().optional(),
page: z.coerce.number().min(1).optional(),
perPage: z.coerce.number().min(1).optional(),
});
/**
* Frontend schema used to parse search params from URL.
*
* Keep this and `ZFindSearchParamsSchema` in sync.
*/
export const ZUrlSearchParamsSchema = z.object({
query: z
.string()
.optional()
@ -17,4 +35,19 @@ export const ZBaseTableSearchParamsSchema = z.object({
.catch(() => undefined),
});
export type TBaseTableSearchParamsSchema = z.infer<typeof ZBaseTableSearchParamsSchema>;
export const ZFindResultResponse = z.object({
data: z.union([z.array(z.unknown()), z.unknown()]),
count: z.number(),
currentPage: z.number(),
perPage: z.number(),
totalPages: z.number(),
});
// Can't infer generics from Zod.
export type FindResultResponse<T> = {
data: T extends Array<unknown> ? T : T[];
count: number;
currentPage: number;
perPage: number;
totalPages: number;
};

View File

@ -0,0 +1,21 @@
export const fromCheckboxValue = (customText: string): string[] => {
if (!customText) {
return [];
}
try {
const parsed = JSON.parse(customText);
if (!Array.isArray(parsed)) {
throw new Error('Parsed checkbox values are not an array');
}
return parsed;
} catch {
return customText.split(',').filter(Boolean);
}
};
export const toCheckboxValue = (values: string[]): string => {
return JSON.stringify(values);
};

View File

@ -24,13 +24,16 @@ export const putPdfFile = async (file: File) => {
() => false,
);
// This will prevent uploading encrypted PDFs or anything that can't be opened.
if (!isEncryptedDocumentsAllowed) {
await PDFDocument.load(await file.arrayBuffer()).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
const arrayBuffer = await file.arrayBuffer();
throw new AppError('INVALID_DOCUMENT_FILE');
});
const pdf = await PDFDocument.load(arrayBuffer).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE');
});
if (!isEncryptedDocumentsAllowed && pdf.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE');
}
if (!file.name.endsWith('.pdf')) {

View File

@ -1,6 +1,3 @@
import { env } from 'next-runtime-env';
import { IS_BILLING_ENABLED } from '../constants/app';
import type { Subscription } from '.prisma/client';
import { SubscriptionStatus } from '.prisma/client';
@ -10,12 +7,21 @@ import { SubscriptionStatus } from '.prisma/client';
export const subscriptionsContainsActivePlan = (
subscriptions: Subscription[],
priceIds: string[],
allowPastDue?: boolean,
) => {
const allowedSubscriptionStatuses: SubscriptionStatus[] = [SubscriptionStatus.ACTIVE];
if (allowPastDue) {
allowedSubscriptionStatuses.push(SubscriptionStatus.PAST_DUE);
}
return subscriptions.some(
(subscription) =>
subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId),
allowedSubscriptionStatuses.includes(subscription.status) &&
priceIds.includes(subscription.priceId),
);
};
/**
* Returns true if there is a subscription that is active and is one of the provided product IDs.
*/
@ -28,15 +34,3 @@ export const subscriptionsContainsActiveProductId = (
subscription.status === SubscriptionStatus.ACTIVE && productId.includes(subscription.planId),
);
};
export const subscriptionsContainActiveEnterprisePlan = (
subscriptions?: Subscription[],
): boolean => {
const enterprisePlanId = env('NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID');
if (!enterprisePlanId || !subscriptions || !IS_BILLING_ENABLED()) {
return false;
}
return subscriptionsContainsActivePlan(subscriptions, [enterprisePlanId]);
};

View File

@ -78,7 +78,9 @@ class HoneybadgerLogger implements Logger {
error(error: Error, options?: LoggerDescriptionOptions): void {
const { context = {}, level = 'error', method, path } = options || {};
const tags = [`level:${level}`];
// const tags = [`level:${level}`];
const tags = [];
let errorMessage = error.message;
if (method) {

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "disabled" BOOLEAN NOT NULL DEFAULT false;

View File

@ -42,6 +42,7 @@ model User {
roles Role[] @default([USER])
identityProvider IdentityProvider @default(DOCUMENSO)
avatarImageId String?
disabled Boolean @default(false)
accounts Account[]
sessions Session[]

Some files were not shown because too many files have changed in this diff Show More