Compare commits

...

8 Commits

Author SHA1 Message Date
5dbbed9ba8 chore: update deps 2025-01-12 23:15:06 +11:00
08a69c6168 fix: pending document edit (#1580)
Fix issue where you cannot edit a pending document when there is a CCer
recipient.
2025-01-11 22:31:59 +11:00
948d9c24cf fix: broken direct template webhook (#1579) 2025-01-11 21:42:33 +11:00
ebbe922982 feat: add template and field endpoints (#1572) 2025-01-11 15:33:20 +11:00
6520bbd5e3 Update FEATURES
chore: typo
2025-01-10 14:25:39 +01:00
4e197ac24c v1.9.0-rc.7 2025-01-09 15:07:11 +11:00
f707e5fb10 fix: update template field schema (#1575)
Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2025-01-09 12:06:17 +11:00
6fc5e565d0 fix: add document visibility to template (#1566)
Adds the visibility property to templates
2025-01-09 10:14:24 +11:00
115 changed files with 7667 additions and 9910 deletions

View File

@ -16,7 +16,7 @@
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"next": "14.2.6",
"next": "14.2.23",
"next-plausible": "^3.12.0",
"nextra": "^2.13.4",
"nextra-theme-docs": "^2.13.4",
@ -27,6 +27,6 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5"
"typescript": "5.2.2"
}
}

View File

@ -13,11 +13,11 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.5.0",
"next": "14.2.6"
"next": "14.2.23"
},
"devDependencies": {
"@types/node": "20.16.5",
"@types/react": "18.3.5",
"typescript": "5.5.4"
"@types/node": "^20",
"@types/react": "^18",
"typescript": "5.2.2"
}
}
}

View File

@ -3,4 +3,4 @@
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.9.0-rc.6",
"version": "1.9.0-rc.7",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@ -33,12 +33,12 @@
"framer-motion": "^10.12.8",
"input-otp": "^1.2.4",
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"luxon": "^3.5.0",
"micro": "^10.0.1",
"next": "14.2.6",
"next": "14.2.23",
"next-auth": "4.24.5",
"next-axiom": "^1.5.1",
"next-plausible": "^3.10.1",
"next-plausible": "^3.12.0",
"next-themes": "^0.2.1",
"papaparse": "^5.4.1",
"perfect-freehand": "^1.2.0",
@ -68,11 +68,11 @@
"@simplewebauthn/types": "^9.0.1",
"@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/node": "^20",
"@types/papaparse": "^5.3.14",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/ua-parser-js": "^0.7.39",
"typescript": "5.2.2"
}
}
}

View File

@ -25,7 +25,7 @@ export type DocumentPageViewButtonProps = {
team?: Pick<Team, 'id' | 'url'>;
};
export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => {
export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
@ -48,7 +48,6 @@ export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButto
try {
const documentWithData = await trpcClient.document.getDocumentById.query({
documentId: document.id,
teamId: team?.id,
});
const documentData = documentWithData?.documentData;

View File

@ -76,7 +76,6 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
try {
const documentWithData = await trpcClient.document.getDocumentById.query({
documentId: document.id,
teamId: team?.id,
});
const documentData = documentWithData?.documentData;

View File

@ -125,6 +125,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
getFieldsForDocument({
documentId,
userId: user.id,
teamId: team?.id,
}),
]);

View File

@ -64,7 +64,6 @@ export const EditDocumentForm = ({
trpc.document.getDocumentWithDetailsById.useQuery(
{
documentId: initialDocument.id,
teamId: team?.id,
},
{
initialData: initialDocument,
@ -74,13 +73,12 @@ export const EditDocumentForm = ({
const { Recipient: recipients, Field: fields } = document;
const { mutateAsync: setSettingsForDocument } = trpc.document.setSettingsForDocument.useMutation({
const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData }),
);
@ -94,7 +92,6 @@ export const EditDocumentForm = ({
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData, id: Number(newData.id) }),
);
@ -107,38 +104,18 @@ export const EditDocumentForm = ({
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({ ...(oldData || initialDocument), Field: newFields }),
);
},
});
const { mutateAsync: updateTypedSignature } =
trpc.document.updateTypedSignatureSettings.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({
...(oldData || initialDocument),
...newData,
id: Number(newData.id),
}),
);
},
});
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation({
const { mutateAsync: setRecipients } = trpc.recipient.setDocumentRecipients.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: ({ recipients: newRecipients }) => {
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({ ...(oldData || initialDocument), Recipient: newRecipients }),
);
@ -151,7 +128,6 @@ export const EditDocumentForm = ({
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData }),
);
@ -205,9 +181,8 @@ export const EditDocumentForm = ({
try {
const { timezone, dateFormat, redirectUrl, language } = data.meta;
await setSettingsForDocument({
await updateDocument({
documentId: document.id,
teamId: team?.id,
data: {
title: data.title,
externalId: data.externalId || null,
@ -246,10 +221,9 @@ export const EditDocumentForm = ({
signingOrder: data.signingOrder,
}),
addSigners({
setRecipients({
documentId: document.id,
teamId: team?.id,
signers: data.signers.map((signer) => ({
recipients: data.signers.map((signer) => ({
...signer,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth || null,
@ -279,9 +253,12 @@ export const EditDocumentForm = ({
fields: data.fields,
});
await updateTypedSignature({
await updateDocument({
documentId: document.id,
typedSignatureEnabled: data.typedSignatureEnabled,
meta: {
typedSignatureEnabled: data.typedSignatureEnabled,
},
});
// Clear all field data from localStorage
@ -313,7 +290,6 @@ export const EditDocumentForm = ({
try {
await sendDocument({
documentId: document.id,
teamId: team?.id,
meta: {
subject,
message,

View File

@ -15,11 +15,7 @@ export type DownloadAuditLogButtonProps = {
documentId: number;
};
export const DownloadAuditLogButton = ({
className,
teamId,
documentId,
}: DownloadAuditLogButtonProps) => {
export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@ -28,7 +24,7 @@ export const DownloadAuditLogButton = ({
const onDownloadAuditLogsClick = async () => {
try {
const { url } = await downloadAuditLogs({ teamId, documentId });
const { url } = await downloadAuditLogs({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,

View File

@ -31,7 +31,7 @@ export const DownloadCertificateButton = ({
const onDownloadCertificatesClick = async () => {
try {
const { url } = await downloadCertificate({ documentId, teamId });
const { url } = await downloadCertificate({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,

View File

@ -91,7 +91,7 @@ export const ResendDocumentActionItem = ({
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
await resendDocument({ documentId: document.id, recipients });
toast({
title: _(msg`Document re-sent`),

View File

@ -55,7 +55,6 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
documentId: row.id,
teamId: team?.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({

View File

@ -86,7 +86,6 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
documentId: row.id,
teamId: team?.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({

View File

@ -38,7 +38,6 @@ export const DeleteDocumentDialog = ({
onOpenChange,
status,
documentTitle,
teamId,
canManageDocument,
}: DeleteDocumentDialogProps) => {
const router = useRouter();
@ -76,7 +75,7 @@ export const DeleteDocumentDialog = ({
const onDelete = async () => {
try {
await deleteDocument({ documentId: id, teamId });
await deleteDocument({ documentId: id });
} catch {
toast({
title: _(msg`Something went wrong`),

View File

@ -37,7 +37,6 @@ export const DuplicateDocumentDialog = ({
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
documentId: id,
teamId: team?.id,
});
const documentData = document?.documentData
@ -66,7 +65,7 @@ export const DuplicateDocumentDialog = ({
const onDuplicate = async () => {
try {
await duplicateDocument({ documentId: id, teamId: team?.id });
await duplicateDocument({ documentId: id });
} catch {
toast({
title: _(msg`Something went wrong`),

View File

@ -76,7 +76,6 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
const { id } = await createDocument({
title: file.name,
documentDataId,
teamId: team?.id,
timezone: userTimezone,
});

View File

@ -61,7 +61,6 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
const { data } = trpc.template.findTemplates.useQuery({
perPage: 100,
teamId: team?.id,
});
const { mutateAsync: updateUserProfile, isLoading: isUpdatingUserProfile } =

View File

@ -23,15 +23,12 @@ import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
type DirectTemplate = FindTemplateRow & {
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
};
export const PublicTemplatesDataTable = () => {
const team = useOptionalCurrentTeam();
const { _ } = useLingui();
const { toast } = useToast();
@ -43,9 +40,7 @@ export const PublicTemplatesDataTable = () => {
} | null>(null);
const { data, isInitialLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery(
{
teamId: team?.id,
},
{},
{
keepPreviousData: true,
},

View File

@ -62,7 +62,6 @@ export const EditTemplateForm = ({
const { data: template, refetch: refetchTemplate } = trpc.template.getTemplateById.useQuery(
{
templateId: initialTemplate.id,
teamId: initialTemplate.teamId || undefined,
},
{
initialData: initialTemplate,
@ -104,19 +103,6 @@ export const EditTemplateForm = ({
},
});
const { mutateAsync: setSigningOrderForTemplate } =
trpc.template.setSigningOrderForTemplate.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateById.setData(
{
templateId: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
@ -129,7 +115,7 @@ export const EditTemplateForm = ({
},
});
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation({
const { mutateAsync: setRecipients } = trpc.recipient.setTemplateRecipients.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateById.setData(
@ -141,31 +127,14 @@ export const EditTemplateForm = ({
},
});
const { mutateAsync: updateTypedSignature } =
trpc.template.updateTemplateTypedSignatureSettings.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateById.setData(
{
templateId: initialTemplate.id,
},
(oldData) => ({
...(oldData || initialTemplate),
...newData,
id: Number(newData.id),
}),
);
},
});
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
try {
await updateTemplateSettings({
templateId: template.id,
teamId: team?.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
@ -195,16 +164,16 @@ export const EditTemplateForm = ({
) => {
try {
await Promise.all([
setSigningOrderForTemplate({
updateTemplateSettings({
templateId: template.id,
teamId: team?.id,
signingOrder: data.signingOrder,
meta: {
signingOrder: data.signingOrder,
},
}),
addTemplateSigners({
setRecipients({
templateId: template.id,
teamId: team?.id,
signers: data.signers,
recipients: data.signers,
}),
]);
@ -228,10 +197,11 @@ export const EditTemplateForm = ({
fields: data.fields,
});
await updateTypedSignature({
await updateTemplateSettings({
templateId: template.id,
teamId: team?.id,
typedSignatureEnabled: data.typedSignatureEnabled,
meta: {
typedSignatureEnabled: data.typedSignatureEnabled,
},
});
// Clear all field data from localStorage
@ -296,6 +266,7 @@ export const EditTemplateForm = ({
<AddTemplateSettingsFormPartial
key={recipients.length}
template={template}
currentTeamMemberRole={team?.currentTeamMember?.role}
documentFlow={documentFlow.settings}
recipients={recipients}
fields={fields}

View File

@ -73,7 +73,6 @@ export const TemplatePageViewDocumentsTable = ({
trpc.document.findDocuments.useQuery(
{
templateId,
teamId: team?.id,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
query: parsedSearchParams.query,

View File

@ -20,12 +20,10 @@ export type TemplatePageViewRecentActivityProps = {
export const TemplatePageViewRecentActivity = ({
templateId,
teamId,
documentRootPath,
}: TemplatePageViewRecentActivityProps) => {
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({
templateId,
teamId,
orderByColumn: 'createdAt',
orderByDirection: 'asc',
perPage: 5,

View File

@ -22,12 +22,7 @@ type DeleteTemplateDialogProps = {
onOpenChange: (_open: boolean) => void;
};
export const DeleteTemplateDialog = ({
id,
teamId,
open,
onOpenChange,
}: DeleteTemplateDialogProps) => {
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
const router = useRouter();
const { _ } = useLingui();
@ -85,7 +80,7 @@ export const DeleteTemplateDialog = ({
type="button"
variant="destructive"
loading={isLoading}
onClick={async () => deleteTemplate({ templateId: id, teamId })}
onClick={async () => deleteTemplate({ templateId: id })}
>
<Trans>Delete</Trans>
</Button>

View File

@ -24,7 +24,6 @@ type DuplicateTemplateDialogProps = {
export const DuplicateTemplateDialog = ({
id,
teamId,
open,
onOpenChange,
}: DuplicateTemplateDialogProps) => {
@ -84,7 +83,6 @@ export const DuplicateTemplateDialog = ({
onClick={async () =>
duplicateTemplate({
templateId: id,
teamId,
})
}
>

View File

@ -31,7 +31,7 @@ type NewTemplateDialogProps = {
templateRootPath: string;
};
export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialogProps) => {
export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps) => {
const router = useRouter();
const { data: session } = useSession();
@ -58,7 +58,6 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
});
const { id } = await createTemplate({
teamId,
title: file.name,
templateDocumentDataId,
});

View File

@ -174,7 +174,6 @@ export const TemplateDirectLinkDialog = ({
await createTemplateDirectLink({
templateId: template.id,
teamId: team?.id,
directRecipientId: recipientId,
});
};
@ -345,7 +344,6 @@ export const TemplateDirectLinkDialog = ({
onClick={async () =>
createTemplateDirectLink({
templateId: template.id,
teamId: team?.id,
})
}
>

View File

@ -47,8 +47,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
@ -120,8 +118,6 @@ export function UseTemplateDialog({
const [open, setOpen] = useState(false);
const team = useOptionalCurrentTeam();
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
@ -163,7 +159,6 @@ export function UseTemplateDialog({
const { id } = await createDocumentFromTemplate({
templateId,
teamId: team?.id,
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentDataId,

View File

@ -66,6 +66,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
const { data: auditLogs } = await findDocumentAuditLogs({
documentId: documentId,
userId: document.userId,
teamId: document.teamId || undefined,
perPage: 100_000,
});

View File

@ -8,6 +8,7 @@ import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-c
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { TrpcProvider } from '@documenso/trpc/react';
import { Header } from '~/components/(dashboard)/layout/header';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
@ -47,6 +48,10 @@ export default async function AuthenticatedTeamsLayout({
const team = getTeamPromise.value;
const teams = getTeamsPromise.status === 'fulfilled' ? getTeamsPromise.value : [];
const trpcHeaders = {
'x-team-Id': team.id.toString(),
};
return (
<NextAuthProvider session={session}>
<LimitsProvider teamId={team.id}>
@ -61,7 +66,9 @@ export default async function AuthenticatedTeamsLayout({
<Header user={user} teams={teams} />
<TeamProvider team={team}>
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
<TrpcProvider headers={trpcHeaders}>
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
</TrpcProvider>
</TeamProvider>
<RefreshOnFocus />

View File

@ -123,7 +123,6 @@ export const ManagePublicTemplateDialog = ({
try {
await updateTemplateSettings({
templateId,
teamId: team?.id,
data: {
type: TemplateType.PRIVATE,
},
@ -158,7 +157,6 @@ export const ManagePublicTemplateDialog = ({
try {
await updateTemplateSettings({
templateId: selectedTemplateId,
teamId: team?.id,
data: {
type: TemplateType.PUBLIC,
publicTitle,

View File

@ -17,7 +17,7 @@ const logger = buildLogger();
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
createContext: async ({ req, res }) => createTrpcContext({ req, res, requestSource: 'app' }),
onError(opts) {
const { error, path } = opts;

View File

@ -13,7 +13,7 @@ const logger = buildLogger();
export default createOpenApiNextHandler<typeof appRouter>({
router: appRouter,
createContext: async ({ req, res }: { req: NextApiRequest; res: NextApiResponse }) =>
createTrpcContext({ req, res }),
createTrpcContext({ req, res, requestSource: 'apiV2' }),
onError: ({ error, path }: { error: TRPCError; path?: string }) => {
// Always log the error for now.
console.error(error.message);

11828
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "1.9.0-rc.6",
"version": "1.9.0-rc.7",
"scripts": {
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",
@ -69,15 +69,16 @@
"inngest-cli": "^0.29.1",
"luxon": "^3.5.0",
"mupdf": "^1.0.0",
"next": "14.2.23",
"next-runtime-env": "^3.2.0",
"react": "^18",
"zod": "3.24.1"
},
"overrides": {
"next": "14.2.6",
"zod": "3.24.1"
"zod": "3.24.1",
"next": "14.2.23"
},
"trigger.dev": {
"endpointId": "documenso-app"
}
}
}

View File

@ -21,7 +21,7 @@
"@ts-rest/next": "^3.30.5",
"@ts-rest/open-api": "^3.33.0",
"@types/swagger-ui-react": "^4.18.3",
"luxon": "^3.4.0",
"luxon": "^3.5.0",
"superjson": "^1.13.1",
"swagger-ui-react": "^5.11.0",
"ts-pattern": "^5.0.5",

View File

@ -1,5 +1,6 @@
import { createNextRoute } from '@ts-rest/next';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
@ -15,8 +16,7 @@ import { findDocuments } from '@documenso/lib/server-only/document/find-document
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { updateDocumentSettings } from '@documenso/lib/server-only/document/update-document-settings';
import { updateDocument as updateDocumentSettings } from '@documenso/lib/server-only/document/update-document';
import { deleteField } from '@documenso/lib/server-only/field/delete-field';
import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
@ -25,7 +25,7 @@ import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-for
import { deleteRecipient } from '@documenso/lib/server-only/recipient/delete-recipient';
import { getRecipientByIdV1Api } from '@documenso/lib/server-only/recipient/get-recipient-by-id-v1-api';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
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';
@ -36,10 +36,10 @@ import { deleteTemplate } from '@documenso/lib/server-only/template/delete-templ
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,
ZDropdownFieldMeta,
ZFieldMetaSchema,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
@ -53,6 +53,7 @@ import {
} from '@documenso/lib/universal/upload/server-actions';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import {
DocumentDataType,
DocumentStatus,
@ -62,6 +63,7 @@ import {
import { ApiContractV1 } from './contract';
import { authenticatedMiddleware } from './middleware/authenticated';
import { ZTemplateWithDataSchema } from './schema';
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
getDocuments: authenticatedMiddleware(async (args, user, team) => {
@ -96,13 +98,14 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const recipients = await getRecipientsForDocument({
documentId: Number(documentId),
teamId: team?.id,
userId: user.id,
teamId: team?.id,
});
const fields = await getFieldsForDocument({
documentId: Number(documentId),
userId: user.id,
teamId: team?.id,
});
const parsedMetaFields = fields.map((field) => {
@ -207,7 +210,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
deleteDocument: authenticatedMiddleware(async (args, user, team) => {
deleteDocument: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { id: documentId } = args.params;
try {
@ -230,6 +233,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
id: document.id,
userId: user.id,
teamId: team?.id,
requestMetadata: metadata,
});
return {
@ -246,7 +250,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
createDocument: authenticatedMiddleware(async (args, user, team) => {
createDocument: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { body } = args;
try {
@ -314,12 +318,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
teamId: team?.id,
formValues: body.formValues,
documentDataId: documentData.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata,
});
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
teamId: team?.id,
subject: body.meta.subject,
message: body.meta.message,
timezone,
@ -330,7 +335,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
typedSignatureEnabled: body.meta.typedSignatureEnabled,
distributionMethod: body.meta.distributionMethod,
emailSettings: body.meta.emailSettings,
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata,
});
if (body.authOptions) {
@ -341,16 +346,16 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
data: {
...body.authOptions,
},
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata,
});
}
const { recipients } = await setRecipientsForDocument({
const { recipients } = await setDocumentRecipients({
userId: user.id,
teamId: team?.id,
documentId: document.id,
recipients: body.recipients,
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata,
});
return {
@ -414,9 +419,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
teamId: team?.id,
});
const parsed = ZTemplateWithDataSchema.parse(template);
return {
status: 200,
body: template,
body: parsed,
};
} catch (err) {
return AppError.toRestAPIError(err);
@ -435,10 +442,12 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
teamId: team?.id,
});
const parsed = z.array(ZTemplateWithDataSchema).parse(templates);
return {
status: 200,
body: {
templates,
templates: parsed,
totalPages,
},
};
@ -447,7 +456,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => {
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { body, params } = args;
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
@ -511,8 +520,9 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
teamId: team?.id,
...body.meta,
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata,
});
}
@ -522,7 +532,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
userId: user.id,
teamId: team?.id,
data: body.authOptions,
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata,
});
}
@ -544,7 +554,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}),
generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => {
generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { body, params } = args;
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
@ -573,6 +583,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
title: body.title,
...body.meta,
},
requestMetadata: metadata,
});
} catch (err) {
return AppError.toRestAPIError(err);
@ -615,7 +626,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
userId: user.id,
teamId: team?.id,
data: body.authOptions,
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata,
});
}
@ -636,7 +647,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}),
sendDocument: authenticatedMiddleware(async (args, user, team) => {
sendDocument: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { id: documentId } = args.params;
const { sendEmail, sendCompletionEmails } = args.body;
@ -672,12 +683,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
teamId: team?.id,
emailSettings: {
...emailSettings,
documentCompleted: sendCompletionEmails,
ownerDocumentCompleted: sendCompletionEmails,
},
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata,
});
}
@ -686,7 +698,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
userId: user.id,
teamId: team?.id,
sendEmail,
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata,
});
return {
@ -710,7 +722,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
resendDocument: authenticatedMiddleware(async (args, user, team) => {
resendDocument: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { id: documentId } = args.params;
const { recipients } = args.body;
@ -720,7 +732,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
documentId: Number(documentId),
recipients,
teamId: team?.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata,
});
return {
@ -739,7 +751,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
createRecipient: authenticatedMiddleware(async (args, user, team) => {
createRecipient: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { id: documentId } = args.params;
const { name, email, role, authOptions, signingOrder } = args.body;
@ -785,7 +797,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
try {
const { recipients: newRecipients } = await setRecipientsForDocument({
const { recipients: newRecipients } = await setDocumentRecipients({
documentId: Number(documentId),
userId: user.id,
teamId: team?.id,
@ -803,7 +815,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
actionAuth: authOptions?.actionAuth ?? null,
},
],
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata,
});
const newRecipient = newRecipients.find((recipient) => recipient.email === email);
@ -1568,3 +1580,39 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}),
});
const updateDocument = async ({
documentId,
userId,
teamId,
data,
}: {
documentId: number;
data: Prisma.DocumentUpdateInput;
userId: number;
teamId?: number;
}) => {
return await prisma.document.update({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
data: {
...data,
},
});
};

View File

@ -2,6 +2,8 @@ import type { NextApiRequest } from 'next';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { Team, User } from '@documenso/prisma/client';
export const authenticatedMiddleware = <
@ -13,7 +15,12 @@ export const authenticatedMiddleware = <
body: unknown;
},
>(
handler: (args: T, user: User, team?: Team | null) => Promise<R>,
handler: (
args: T,
user: User,
team: Team | null | undefined,
options: { metadata: ApiRequestMetadata },
) => Promise<R>,
) => {
return async (args: T) => {
try {
@ -36,7 +43,18 @@ export const authenticatedMiddleware = <
});
}
return await handler(args, apiToken.user, apiToken.team);
const metadata: ApiRequestMetadata = {
requestMetadata: extractNextApiRequestMetadata(args.req),
source: 'apiV1',
auth: 'api',
auditUser: {
id: apiToken.team ? null : apiToken.user.id,
email: apiToken.team ? null : apiToken.user.email,
name: apiToken.team?.name ?? apiToken.user.name,
},
};
return await handler(args, apiToken.user, apiToken.team, { metadata });
} catch (err) {
console.log({ err: err });

View File

@ -61,6 +61,7 @@ export const ZSuccessfulGetDocumentResponseSchema = ZSuccessfulDocumentResponseS
fields: z.lazy(() =>
ZFieldSchema.pick({
id: true,
documentId: true,
recipientId: true,
type: true,
page: true,
@ -68,6 +69,8 @@ export const ZSuccessfulGetDocumentResponseSchema = ZSuccessfulDocumentResponseS
positionY: true,
width: true,
height: true,
customText: true,
fieldMeta: true,
})
.extend({
fieldMeta: ZFieldMetaSchema.nullish(),
@ -524,6 +527,7 @@ export const ZFieldSchema = z.object({
height: z.unknown(),
customText: z.string(),
inserted: z.boolean(),
fieldMeta: ZFieldMetaSchema.nullish().openapi({}),
});
export const ZTemplateWithDataSchema = ZTemplateSchema.extend({
@ -541,6 +545,8 @@ export const ZTemplateWithDataSchema = ZTemplateSchema.extend({
}),
Field: ZFieldSchema.pick({
id: true,
documentId: true,
templateId: true,
recipientId: true,
type: true,
page: true,
@ -548,6 +554,8 @@ export const ZTemplateWithDataSchema = ZTemplateSchema.extend({
positionY: true,
width: true,
height: true,
customText: true,
fieldMeta: true,
}).array(),
Recipient: ZRecipientSchema.pick({
id: true,

View File

@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
@ -157,3 +159,109 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
await expect(page.getByLabel('Title')).toHaveValue('New Title');
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
});
test('[TEMPLATE_FLOW] add document visibility settings', async ({ page }) => {
const { owner, ...team } = await seedTeam({
createTeamMembers: 1,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set document visibility.
await page.getByTestId('documentVisibilitySelectValue').click();
await page.getByLabel('Managers and above').click();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Navigate back to the edit page to check that the settings are saved correctly.
await page.goto(`/t/${team.url}/templates/${template.id}/edit`);
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
});
test('[TEMPLATE_FLOW] team member visibility permissions', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 2, // Create an additional member to test different roles
});
await prisma.teamMember.update({
where: {
id: team.members[1].id,
},
data: {
role: TeamMemberRole.MANAGER,
},
});
const owner = team.owner;
const managerUser = team.members[1].user;
const memberUser = team.members[2].user;
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
// Test as manager
await apiSignin({
page,
email: managerUser.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Manager should be able to set visibility to managers and above
await page.getByTestId('documentVisibilitySelectValue').click();
await page.getByLabel('Managers and above').click();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
await expect(page.getByText('Admins only')).toBeDisabled();
// Save and verify
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Test as regular member
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Regular member should not be able to modify visibility when set to managers and above
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
// Create a new template with 'everyone' visibility
const everyoneTemplate = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
visibility: 'EVERYONE',
},
});
// Navigate to the new template
await page.goto(`/t/${team.url}/templates/${everyoneTemplate.id}/edit`);
// Regular member should be able to see but not modify visibility
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Everyone');
});

View File

@ -5,7 +5,7 @@ import path from 'path';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { DocumentDataType } from '@documenso/prisma/client';
import { DocumentDataType, TeamMemberRole } from '@documenso/prisma/client';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
@ -529,3 +529,90 @@ test('[TEMPLATE]: should create a document from a template using template docume
);
expect(document.documentData.type).toEqual(templateWithData.templateDocumentData.type);
});
test('[TEMPLATE]: should persist document visibility when creating from template', async ({
page,
}) => {
const { owner, ...team } = await seedTeam({
createTeamMembers: 2,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set template title and visibility
await page.getByLabel('Title').fill('TEMPLATE_WITH_VISIBILITY');
await page.getByTestId('documentVisibilitySelectValue').click();
await page.getByLabel('Managers and above').click();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Test creating document as team manager
await prisma.teamMember.update({
where: {
id: team.members[1].id,
},
data: {
role: TeamMemberRole.MANAGER,
},
});
const managerUser = team.members[1].user;
await apiSignin({
page,
email: managerUser.email,
redirectPath: `/t/${team.url}/templates`,
});
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct visibility
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
});
expect(document.title).toEqual('TEMPLATE_WITH_VISIBILITY');
expect(document.visibility).toEqual('MANAGER_AND_ABOVE');
expect(document.teamId).toEqual(team.id);
// Test that regular member cannot create document from restricted template
const memberUser = team.members[2].user;
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/templates`,
});
// Template should not be visible to regular member
await expect(page.getByRole('button', { name: 'Use Template' })).not.toBeVisible();
});

View File

@ -67,6 +67,8 @@ test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) =>
await page.getByRole('button', { name: 'Enable direct link signing' }).click();
await page.getByRole('button', { name: 'Create one automatically' }).click();
await expect(page.getByRole('heading', { name: 'Direct Link Signing' })).toBeVisible();
await page.waitForTimeout(1000);
await page.getByTestId('btn-dialog-close').click();
// Expect badge to appear.

View File

@ -13,7 +13,7 @@
"author": "",
"devDependencies": {
"@playwright/test": "^1.18.1",
"@types/node": "^20.8.2",
"@types/node": "^20",
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@documenso/web": "*",

View File

@ -1,4 +1,4 @@
This file list all features currently licensed under the Documenso Enterprise Edition (the "Commercial License”)
This file lists all features currently licensed under the Documenso Enterprise Edition (the "Commercial License”)
Copyright (c) 2023 Documenso, Inc
- The Stripe Billing Module

View File

@ -15,9 +15,9 @@
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"luxon": "^3.4.0",
"luxon": "^3.5.0",
"micro": "^10.0.1",
"next": "14.2.6",
"next": "14.2.23",
"next-auth": "4.24.5",
"react": "^18",
"ts-pattern": "^5.0.5",

View File

@ -42,10 +42,10 @@
"@vvo/tzdb": "^6.117.0",
"inngest": "^3.19.13",
"kysely": "^0.26.3",
"luxon": "^3.4.0",
"luxon": "^3.5.0",
"micro": "^10.0.1",
"nanoid": "^4.0.2",
"next": "14.2.6",
"next": "14.2.23",
"next-auth": "4.24.5",
"oslo": "^0.17.0",
"pdf-lib": "^1.17.1",

View File

@ -1,7 +1,7 @@
'use server';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffDocumentMetaChanges,
@ -13,6 +13,8 @@ import type { SupportedLanguageCodes } from '../../constants/i18n';
import type { TDocumentEmailSettings } from '../../types/document-email';
export type CreateDocumentMetaOptions = {
userId: number;
teamId?: number;
documentId: number;
subject?: string;
message?: string;
@ -25,18 +27,18 @@ export type CreateDocumentMetaOptions = {
distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean;
language?: SupportedLanguageCodes;
userId: number;
requestMetadata: RequestMetadata;
requestMetadata: ApiRequestMetadata;
};
export const upsertDocumentMeta = async ({
userId,
teamId,
subject,
message,
timezone,
dateFormat,
documentId,
password,
userId,
redirectUrl,
signingOrder,
emailSettings,
@ -45,34 +47,24 @@ export const upsertDocumentMeta = async ({
language,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});
const { documentMeta: originalDocumentMeta } = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
{
userId: user.id,
},
{
team: {
members: {
some: {
userId: user.id,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
include: {
documentMeta: true,
@ -120,8 +112,7 @@ export const upsertDocumentMeta = async ({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
documentId,
user,
requestMetadata,
metadata: requestMetadata,
data: {
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
},

View File

@ -5,7 +5,7 @@ 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 type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentSource, DocumentVisibility, WebhookTriggerEvents } from '@documenso/prisma/client';
@ -27,7 +27,7 @@ export type CreateDocumentOptions = {
formValues?: Record<string, string | number | boolean>;
normalizePdf?: boolean;
timezone?: string;
requestMetadata?: RequestMetadata;
requestMetadata: ApiRequestMetadata;
};
export const ZCreateDocumentResponseSchema = DocumentSchema;
@ -162,8 +162,7 @@ export const createDocument = async ({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
user,
requestMetadata,
metadata: requestMetadata,
data: {
title,
source: {

View File

@ -20,9 +20,10 @@ import { DocumentStatus, 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 { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
@ -31,7 +32,7 @@ export type DeleteDocumentOptions = {
id: number;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
requestMetadata: ApiRequestMetadata;
};
export const deleteDocument = async ({
@ -47,7 +48,9 @@ export const deleteDocument = async ({
});
if (!user) {
throw new Error('User not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const document = await prisma.document.findUnique({
@ -67,7 +70,9 @@ export const deleteDocument = async ({
});
if (!document || (teamId !== undefined && teamId !== document.teamId)) {
throw new Error('Document not found');
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isUserOwner = document.userId === userId;
@ -75,7 +80,9 @@ export const deleteDocument = async ({
const userRecipient = document.Recipient.find((recipient) => recipient.email === user.email);
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
throw new Error('Not allowed');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Not allowed',
});
}
// Handle hard or soft deleting the actual document if user has permission.
@ -130,7 +137,7 @@ type HandleDocumentOwnerDeleteOptions = {
})
| null;
user: User;
requestMetadata?: RequestMetadata;
requestMetadata: ApiRequestMetadata;
};
const handleDocumentOwnerDelete = async ({
@ -150,8 +157,7 @@ const handleDocumentOwnerDelete = async ({
data: createDocumentAuditLogData({
documentId: document.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
metadata: requestMetadata,
data: {
type: 'SOFT',
},
@ -177,8 +183,7 @@ const handleDocumentOwnerDelete = async ({
data: createDocumentAuditLogData({
documentId: document.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
metadata: requestMetadata,
data: {
type: 'HARD',
},

View File

@ -8,6 +8,7 @@ import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
export interface FindDocumentAuditLogsOptions {
userId: number;
teamId?: number;
documentId: number;
page?: number;
perPage?: number;
@ -21,6 +22,7 @@ export interface FindDocumentAuditLogsOptions {
export const findDocumentAuditLogs = async ({
userId,
teamId,
documentId,
page = 1,
perPage = 30,
@ -34,20 +36,21 @@ export const findDocumentAuditLogs = async ({
const document = await prisma.document.findFirst({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
});

View File

@ -1,10 +1,10 @@
import { TRPCError } from '@trpc/server';
import type { z } from 'zod';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { DocumentSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
@ -12,7 +12,7 @@ export type MoveDocumentToTeamOptions = {
documentId: number;
teamId: number;
userId: number;
requestMetadata?: RequestMetadata;
requestMetadata: ApiRequestMetadata;
};
export const ZMoveDocumentToTeamResponseSchema = DocumentSchema;
@ -26,10 +26,6 @@ export const moveDocumentToTeam = async ({
requestMetadata,
}: MoveDocumentToTeamOptions): Promise<TMoveDocumentToTeamResponse> => {
return await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({
where: { id: userId },
});
const document = await tx.document.findFirst({
where: {
id: documentId,
@ -39,8 +35,7 @@ export const moveDocumentToTeam = async ({
});
if (!document) {
throw new TRPCError({
code: 'NOT_FOUND',
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found or already associated with a team.',
});
}
@ -57,9 +52,8 @@ export const moveDocumentToTeam = async ({
});
if (!team) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not a member of this team.',
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'This team does not exist, or you are not a member of this team.',
});
}
@ -68,12 +62,11 @@ export const moveDocumentToTeam = async ({
data: { teamId },
});
const log = await tx.documentAuditLog.create({
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM,
documentId: updatedDocument.id,
user,
requestMetadata,
metadata: requestMetadata,
data: {
movedByUserId: userId,
fromPersonalAccount: true,

View File

@ -10,7 +10,7 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '@documenso/lib/constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
@ -29,7 +29,7 @@ export type ResendDocumentOptions = {
userId: number;
recipients: number[];
teamId?: number;
requestMetadata: RequestMetadata;
requestMetadata: ApiRequestMetadata;
};
export const resendDocument = async ({
@ -201,8 +201,7 @@ export const resendDocument = async ({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
metadata: requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,

View File

@ -1,7 +1,7 @@
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 type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
@ -31,7 +31,7 @@ export type SendDocumentOptions = {
userId: number;
teamId?: number;
sendEmail?: boolean;
requestMetadata?: RequestMetadata;
requestMetadata: ApiRequestMetadata;
};
export const ZSendDocumentResponseSchema = DocumentSchema.extend({
@ -48,17 +48,6 @@ export const sendDocument = async ({
sendEmail,
requestMetadata,
}: SendDocumentOptions): Promise<TSendDocumentResponse> => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
const document = await prisma.document.findUnique({
where: {
id: documentId,
@ -198,7 +187,7 @@ export const sendDocument = async ({
userId,
documentId,
recipientId: recipient.id,
requestMetadata,
requestMetadata: requestMetadata?.requestMetadata,
},
});
}),
@ -215,7 +204,7 @@ export const sendDocument = async ({
name: 'internal.seal-document',
payload: {
documentId,
requestMetadata,
requestMetadata: requestMetadata?.requestMetadata,
},
});
@ -237,8 +226,7 @@ export const sendDocument = async ({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
documentId: document.id,
requestMetadata,
user,
metadata: requestMetadata,
data: {},
}),
});

View File

@ -1,281 +0,0 @@
'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';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
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';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
export type UpdateDocumentSettingsOptions = {
userId: number;
teamId?: number;
documentId: number;
data: {
title?: string;
externalId?: string | null;
visibility?: DocumentVisibility | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
};
requestMetadata?: RequestMetadata;
};
export const ZUpdateDocumentSettingsResponseSchema = DocumentSchema;
export type TUpdateDocumentSettingsResponse = z.infer<typeof ZUpdateDocumentSettingsResponseSchema>;
export const updateDocumentSettings = async ({
userId,
teamId,
documentId,
data,
requestMetadata,
}: UpdateDocumentSettingsOptions): Promise<TUpdateDocumentSettingsResponse> => {
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Missing data to update',
});
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
team: {
select: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
},
},
});
if (teamId) {
const currentUserRole = document.team?.members[0]?.role;
const isDocumentOwner = document.userId === userId;
const requestedVisibility = data.visibility;
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) ||
(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',
});
});
}
}
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
// If the new global auth values aren't passed in, fallback to the current document values.
const newGlobalAccessAuth =
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
const newGlobalActionAuth =
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const isTitleSame = data.title === undefined || data.title === document.title;
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
const isGlobalAccessSame =
documentGlobalAccessAuth === undefined || documentGlobalAccessAuth === newGlobalAccessAuth;
const isGlobalActionSame =
documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth;
const isDocumentVisibilitySame =
data.visibility === undefined || data.visibility === document.visibility;
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
if (!isTitleSame && document.status !== DocumentStatus.DRAFT) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'You cannot update the title if the document has been sent',
});
}
if (!isTitleSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.title,
to: data.title || '',
},
}),
);
}
if (!isExternalIdSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.externalId,
to: data.externalId || '',
},
}),
);
}
if (!isGlobalAccessSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: documentGlobalAccessAuth,
to: newGlobalAccessAuth,
},
}),
);
}
if (!isGlobalActionSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: documentGlobalActionAuth,
to: newGlobalActionAuth,
},
}),
);
}
if (!isDocumentVisibilitySame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.visibility,
to: data.visibility || '',
},
}),
);
}
// Early return if nothing is required.
if (auditLogs.length === 0) {
return document;
}
return await prisma.$transaction(async (tx) => {
const authOptions = createDocumentAuthOptions({
globalAccessAuth: newGlobalAccessAuth,
globalActionAuth: newGlobalActionAuth,
});
const updatedDocument = await tx.document.update({
where: {
id: documentId,
},
data: {
title: data.title,
externalId: data.externalId,
visibility: data.visibility as DocumentVisibility,
authOptions,
},
});
await tx.documentAuditLog.createMany({
data: auditLogs,
});
return updatedDocument;
});
};

View File

@ -1,23 +1,46 @@
'use server';
import type { Prisma } from '@prisma/client';
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';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
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';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
export type UpdateDocumentOptions = {
documentId: number;
data: Prisma.DocumentUpdateInput;
userId: number;
teamId?: number;
documentId: number;
data?: {
title?: string;
externalId?: string | null;
visibility?: DocumentVisibility | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
};
requestMetadata: ApiRequestMetadata;
};
export const ZUpdateDocumentResponseSchema = DocumentSchema;
export type TUpdateDocumentResponse = z.infer<typeof ZUpdateDocumentResponseSchema>;
export const updateDocument = async ({
documentId,
userId,
teamId,
documentId,
data,
}: UpdateDocumentOptions) => {
return await prisma.document.update({
requestMetadata,
}: UpdateDocumentOptions): Promise<TUpdateDocumentResponse> => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
@ -36,8 +59,215 @@ export const updateDocument = async ({
teamId: null,
}),
},
data: {
...data,
include: {
team: {
select: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
},
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (teamId) {
const currentUserRole = document.team?.members[0]?.role;
const isDocumentOwner = document.userId === userId;
const requestedVisibility = data?.visibility;
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) ||
(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',
});
});
}
}
// If no data just return the document since this function is normally chained after a meta update.
if (!data || Object.values(data).length === 0) {
return document;
}
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
// If the new global auth values aren't passed in, fallback to the current document values.
const newGlobalAccessAuth =
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
const newGlobalActionAuth =
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const isTitleSame = data.title === undefined || data.title === document.title;
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
const isGlobalAccessSame =
documentGlobalAccessAuth === undefined || documentGlobalAccessAuth === newGlobalAccessAuth;
const isGlobalActionSame =
documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth;
const isDocumentVisibilitySame =
data.visibility === undefined || data.visibility === document.visibility;
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
if (!isTitleSame && document.status !== DocumentStatus.DRAFT) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'You cannot update the title if the document has been sent',
});
}
if (!isTitleSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
documentId,
metadata: requestMetadata,
data: {
from: document.title,
to: data.title || '',
},
}),
);
}
if (!isExternalIdSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED,
documentId,
metadata: requestMetadata,
data: {
from: document.externalId,
to: data.externalId || '',
},
}),
);
}
if (!isGlobalAccessSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED,
documentId,
metadata: requestMetadata,
data: {
from: documentGlobalAccessAuth,
to: newGlobalAccessAuth,
},
}),
);
}
if (!isGlobalActionSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED,
documentId,
metadata: requestMetadata,
data: {
from: documentGlobalActionAuth,
to: newGlobalActionAuth,
},
}),
);
}
if (!isDocumentVisibilitySame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED,
documentId,
metadata: requestMetadata,
data: {
from: document.visibility,
to: data.visibility || '',
},
}),
);
}
// Early return if nothing is required.
if (auditLogs.length === 0) {
return document;
}
return await prisma.$transaction(async (tx) => {
const authOptions = createDocumentAuthOptions({
globalAccessAuth: newGlobalAccessAuth,
globalActionAuth: newGlobalActionAuth,
});
const updatedDocument = await tx.document.update({
where: {
id: documentId,
},
data: {
title: data.title,
externalId: data.externalId,
visibility: data.visibility as DocumentVisibility,
authOptions,
},
});
await tx.documentAuditLog.createMany({
data: auditLogs,
});
return updatedDocument;
});
};

View File

@ -0,0 +1,148 @@
import { z } from 'zod';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client';
import { FieldSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
export interface CreateDocumentFieldsOptions {
userId: number;
teamId?: number;
documentId: number;
fields: {
recipientId: number;
type: FieldType;
pageNumber: number;
pageX: number;
pageY: number;
width: number;
height: number;
fieldMeta?: TFieldMetaSchema;
}[];
requestMetadata: ApiRequestMetadata;
}
export const ZCreateDocumentFieldsResponseSchema = z.object({
fields: z.array(FieldSchema),
});
export type TCreateDocumentFieldsResponse = z.infer<typeof ZCreateDocumentFieldsResponseSchema>;
export const createDocumentFields = async ({
userId,
teamId,
documentId,
fields,
requestMetadata,
}: CreateDocumentFieldsOptions): Promise<TCreateDocumentFieldsResponse> => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
Field: true,
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (document.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
});
}
// Field validation.
const validatedFields = fields.map((field) => {
const recipient = document.Recipient.find((recipient) => recipient.id === field.recipientId);
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient ${field.recipientId} not found`,
});
}
// Check whether the recipient associated with the field can have new fields created.
if (!canRecipientFieldsBeModified(recipient, document.Field)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Recipient type cannot have fields, or they have already interacted with the document.',
});
}
return {
...field,
recipientEmail: recipient.email,
};
});
const createdFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
validatedFields.map(async (field) => {
const createdField = await tx.field.create({
data: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
documentId,
recipientId: field.recipientId,
},
});
// Handle field created audit log.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
documentId,
metadata: requestMetadata,
data: {
fieldId: createdField.secondaryId,
fieldRecipientEmail: field.recipientEmail,
fieldRecipientId: createdField.recipientId,
fieldType: createdField.type,
},
}),
});
return createdField;
}),
);
});
return {
fields: createdFields,
};
};

View File

@ -0,0 +1,122 @@
import { z } from 'zod';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client';
import { FieldSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
export interface CreateTemplateFieldsOptions {
userId: number;
teamId?: number;
templateId: number;
fields: {
recipientId: number;
type: FieldType;
pageNumber: number;
pageX: number;
pageY: number;
width: number;
height: number;
fieldMeta?: TFieldMetaSchema;
}[];
}
export const ZCreateTemplateFieldsResponseSchema = z.object({
fields: z.array(FieldSchema),
});
export type TCreateTemplateFieldsResponse = z.infer<typeof ZCreateTemplateFieldsResponseSchema>;
export const createTemplateFields = async ({
userId,
teamId,
templateId,
fields,
}: CreateTemplateFieldsOptions): Promise<TCreateTemplateFieldsResponse> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
Field: true,
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'template not found',
});
}
// Field validation.
const validatedFields = fields.map((field) => {
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient ${field.recipientId} not found`,
});
}
// Check whether the recipient associated with the field can have new fields created.
if (!canRecipientFieldsBeModified(recipient, template.Field)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Recipient type cannot have fields, or they have already interacted with the template.',
});
}
return {
...field,
recipientEmail: recipient.email,
};
});
const createdFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
validatedFields.map(async (field) => {
const createdField = await tx.field.create({
data: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
templateId,
recipientId: field.recipientId,
},
});
return createdField;
}),
);
});
return {
fields: createdFields,
};
};

View File

@ -0,0 +1,122 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
export interface DeleteDocumentFieldOptions {
userId: number;
teamId?: number;
fieldId: number;
requestMetadata: ApiRequestMetadata;
}
export const deleteDocumentField = async ({
userId,
teamId,
fieldId,
requestMetadata,
}: DeleteDocumentFieldOptions): Promise<void> => {
const field = await prisma.field.findFirst({
where: {
id: fieldId,
},
});
if (!field) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Field not found',
});
}
const documentId = field.documentId;
if (!documentId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Field does not belong to a document. Use delete template field instead.',
});
}
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: {
where: {
id: field.recipientId,
},
include: {
Field: true,
},
},
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (document.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
});
}
const recipient = document.Recipient.find((recipient) => recipient.id === field.recipientId);
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient for field ${fieldId} not found`,
});
}
// Check whether the recipient associated with the field can have new fields created.
if (!canRecipientFieldsBeModified(recipient, recipient.Field)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Recipient has already interacted with the document.',
});
}
await prisma.$transaction(async (tx) => {
const deletedField = await tx.field.delete({
where: {
id: fieldId,
},
});
// Handle field deleted audit log.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED,
documentId,
metadata: requestMetadata,
data: {
fieldId: deletedField.secondaryId,
fieldRecipientEmail: recipient.email,
fieldRecipientId: deletedField.recipientId,
fieldType: deletedField.type,
},
}),
});
});
};

View File

@ -0,0 +1,48 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface DeleteTemplateFieldOptions {
userId: number;
teamId?: number;
fieldId: number;
}
export const deleteTemplateField = async ({
userId,
teamId,
fieldId,
}: DeleteTemplateFieldOptions): Promise<void> => {
const field = await prisma.field.findFirst({
where: {
id: fieldId,
Template: teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
},
},
});
if (!field || !field.templateId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Field not found',
});
}
await prisma.field.delete({
where: {
id: fieldId,
},
});
};

View File

@ -3,30 +3,34 @@ import { prisma } from '@documenso/prisma';
export interface GetFieldsForDocumentOptions {
documentId: number;
userId: number;
teamId?: number;
}
export type DocumentField = Awaited<ReturnType<typeof getFieldsForDocument>>[number];
export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForDocumentOptions) => {
export const getFieldsForDocument = async ({
documentId,
userId,
teamId,
}: GetFieldsForDocumentOptions) => {
const fields = await prisma.field.findMany({
where: {
documentId,
Document: {
OR: [
{
userId,
},
{
Document: teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
},
],
},
},
include: {
Signature: true,

View File

@ -1,35 +0,0 @@
import { prisma } from '@documenso/prisma';
export interface GetFieldsForTemplateOptions {
templateId: number;
userId: number;
}
export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForTemplateOptions) => {
const fields = await prisma.field.findMany({
where: {
templateId,
Template: {
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
orderBy: {
id: 'asc',
},
});
return fields;
};

View File

@ -16,7 +16,7 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffFieldChanges,
@ -31,9 +31,10 @@ import { canRecipientFieldsBeModified } from '../../utils/recipients';
export interface SetFieldsForDocumentOptions {
userId: number;
teamId?: number;
documentId: number;
fields: FieldData[];
requestMetadata?: RequestMetadata;
requestMetadata: ApiRequestMetadata;
}
export const ZSetFieldsForDocumentResponseSchema = z.object({
@ -44,6 +45,7 @@ export type TSetFieldsForDocumentResponse = z.infer<typeof ZSetFieldsForDocument
export const setFieldsForDocument = async ({
userId,
teamId,
documentId,
fields,
requestMetadata,
@ -51,37 +53,27 @@ export const setFieldsForDocument = async ({
const document = await prisma.document.findFirst({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
@ -280,8 +272,7 @@ export const setFieldsForDocument = async ({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
documentId: documentId,
user,
requestMetadata,
metadata: requestMetadata,
data: {
changes,
...baseAuditLog,
@ -296,8 +287,7 @@ export const setFieldsForDocument = async ({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
documentId: documentId,
user,
requestMetadata,
metadata: requestMetadata,
data: {
...baseAuditLog,
},
@ -325,8 +315,7 @@ export const setFieldsForDocument = async ({
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED,
documentId: documentId,
user,
requestMetadata,
metadata: requestMetadata,
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',

View File

@ -20,6 +20,7 @@ import { FieldSchema } from '@documenso/prisma/generated/zod';
export type SetFieldsForTemplateOptions = {
userId: number;
teamId?: number;
templateId: number;
fields: {
id?: number | null;
@ -42,26 +43,28 @@ export type TSetFieldsForTemplateResponse = z.infer<typeof ZSetFieldsForTemplate
export const setFieldsForTemplate = async ({
userId,
teamId,
templateId,
fields,
}: SetFieldsForTemplateOptions): Promise<TSetFieldsForTemplateResponse> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
});

View File

@ -0,0 +1,165 @@
import { z } from 'zod';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffFieldChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client';
import { FieldSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
export interface UpdateDocumentFieldsOptions {
userId: number;
teamId?: number;
documentId: number;
fields: {
id: number;
type?: FieldType;
pageNumber?: number;
pageX?: number;
pageY?: number;
width?: number;
height?: number;
fieldMeta?: TFieldMetaSchema;
}[];
requestMetadata: ApiRequestMetadata;
}
export const ZUpdateDocumentFieldsResponseSchema = z.object({
fields: z.array(FieldSchema),
});
export type TUpdateDocumentFieldsResponse = z.infer<typeof ZUpdateDocumentFieldsResponseSchema>;
export const updateDocumentFields = async ({
userId,
teamId,
documentId,
fields,
requestMetadata,
}: UpdateDocumentFieldsOptions): Promise<TUpdateDocumentFieldsResponse> => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
Field: true,
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (document.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
});
}
const fieldsToUpdate = fields.map((field) => {
const originalField = document.Field.find((existingField) => existingField.id === field.id);
if (!originalField) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Field with id ${field.id} not found`,
});
}
const recipient = document.Recipient.find(
(recipient) => recipient.id === originalField.recipientId,
);
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient attached to field ${field.id} not found`,
});
}
// Check whether the recipient associated with the field can be modified.
if (!canRecipientFieldsBeModified(recipient, document.Field)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Cannot modify a field where the recipient has already interacted with the document',
});
}
return {
originalField,
updateData: field,
recipientEmail: recipient.email,
};
});
const updatedFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
fieldsToUpdate.map(async ({ originalField, updateData, recipientEmail }) => {
const updatedField = await tx.field.update({
where: {
id: updateData.id,
},
data: {
type: updateData.type,
page: updateData.pageNumber,
positionX: updateData.pageX,
positionY: updateData.pageY,
width: updateData.width,
height: updateData.height,
fieldMeta: updateData.fieldMeta,
},
});
const changes = diffFieldChanges(originalField, updatedField);
// Handle field updated audit log.
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
documentId: documentId,
metadata: requestMetadata,
data: {
fieldId: updatedField.secondaryId,
fieldRecipientEmail: recipientEmail,
fieldRecipientId: updatedField.recipientId,
fieldType: updatedField.type,
changes,
},
}),
});
}
return updatedField;
}),
);
});
return {
fields: updatedFields,
};
};

View File

@ -0,0 +1,129 @@
import { z } from 'zod';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client';
import { FieldSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
export interface UpdateTemplateFieldsOptions {
userId: number;
teamId?: number;
templateId: number;
fields: {
id: number;
type?: FieldType;
pageNumber?: number;
pageX?: number;
pageY?: number;
width?: number;
height?: number;
fieldMeta?: TFieldMetaSchema;
}[];
}
export const ZUpdateTemplateFieldsResponseSchema = z.object({
fields: z.array(FieldSchema),
});
export type TUpdateTemplateFieldsResponse = z.infer<typeof ZUpdateTemplateFieldsResponseSchema>;
export const updateTemplateFields = async ({
userId,
teamId,
templateId,
fields,
}: UpdateTemplateFieldsOptions): Promise<TUpdateTemplateFieldsResponse> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
Field: true,
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const fieldsToUpdate = fields.map((field) => {
const originalField = template.Field.find((existingField) => existingField.id === field.id);
if (!originalField) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Field with id ${field.id} not found`,
});
}
const recipient = template.Recipient.find(
(recipient) => recipient.id === originalField.recipientId,
);
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient attached to field ${field.id} not found`,
});
}
// Check whether the recipient associated with the field can be modified.
if (!canRecipientFieldsBeModified(recipient, template.Field)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Cannot modify a field where the recipient has already interacted with the document',
});
}
return {
updateData: field,
};
});
const updatedFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
fieldsToUpdate.map(async ({ updateData }) => {
const updatedField = await tx.field.update({
where: {
id: updateData.id,
},
data: {
type: updateData.type,
page: updateData.pageNumber,
positionX: updateData.pageX,
positionY: updateData.pageY,
width: updateData.width,
height: updateData.height,
fieldMeta: updateData.fieldMeta,
},
});
return updatedField;
}),
);
});
return {
fields: updatedFields,
};
};

View File

@ -3,13 +3,13 @@ import sharp from 'sharp';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
export type SetAvatarImageOptions = {
userId: number;
teamId?: number | null;
bytes?: string | null;
requestMetadata?: RequestMetadata;
requestMetadata: ApiRequestMetadata;
};
export const setAvatarImage = async ({

View File

@ -0,0 +1,167 @@
import { 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';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { ZRecipientBaseResponseSchema } from '@documenso/trpc/server/recipient-router/schema';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface CreateDocumentRecipientsOptions {
userId: number;
teamId?: number;
documentId: number;
recipients: {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
}[];
requestMetadata: ApiRequestMetadata;
}
export const ZCreateDocumentRecipientsResponseSchema = z.object({
recipients: ZRecipientBaseResponseSchema.array(),
});
export type TCreateDocumentRecipientsResponse = z.infer<
typeof ZCreateDocumentRecipientsResponseSchema
>;
export const createDocumentRecipients = async ({
userId,
teamId,
documentId,
recipients: recipientsToCreate,
requestMetadata,
}: CreateDocumentRecipientsOptions): Promise<TCreateDocumentRecipientsResponse> => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (document.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
});
}
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
}));
const duplicateRecipients = normalizedRecipients.filter((newRecipient) => {
const existingRecipient = document.Recipient.find(
(existingRecipient) => existingRecipient.email === newRecipient.email,
);
return existingRecipient !== undefined;
});
if (duplicateRecipients.length > 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`,
});
}
const createdRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null,
actionAuth: recipient.actionAuth || null,
});
const createdRecipient = await tx.recipient.create({
data: {
documentId,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions,
},
});
// Handle recipient created audit log.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
documentId: documentId,
metadata: requestMetadata,
data: {
recipientEmail: createdRecipient.email,
recipientName: createdRecipient.name,
recipientId: createdRecipient.id,
recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth || undefined,
actionAuth: recipient.actionAuth || undefined,
},
}),
});
return createdRecipient;
}),
);
});
return {
recipients: createdRecipients,
};
};

View File

@ -0,0 +1,139 @@
import { z } from 'zod';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { ZRecipientBaseResponseSchema } from '@documenso/trpc/server/recipient-router/schema';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface CreateTemplateRecipientsOptions {
userId: number;
teamId?: number;
templateId: number;
recipients: {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
}[];
}
export const ZCreateTemplateRecipientsResponseSchema = z.object({
recipients: ZRecipientBaseResponseSchema.array(),
});
export type TCreateTemplateRecipientsResponse = z.infer<
typeof ZCreateTemplateRecipientsResponseSchema
>;
export const createTemplateRecipients = async ({
userId,
teamId,
templateId,
recipients: recipientsToCreate,
}: CreateTemplateRecipientsOptions): Promise<TCreateTemplateRecipientsResponse> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
}));
const duplicateRecipients = normalizedRecipients.filter((newRecipient) => {
const existingRecipient = template.Recipient.find(
(existingRecipient) => existingRecipient.email === newRecipient.email,
);
return existingRecipient !== undefined;
});
if (duplicateRecipients.length > 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`,
});
}
const createdRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null,
actionAuth: recipient.actionAuth || null,
});
const createdRecipient = await tx.recipient.create({
data: {
templateId,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions,
},
});
return createdRecipient;
}),
);
});
return {
recipients: createdRecipients,
};
};

View File

@ -0,0 +1,161 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { 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 { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface DeleteDocumentRecipientOptions {
userId: number;
teamId?: number;
recipientId: number;
requestMetadata: ApiRequestMetadata;
}
export const deleteDocumentRecipient = async ({
userId,
teamId,
recipientId,
requestMetadata,
}: DeleteDocumentRecipientOptions): Promise<void> => {
const document = await prisma.document.findFirst({
where: {
Recipient: {
some: {
id: recipientId,
},
},
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
documentMeta: true,
team: true,
Recipient: {
where: {
id: recipientId,
},
},
},
});
const user = await prisma.user.findFirst({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (document.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
});
}
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const recipientToDelete = document.Recipient[0];
if (!recipientToDelete || recipientToDelete.id !== recipientId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
await prisma.$transaction(async (tx) => {
await tx.recipient.delete({
where: {
id: recipientId,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
documentId: document.id,
metadata: requestMetadata,
data: {
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
recipientId: recipientToDelete.id,
recipientRole: recipientToDelete.role,
},
}),
});
});
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientRemoved;
// Send email to deleted recipient.
if (recipientToDelete.sendStatus === SendStatus.SENT && isRecipientRemovedEmailEnabled) {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
documentName: document.title,
inviterName: document.team?.name || user.name || undefined,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
await mailer.sendMail({
to: {
address: recipientToDelete.email,
name: recipientToDelete.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
}
};

View File

@ -0,0 +1,67 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface DeleteTemplateRecipientOptions {
userId: number;
teamId?: number;
recipientId: number;
}
export const deleteTemplateRecipient = async ({
userId,
teamId,
recipientId,
}: DeleteTemplateRecipientOptions): Promise<void> => {
const template = await prisma.template.findFirst({
where: {
Recipient: {
some: {
id: recipientId,
},
},
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: {
where: {
id: recipientId,
},
},
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const recipientToDelete = template.Recipient[0];
if (!recipientToDelete || recipientToDelete.id !== recipientId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
await prisma.recipient.delete({
where: {
id: recipientId,
},
});
};

View File

@ -29,25 +29,21 @@ export const getRecipientById = async ({
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
Document: {
OR: [
teamId === undefined
? {
userId,
teamId: null,
}
: {
teamId,
team: {
members: {
some: {
userId,
},
},
Document: teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
],
},
},
}
: {
userId,
teamId: null,
},
},
include: {
Field: true,

View File

@ -14,23 +14,21 @@ export const getRecipientsForDocument = async ({
const recipients = await prisma.recipient.findMany({
where: {
documentId,
Document: {
OR: [
{
userId,
},
{
teamId,
Document: teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
},
],
},
},
orderBy: {
id: 'asc',

View File

@ -3,31 +3,32 @@ import { prisma } from '@documenso/prisma';
export interface GetRecipientsForTemplateOptions {
templateId: number;
userId: number;
teamId?: number;
}
export const getRecipientsForTemplate = async ({
templateId,
userId,
teamId,
}: GetRecipientsForTemplateOptions) => {
const recipients = await prisma.recipient.findMany({
where: {
templateId,
Template: {
OR: [
{
userId,
},
{
Template: teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
},
],
},
},
orderBy: {
id: 'asc',

View File

@ -7,11 +7,12 @@ import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-ent
import { mailer } from '@documenso/email/mailer';
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid } from '@documenso/lib/universal/id';
import {
createDocumentAuditLogData,
@ -33,29 +34,27 @@ import { canRecipientBeModified } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export interface SetRecipientsForDocumentOptions {
export interface SetDocumentRecipientsOptions {
userId: number;
teamId?: number;
documentId: number;
recipients: RecipientData[];
requestMetadata?: RequestMetadata;
requestMetadata: ApiRequestMetadata;
}
export const ZSetRecipientsForDocumentResponseSchema = z.object({
export const ZSetDocumentRecipientsResponseSchema = z.object({
recipients: RecipientSchema.array(),
});
export type TSetRecipientsForDocumentResponse = z.infer<
typeof ZSetRecipientsForDocumentResponseSchema
>;
export type TSetDocumentRecipientsResponse = z.infer<typeof ZSetDocumentRecipientsResponseSchema>;
export const setRecipientsForDocument = async ({
export const setDocumentRecipients = async ({
userId,
teamId,
documentId,
recipients,
requestMetadata,
}: SetRecipientsForDocumentOptions): Promise<TSetRecipientsForDocumentResponse> => {
}: SetDocumentRecipientsOptions): Promise<TSetDocumentRecipientsResponse> => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
@ -167,10 +166,10 @@ export const setRecipientsForDocument = async ({
linkedRecipients.map(async (recipient) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
if (recipient.actionAuth !== undefined) {
if (recipient.actionAuth !== undefined || recipient.accessAuth !== undefined) {
authOptions = createRecipientAuthOptions({
accessAuth: authOptions.accessAuth,
actionAuth: recipient.actionAuth,
accessAuth: recipient.accessAuth || authOptions.accessAuth,
actionAuth: recipient.actionAuth || authOptions.actionAuth,
});
}
@ -236,8 +235,7 @@ export const setRecipientsForDocument = async ({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: documentId,
user,
requestMetadata,
metadata: requestMetadata,
data: {
changes,
...baseAuditLog,
@ -252,10 +250,10 @@ export const setRecipientsForDocument = async ({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
documentId: documentId,
user,
requestMetadata,
metadata: requestMetadata,
data: {
...baseAuditLog,
accessAuth: recipient.accessAuth || undefined,
actionAuth: recipient.actionAuth || undefined,
},
}),
@ -282,8 +280,7 @@ export const setRecipientsForDocument = async ({
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
documentId: documentId,
user,
requestMetadata,
metadata: requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
@ -368,17 +365,22 @@ type RecipientData = {
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
const newRecipientAccessAuth = newRecipientData.accessAuth || null;
const newRecipientActionAuth = newRecipientData.actionAuth || null;
return (
recipient.email !== newRecipientData.email ||
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
authOptions.actionAuth !== newRecipientData.actionAuth
authOptions.accessAuth !== newRecipientAccessAuth ||
authOptions.actionAuth !== newRecipientActionAuth
);
};

View File

@ -18,7 +18,7 @@ import {
import { nanoid } from '../../universal/id';
import { createRecipientAuthOptions } from '../../utils/document-auth';
export type SetRecipientsForTemplateOptions = {
export type SetTemplateRecipientsOptions = {
userId: number;
teamId?: number;
templateId: number;
@ -32,37 +32,36 @@ export type SetRecipientsForTemplateOptions = {
}[];
};
export const ZSetRecipientsForTemplateResponseSchema = z.object({
export const ZSetTemplateRecipientsResponseSchema = z.object({
recipients: RecipientSchema.array(),
});
export type TSetRecipientsForTemplateResponse = z.infer<
typeof ZSetRecipientsForTemplateResponseSchema
>;
export type TSetTemplateRecipientsResponse = z.infer<typeof ZSetTemplateRecipientsResponseSchema>;
export const setRecipientsForTemplate = async ({
export const setTemplateRecipients = async ({
userId,
teamId,
templateId,
recipients,
}: SetRecipientsForTemplateOptions): Promise<TSetRecipientsForTemplateResponse> => {
}: SetTemplateRecipientsOptions): Promise<TSetTemplateRecipientsResponse> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
include: {
directLink: true,

View File

@ -0,0 +1,249 @@
import { 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';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffRecipientChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
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 { ZRecipientResponseSchema } from '@documenso/trpc/server/recipient-router/schema';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientBeModified } from '../../utils/recipients';
export interface UpdateDocumentRecipientsOptions {
userId: number;
teamId?: number;
documentId: number;
recipients: RecipientData[];
requestMetadata: ApiRequestMetadata;
}
export const ZUpdateDocumentRecipientsResponseSchema = z.object({
recipients: ZRecipientResponseSchema.array(),
});
export type TUpdateDocumentRecipientsResponse = z.infer<
typeof ZUpdateDocumentRecipientsResponseSchema
>;
export const updateDocumentRecipients = async ({
userId,
teamId,
documentId,
recipients,
requestMetadata,
}: UpdateDocumentRecipientsOptions): Promise<TUpdateDocumentRecipientsResponse> => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Field: true,
Recipient: true,
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (document.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
});
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const recipientsToUpdate = recipients.map((recipient) => {
const originalRecipient = document.Recipient.find(
(existingRecipient) => existingRecipient.id === recipient.id,
);
if (!originalRecipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Recipient with id ${recipient.id} not found`,
});
}
const duplicateRecipientWithSameEmail = document.Recipient.find(
(existingRecipient) =>
existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id,
);
if (duplicateRecipientWithSameEmail) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`,
});
}
if (
hasRecipientBeenChanged(originalRecipient, recipient) &&
!canRecipientBeModified(originalRecipient, document.Field)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify a recipient who has already interacted with the document',
});
}
return {
originalRecipient,
updateData: recipient,
};
});
const updatedRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
recipientsToUpdate.map(async ({ originalRecipient, updateData }) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions);
if (updateData.actionAuth !== undefined || updateData.accessAuth !== undefined) {
authOptions = createRecipientAuthOptions({
accessAuth: updateData.accessAuth || authOptions.accessAuth,
actionAuth: updateData.actionAuth || authOptions.actionAuth,
});
}
const mergedRecipient = {
...originalRecipient,
...updateData,
};
const updatedRecipient = await tx.recipient.update({
where: {
id: originalRecipient.id,
documentId,
},
data: {
name: mergedRecipient.name,
email: mergedRecipient.email,
role: mergedRecipient.role,
signingOrder: mergedRecipient.signingOrder,
documentId,
sendStatus:
mergedRecipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
mergedRecipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
authOptions,
},
include: {
Field: true,
},
});
// Clear all fields if the recipient role is changed to a type that cannot have fields.
if (
originalRecipient.role !== updatedRecipient.role &&
(updatedRecipient.role === RecipientRole.CC ||
updatedRecipient.role === RecipientRole.VIEWER)
) {
await tx.field.deleteMany({
where: {
recipientId: updatedRecipient.id,
},
});
}
const changes = diffRecipientChanges(originalRecipient, updatedRecipient);
// Handle recipient updated audit log.
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: documentId,
metadata: requestMetadata,
data: {
recipientEmail: updatedRecipient.email,
recipientName: updatedRecipient.name,
recipientId: updatedRecipient.id,
recipientRole: updatedRecipient.role,
changes,
},
}),
});
}
return updatedRecipient;
}),
);
});
return {
recipients: updatedRecipients,
};
};
/**
* If you change this you MUST update the `hasRecipientBeenChanged` function.
*/
type RecipientData = {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
const newRecipientAccessAuth = newRecipientData.accessAuth || null;
const newRecipientActionAuth = newRecipientData.actionAuth || null;
return (
recipient.email !== newRecipientData.email ||
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
authOptions.accessAuth !== newRecipientAccessAuth ||
authOptions.actionAuth !== newRecipientActionAuth
);
};

View File

@ -0,0 +1,185 @@
import { z } from 'zod';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { ZRecipientResponseSchema } from '@documenso/trpc/server/recipient-router/schema';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface UpdateTemplateRecipientsOptions {
userId: number;
teamId?: number;
templateId: number;
recipients: {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
}[];
}
export const ZUpdateTemplateRecipientsResponseSchema = z.object({
recipients: ZRecipientResponseSchema.array(),
});
export type TUpdateTemplateRecipientsResponse = z.infer<
typeof ZUpdateTemplateRecipientsResponseSchema
>;
export const updateTemplateRecipients = async ({
userId,
teamId,
templateId,
recipients,
}: UpdateTemplateRecipientsOptions): Promise<TUpdateTemplateRecipientsResponse> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const recipientsToUpdate = recipients.map((recipient) => {
const originalRecipient = template.Recipient.find(
(existingRecipient) => existingRecipient.id === recipient.id,
);
if (!originalRecipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Recipient with id ${recipient.id} not found`,
});
}
const duplicateRecipientWithSameEmail = template.Recipient.find(
(existingRecipient) =>
existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id,
);
if (duplicateRecipientWithSameEmail) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`,
});
}
return {
originalRecipient,
recipientUpdateData: recipient,
};
});
const updatedRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
recipientsToUpdate.map(async ({ originalRecipient, recipientUpdateData }) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions);
if (
recipientUpdateData.actionAuth !== undefined ||
recipientUpdateData.accessAuth !== undefined
) {
authOptions = createRecipientAuthOptions({
accessAuth: recipientUpdateData.accessAuth || authOptions.accessAuth,
actionAuth: recipientUpdateData.actionAuth || authOptions.actionAuth,
});
}
const mergedRecipient = {
...originalRecipient,
...recipientUpdateData,
};
const updatedRecipient = await tx.recipient.update({
where: {
id: originalRecipient.id,
templateId,
},
data: {
name: mergedRecipient.name,
email: mergedRecipient.email,
role: mergedRecipient.role,
signingOrder: mergedRecipient.signingOrder,
templateId,
sendStatus:
mergedRecipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
mergedRecipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
authOptions,
},
include: {
Field: true,
},
});
// Clear all fields if the recipient role is changed to a type that cannot have fields.
if (
originalRecipient.role !== updatedRecipient.role &&
(updatedRecipient.role === RecipientRole.CC ||
updatedRecipient.role === RecipientRole.VIEWER)
) {
await tx.field.deleteMany({
where: {
recipientId: updatedRecipient.id,
},
});
}
return updatedRecipient;
}),
);
});
return {
recipients: updatedRecipients,
};
};

View File

@ -33,7 +33,7 @@ import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { ZFieldMetaSchema } from '../../types/field-meta';
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
@ -55,7 +55,7 @@ export type CreateDocumentFromDirectTemplateOptions = {
directTemplateExternalId?: string;
signedFieldValues: TSignFieldWithTokenMutationSchema[];
templateUpdatedAt: Date;
requestMetadata: RequestMetadata;
requestMetadata: ApiRequestMetadata;
user?: {
id: number;
name?: string;
@ -454,7 +454,7 @@ export const createDocumentFromDirectTemplate = async ({
name: user?.name,
email: directRecipientEmail,
},
requestMetadata,
metadata: requestMetadata,
data: {
title: document.title,
source: {
@ -472,7 +472,7 @@ export const createDocumentFromDirectTemplate = async ({
name: user?.name,
email: directRecipientEmail,
},
requestMetadata,
metadata: requestMetadata,
data: {
recipientEmail: createdDirectRecipient.email,
recipientId: createdDirectRecipient.id,
@ -490,7 +490,7 @@ export const createDocumentFromDirectTemplate = async ({
name: user?.name,
email: directRecipientEmail,
},
requestMetadata,
metadata: requestMetadata,
data: {
recipientEmail: createdDirectRecipient.email,
recipientId: createdDirectRecipient.id,
@ -535,7 +535,7 @@ export const createDocumentFromDirectTemplate = async ({
name: user?.name,
email: directRecipientEmail,
},
requestMetadata,
metadata: requestMetadata,
data: {
recipientEmail: createdDirectRecipient.email,
recipientId: createdDirectRecipient.id,
@ -609,6 +609,7 @@ export const createDocumentFromDirectTemplate = async ({
},
include: {
documentData: true,
documentMeta: true,
Recipient: true,
},
});

View File

@ -26,7 +26,7 @@ import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import type { TDocumentEmailSettings } from '../../types/document-email';
import { ZFieldMetaSchema } from '../../types/field-meta';
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
createDocumentAuthOptions,
@ -73,7 +73,7 @@ export type CreateDocumentFromTemplateOptions = {
typedSignatureEnabled?: boolean;
emailSettings?: TDocumentEmailSettings;
};
requestMetadata?: RequestMetadata;
requestMetadata: ApiRequestMetadata;
};
export const ZCreateDocumentFromTemplateResponseSchema = DocumentSchema.extend({
@ -95,12 +95,6 @@ export const createDocumentFromTemplate = async ({
override,
requestMetadata,
}: CreateDocumentFromTemplateOptions): Promise<TCreateDocumentFromTemplateResponse> => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const template = await prisma.template.findUnique({
where: {
id: templateId,
@ -213,7 +207,7 @@ export const createDocumentFromTemplate = async ({
globalAccessAuth: templateAuthOptions.globalAccessAuth,
globalActionAuth: templateAuthOptions.globalActionAuth,
}),
visibility: template.team?.teamGlobalSettings?.documentVisibility,
visibility: template.visibility || template.team?.teamGlobalSettings?.documentVisibility,
documentMeta: {
create: {
subject: override?.subject || template.templateMeta?.subject,
@ -312,8 +306,7 @@ export const createDocumentFromTemplate = async ({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
user,
requestMetadata,
metadata: requestMetadata,
data: {
title: document.title,
source: {

View File

@ -16,6 +16,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
export type CreateTemplateDirectLinkOptions = {
templateId: number;
userId: number;
teamId?: number;
directRecipientId?: number;
};
@ -28,25 +29,27 @@ export type TCreateTemplateDirectLinkResponse = z.infer<
export const createTemplateDirectLink = async ({
templateId,
userId,
teamId,
directRecipientId,
}: CreateTemplateDirectLinkOptions): Promise<TCreateTemplateDirectLinkResponse> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,

View File

@ -8,29 +8,32 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
export type DeleteTemplateDirectLinkOptions = {
templateId: number;
userId: number;
teamId?: number;
};
export const deleteTemplateDirectLink = async ({
templateId,
userId,
teamId,
}: DeleteTemplateDirectLinkOptions): Promise<void> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
include: {
directLink: true,

View File

@ -12,26 +12,21 @@ export const deleteTemplate = async ({ id, userId, teamId }: DeleteTemplateOptio
return await prisma.template.delete({
where: {
id,
OR:
teamId === undefined
? [
{
userId,
teamId: null,
},
]
: [
{
teamId,
team: {
members: {
some: {
userId,
},
},
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
],
},
}
: {
userId,
teamId: null,
}),
},
});
};

View File

@ -9,6 +9,7 @@ import type { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/te
export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & {
userId: number;
teamId?: number;
};
export const ZDuplicateTemplateResponseSchema = TemplateSchema;
@ -20,28 +21,25 @@ export const duplicateTemplate = async ({
userId,
teamId,
}: DuplicateTemplateOptions): Promise<TDuplicateTemplateResponse> => {
let templateWhereFilter: Prisma.TemplateWhereUniqueInput = {
id: templateId,
userId,
teamId: null,
};
if (teamId !== undefined) {
templateWhereFilter = {
id: templateId,
teamId,
team: {
members: {
some: {
userId,
},
},
},
};
}
const template = await prisma.template.findUnique({
where: templateWhereFilter,
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
Field: true,

View File

@ -1,7 +1,13 @@
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import type { Prisma, Template } from '@documenso/prisma/client';
import {
DocumentVisibility,
type Prisma,
TeamMemberRole,
type Template,
} from '@documenso/prisma/client';
import {
DocumentDataSchema,
FieldSchema,
@ -12,6 +18,7 @@ import {
TemplateSchema,
} from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type FindResultResponse, ZFindResultResponse } from '../../types/search-params';
export type FindTemplatesOptions = {
@ -52,28 +59,58 @@ export const findTemplates = async ({
page = 1,
perPage = 10,
}: FindTemplatesOptions): Promise<TFindTemplatesResponse> => {
let whereFilter: Prisma.TemplateWhereInput = {
userId,
teamId: null,
type,
};
const whereFilter: Prisma.TemplateWhereInput[] = [];
if (teamId === undefined) {
whereFilter.push({ userId, teamId: null });
}
if (teamId !== undefined) {
whereFilter = {
team: {
id: teamId,
members: {
some: {
userId,
},
},
const teamMember = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
},
};
});
if (!teamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not a member of this team.',
});
}
whereFilter.push(
{ teamId },
{
OR: [
match(teamMember.role)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE })),
{ userId, teamId },
],
},
);
}
const [data, count] = await Promise.all([
prisma.template.findMany({
where: whereFilter,
where: {
type,
AND: whereFilter,
},
include: {
templateDocumentData: true,
team: {
@ -103,7 +140,9 @@ export const findTemplates = async ({
},
}),
prisma.template.count({
where: whereFilter,
where: {
AND: whereFilter,
},
}),
]);

View File

@ -1,7 +1,6 @@
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import {
DocumentDataSchema,
FieldSchema,
@ -40,32 +39,25 @@ export const getTemplateById = async ({
userId,
teamId,
}: GetTemplateByIdOptions): Promise<TGetTemplateByIdResponse> => {
const whereFilter: Prisma.TemplateWhereInput = {
id,
OR:
teamId === undefined
? [
{
userId,
teamId: null,
},
]
: [
{
teamId,
team: {
members: {
some: {
userId,
},
const template = await prisma.template.findFirst({
where: {
id,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
],
};
const template = await prisma.template.findFirst({
where: whereFilter,
}
: {
userId,
teamId: null,
}),
},
include: {
directLink: true,
templateDocumentData: true,

View File

@ -10,6 +10,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
export type ToggleTemplateDirectLinkOptions = {
templateId: number;
userId: number;
teamId?: number;
enabled: boolean;
};
@ -22,25 +23,27 @@ export type TToggleTemplateDirectLinkResponse = z.infer<
export const toggleTemplateDirectLink = async ({
templateId,
userId,
teamId,
enabled,
}: ToggleTemplateDirectLinkOptions): Promise<TToggleTemplateDirectLinkResponse> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,

View File

@ -3,22 +3,22 @@
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 type { DocumentVisibility, 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';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
export type UpdateTemplateSettingsOptions = {
export type UpdateTemplateOptions = {
userId: number;
teamId?: number;
templateId: number;
data: {
data?: {
title?: string;
externalId?: string | null;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
publicTitle?: string;
@ -26,26 +26,19 @@ export type UpdateTemplateSettingsOptions = {
type?: Template['type'];
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata?: RequestMetadata;
};
export const ZUpdateTemplateSettingsResponseSchema = TemplateSchema;
export const ZUpdateTemplateResponseSchema = TemplateSchema;
export type TUpdateTemplateSettingsResponse = z.infer<typeof ZUpdateTemplateSettingsResponseSchema>;
export type TUpdateTemplateResponse = z.infer<typeof ZUpdateTemplateResponseSchema>;
export const updateTemplateSettings = async ({
export const updateTemplate = async ({
userId,
teamId,
templateId,
meta,
data,
}: 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',
});
}
meta = {},
data = {},
}: UpdateTemplateOptions): Promise<TUpdateTemplateResponse> => {
const template = await prisma.template.findFirstOrThrow({
where: {
id: templateId,
@ -70,6 +63,10 @@ export const updateTemplateSettings = async ({
},
});
if (Object.values(data).length === 0 && Object.keys(meta).length === 0) {
return template;
}
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
@ -107,11 +104,12 @@ export const updateTemplateSettings = async ({
id: templateId,
},
data: {
title: data.title,
externalId: data.externalId,
type: data.type,
publicDescription: data.publicDescription,
publicTitle: data.publicTitle,
title: data?.title,
externalId: data?.externalId,
type: data?.type,
visibility: data?.visibility,
publicDescription: data?.publicDescription,
publicTitle: data?.publicTitle,
authOptions,
templateMeta: {
upsert: {

View File

@ -8,7 +8,7 @@ import { z } from 'zod';
import { DocumentSource, FieldType } from '@documenso/prisma/client';
import { ZRecipientActionAuthTypesSchema } from './document-auth';
import { ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthTypesSchema } from './document-auth';
export const ZDocumentAuditLogTypeSchema = z.enum([
// Document actions.
@ -127,11 +127,11 @@ export const ZGenericFromToSchema = z.object({
});
export const ZRecipientDiffActionAuthSchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.ACCESS_AUTH),
type: z.literal(RECIPIENT_DIFF_TYPE.ACTION_AUTH),
});
export const ZRecipientDiffAccessAuthSchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.ACTION_AUTH),
type: z.literal(RECIPIENT_DIFF_TYPE.ACCESS_AUTH),
});
export const ZRecipientDiffNameSchema = ZGenericFromToSchema.extend({
@ -438,6 +438,7 @@ export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({
export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED),
data: ZBaseRecipientDataSchema.extend({
accessAuth: ZRecipientAccessAuthTypesSchema.optional(),
actionAuth: ZRecipientActionAuthTypesSchema.optional(),
}),
});

View File

@ -48,7 +48,9 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
* Must keep these two in sync.
*/
export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]);
export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
export const ZDocumentAccessAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT])
.describe('The type of authentication required for the recipient to access the document.');
/**
* The global document action auth methods.
@ -60,11 +62,11 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
]);
export const ZDocumentActionAuthTypesSchema = z.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
]);
export const ZDocumentActionAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH])
.describe(
'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.',
);
/**
* The recipient access auth methods.
@ -74,7 +76,9 @@ export const ZDocumentActionAuthTypesSchema = z.enum([
export const ZRecipientAccessAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
]);
export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
export const ZRecipientAccessAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT])
.describe('The type of authentication required for the recipient to access the document.');
/**
* The recipient action auth methods.
@ -87,12 +91,14 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuth2FASchema,
ZDocumentAuthExplicitNoneSchema,
]);
export const ZRecipientActionAuthTypesSchema = z.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXPLICIT_NONE,
]);
export const ZRecipientActionAuthTypesSchema = z
.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXPLICIT_NONE,
])
.describe('The type of authentication required for the recipient to sign the document.');
export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum;
export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum;

View File

@ -15,13 +15,44 @@ export enum DocumentEmailEvents {
export const ZDocumentEmailSettingsSchema = z
.object({
recipientSigningRequest: z.boolean().default(true),
recipientRemoved: z.boolean().default(true),
recipientSigned: z.boolean().default(true),
documentPending: z.boolean().default(true),
documentCompleted: z.boolean().default(true),
documentDeleted: z.boolean().default(true),
ownerDocumentCompleted: z.boolean().default(true),
recipientSigningRequest: z
.boolean()
.describe(
'Whether to send an email to all recipients that the document is ready for them to sign.',
)
.default(true),
recipientRemoved: z
.boolean()
.describe(
'Whether to send an email to the recipient who was removed from a pending document.',
)
.default(true),
recipientSigned: z
.boolean()
.describe(
'Whether to send an email to the document owner when a recipient has signed the document.',
)
.default(true),
documentPending: z
.boolean()
.describe(
'Whether to send an email to the recipient who has just signed the document indicating that there are still other recipients who need to sign the document. This will only be sent if the document is still pending after the recipient has signed.',
)
.default(true),
documentCompleted: z
.boolean()
.describe('Whether to send an email to all recipients when the document is complete.')
.default(true),
documentDeleted: z
.boolean()
.describe(
'Whether to send an email to all recipients if a pending document has been deleted.',
)
.default(true),
ownerDocumentCompleted: z
.boolean()
.describe('Whether to send an email to the document owner when the document is complete.')
.default(true),
})
.strip()
.catch(() => ({

View File

@ -8,9 +8,9 @@ import { z } from 'zod';
* 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(),
query: z.string().describe('The search query.').optional(),
page: z.coerce.number().min(1).describe('The pagination page number, starts at 1.').optional(),
perPage: z.coerce.number().min(1).describe('The number of items per page.').max(100).optional(),
});
/**
@ -31,16 +31,17 @@ export const ZUrlSearchParamsSchema = z.object({
perPage: z.coerce
.number()
.min(1)
.max(100)
.optional()
.catch(() => undefined),
});
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(),
data: z.union([z.array(z.unknown()), z.unknown()]).describe('The results from the search.'),
count: z.number().describe('The total number of items.'),
currentPage: z.number().describe('The current page number, starts at 1.'),
perPage: z.number().describe('The number of items per page.'),
totalPages: z.number().describe('The total number of pages.'),
});
// Can't infer generics from Zod.

View File

@ -12,6 +12,36 @@ export const ZRequestMetadataSchema = z.object({
export type RequestMetadata = z.infer<typeof ZRequestMetadataSchema>;
export type ApiRequestMetadata = {
/**
* The general metadata of the request.
*/
requestMetadata: RequestMetadata;
/**
* The source of the request.
*/
source: 'apiV1' | 'apiV2' | 'app';
/**
* The method of authentication used to access the API.
*
* If the request is not authenticated, the value will be `null`.
*/
auth: 'api' | 'session' | null;
/**
* The user that is performing the action.
*
* If a team API key is used, the user will classified as the team.
*/
auditUser?: {
id: number | null;
email: string | null;
name: string | null;
};
};
export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetadata => {
const parsedIp = ZIpSchema.safeParse(req.headers['x-forwarded-for'] || req.socket.remoteAddress);

View File

@ -19,14 +19,15 @@ import {
ZDocumentAuditLogSchema,
} from '../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../types/document-auth';
import type { RequestMetadata } from '../universal/extract-request-metadata';
import type { ApiRequestMetadata, RequestMetadata } from '../universal/extract-request-metadata';
type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
documentId: number;
type: T;
data: Extract<TDocumentAuditLog, { type: T }>['data'];
user: { email?: string; id?: number | null; name?: string | null } | null;
user?: { email?: string | null; id?: number | null; name?: string | null } | null;
requestMetadata?: RequestMetadata;
metadata?: ApiRequestMetadata;
};
export type CreateDocumentAuditLogDataResponse = Pick<
@ -42,16 +43,31 @@ export const createDocumentAuditLogData = <T extends TDocumentAuditLog['type']>(
data,
user,
requestMetadata,
metadata,
}: CreateDocumentAuditLogDataOptions<T>): CreateDocumentAuditLogDataResponse => {
let userId: number | null = metadata?.auditUser?.id || null;
let email: string | null = metadata?.auditUser?.email || null;
let name: string | null = metadata?.auditUser?.name || null;
// Prioritize explicit user parameter over metadata audit user.
if (user) {
userId = user.id || null;
email = user.email || null;
name = user.name || null;
}
const ipAddress = metadata?.requestMetadata.ipAddress ?? requestMetadata?.ipAddress ?? null;
const userAgent = metadata?.requestMetadata.userAgent ?? requestMetadata?.userAgent ?? null;
return {
type,
data,
documentId,
userId: user?.id ?? null,
email: user?.email ?? null,
name: user?.name ?? null,
userAgent: requestMetadata?.userAgent ?? null,
ipAddress: requestMetadata?.ipAddress ?? null,
userId,
email,
name,
userAgent,
ipAddress,
};
};

View File

@ -8,8 +8,17 @@ export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}
* Whether a recipient can be modified by the document owner.
*/
export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) => {
if (!recipient) {
return false;
}
// CCers can always be modified (unless document is completed).
if (recipient.role === RecipientRole.CC) {
return true;
}
// Deny if the recipient has already signed the document.
if (!recipient || recipient.signingStatus === SigningStatus.SIGNED) {
if (recipient.signingStatus === SigningStatus.SIGNED) {
return false;
}

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "visibility" "DocumentVisibility" NOT NULL DEFAULT 'EVERYONE';

View File

@ -654,19 +654,20 @@ model TemplateMeta {
}
model Template {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
externalId String?
type TemplateType @default(PRIVATE)
type TemplateType @default(PRIVATE)
title String
userId Int
teamId Int?
visibility DocumentVisibility @default(EVERYONE)
authOptions Json?
templateMeta TemplateMeta?
templateDocumentDataId String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
publicTitle String @default("")
publicDescription String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
publicTitle String @default("")
publicDescription String @default("")
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)

View File

@ -19,7 +19,7 @@
"@trpc/server": "^10.36.0",
"@ts-rest/core": "^3.30.5",
"@ts-rest/next": "^3.30.5",
"luxon": "^3.4.0",
"luxon": "^3.5.0",
"superjson": "^1.13.1",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"

View File

@ -35,9 +35,10 @@ export const trpc = createTRPCReact<AppRouter>({
export interface TrpcProviderProps {
children: React.ReactNode;
headers?: Record<string, string>;
}
export function TrpcProvider({ children }: TrpcProviderProps) {
export function TrpcProvider({ children, headers }: TrpcProviderProps) {
let queryClientConfig: QueryClientConfig | undefined;
const isDevelopingOffline =
@ -63,15 +64,16 @@ export function TrpcProvider({ children }: TrpcProviderProps) {
const [trpcClient] = useState(() =>
trpc.createClient({
transformer: SuperJSON,
links: [
splitLink({
condition: (op) => op.context.skipBatch === true,
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
headers,
}),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
headers,
}),
}),
],

View File

@ -17,7 +17,6 @@ import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
import { compareSync } from '@documenso/lib/server-only/auth/hash';
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
@ -89,7 +88,7 @@ export const authRouter = router({
userId: ctx.user.id,
verificationResponse,
passkeyName: input.passkeyName,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
requestMetadata: ctx.metadata.requestMetadata,
});
}),
@ -132,7 +131,7 @@ export const authRouter = router({
await deletePasskey({
userId: ctx.user.id,
passkeyId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
requestMetadata: ctx.metadata.requestMetadata,
});
}),
@ -158,7 +157,7 @@ export const authRouter = router({
userId: ctx.user.id,
passkeyId,
name,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
requestMetadata: ctx.metadata.requestMetadata,
});
}),
});

View File

@ -1,15 +1,37 @@
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { z } from 'zod';
import { getServerSession } from '@documenso/lib/next-auth/get-server-session';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
export const createTrpcContext = async ({ req, res }: CreateNextContextOptions) => {
import type { CreateNextContextOptions } from './adapters/next';
type CreateTrpcContext = CreateNextContextOptions & {
requestSource: 'apiV1' | 'apiV2' | 'app';
};
export const createTrpcContext = async ({ req, res, requestSource }: CreateTrpcContext) => {
const { session, user } = await getServerSession({ req, res });
const metadata: ApiRequestMetadata = {
requestMetadata: extractNextApiRequestMetadata(req),
source: requestSource,
auth: null,
};
const teamId = z.coerce
.number()
.optional()
.catch(() => undefined)
.parse(req.headers['x-team-id']);
if (!session) {
return {
session: null,
user: null,
teamId,
req,
metadata,
};
}
@ -17,14 +39,18 @@ export const createTrpcContext = async ({ req, res }: CreateNextContextOptions)
return {
session: null,
user: null,
teamId,
req,
metadata,
};
}
return {
session,
user,
teamId,
req,
metadata,
};
};

View File

@ -39,12 +39,10 @@ import {
sendDocument,
} from '@documenso/lib/server-only/document/send-document';
import {
ZUpdateDocumentSettingsResponseSchema,
updateDocumentSettings,
} from '@documenso/lib/server-only/document/update-document-settings';
import { updateTitle } from '@documenso/lib/server-only/document/update-title';
ZUpdateDocumentResponseSchema,
updateDocument,
} from '@documenso/lib/server-only/document/update-document';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { DocumentStatus } from '@documenso/prisma/client';
import { authenticatedProcedure, procedure, router } from '../trpc';
@ -64,9 +62,8 @@ import {
ZSearchDocumentsMutationSchema,
ZSendDocumentMutationSchema,
ZSetPasswordForDocumentMutationSchema,
ZSetSettingsForDocumentMutationSchema,
ZSetSigningOrderForDocumentMutationSchema,
ZSetTitleForDocumentMutationSchema,
ZUpdateDocumentRequestSchema,
ZUpdateTypedSignatureSettingsMutationSchema,
} from './schema';
@ -77,9 +74,13 @@ export const documentRouter = router({
getDocumentById: authenticatedProcedure
.input(ZGetDocumentByIdQuerySchema)
.query(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId } = input;
return await getDocumentById({
...input,
userId: ctx.user.id,
teamId,
documentId,
});
}),
@ -104,28 +105,19 @@ export const documentRouter = router({
.meta({
openapi: {
method: 'GET',
path: '/document/find',
path: '/document',
summary: 'Find documents',
description: 'Find documents based on a search criteria',
tags: ['Documents'],
tags: ['Document'],
},
})
.input(ZFindDocumentsQuerySchema)
.output(ZFindDocumentsResponseSchema)
.query(async ({ input, ctx }) => {
const { user } = ctx;
const { user, teamId } = ctx;
const {
query,
teamId,
templateId,
page,
perPage,
orderByDirection,
orderByColumn,
source,
status,
} = input;
const { query, templateId, page, perPage, orderByDirection, orderByColumn, source, status } =
input;
const documents = await findDocuments({
userId: user.id,
@ -154,34 +146,41 @@ export const documentRouter = router({
path: '/document/{documentId}',
summary: 'Get document',
description: 'Returns a document given an ID',
tags: ['Documents'],
tags: ['Document'],
},
})
.input(ZGetDocumentWithDetailsByIdQuerySchema)
.output(ZGetDocumentWithDetailsByIdResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { documentId } = input;
return await getDocumentWithDetailsById({
...input,
userId: ctx.user.id,
userId: user.id,
teamId,
documentId,
});
}),
/**
* @public
* Wait until RR7 so we can passthrough documents.
*
* @private
*/
createDocument: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/create',
summary: 'Create document',
tags: ['Documents'],
},
})
// .meta({
// openapi: {
// method: 'POST',
// path: '/document/create',
// summary: 'Create document',
// tags: ['Document'],
// },
// })
.input(ZCreateDocumentMutationSchema)
.output(ZCreateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { title, documentDataId, teamId, timezone } = input;
const { teamId } = ctx;
const { title, documentDataId, timezone } = input;
const { remaining } = await getServerLimits({ email: ctx.user.email, teamId });
@ -199,7 +198,7 @@ export const documentRouter = router({
documentDataId,
normalizePdf: true,
timezone,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
requestMetadata: ctx.metadata,
});
}),
@ -212,38 +211,43 @@ export const documentRouter = router({
.meta({
openapi: {
method: 'POST',
path: '/document/{documentId}',
path: '/document/update',
summary: 'Update document',
tags: ['Documents'],
tags: ['Document'],
},
})
.input(ZSetSettingsForDocumentMutationSchema)
.output(ZUpdateDocumentSettingsResponseSchema)
.input(ZUpdateDocumentRequestSchema)
.output(ZUpdateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { documentId, teamId, data, meta } = input;
const { teamId } = ctx;
const { documentId, data, meta = {} } = input;
const userId = ctx.user.id;
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
if (meta.timezone || meta.dateFormat || meta.redirectUrl) {
if (Object.values(meta).length > 0) {
await upsertDocumentMeta({
documentId,
dateFormat: meta.dateFormat,
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
language: meta.language,
userId: ctx.user.id,
requestMetadata,
teamId,
documentId,
subject: meta.subject,
message: meta.message,
timezone: meta.timezone,
dateFormat: meta.dateFormat,
language: meta.language,
typedSignatureEnabled: meta.typedSignatureEnabled,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
emailSettings: meta.emailSettings,
requestMetadata: ctx.metadata,
});
}
return await updateDocumentSettings({
return await updateDocument({
userId,
teamId,
documentId,
data,
requestMetadata,
requestMetadata: ctx.metadata,
});
}),
@ -253,16 +257,17 @@ export const documentRouter = router({
deleteDocument: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/{documentId}/delete',
method: 'DELETE',
path: '/document/{documentId}',
summary: 'Delete document',
tags: ['Documents'],
tags: ['Document'],
},
})
.input(ZDeleteDocumentMutationSchema)
.output(z.void())
.mutation(async ({ input, ctx }) => {
const { documentId, teamId } = input;
const { teamId } = ctx;
const { documentId } = input;
const userId = ctx.user.id;
@ -270,7 +275,7 @@ export const documentRouter = router({
id: documentId,
userId,
teamId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
requestMetadata: ctx.metadata,
});
}),
@ -281,10 +286,10 @@ export const documentRouter = router({
.meta({
openapi: {
method: 'POST',
path: '/document/{documentId}/move',
path: '/document/move',
summary: 'Move document',
description: 'Move a document to a team',
tags: ['Documents'],
description: 'Move a document from your personal account to a team',
tags: ['Document'],
},
})
.input(ZMoveDocumentToTeamSchema)
@ -297,27 +302,7 @@ export const documentRouter = router({
documentId,
teamId,
userId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
}),
/**
* @private
*/
// Should probably use `updateDocument`
setTitleForDocument: authenticatedProcedure
.input(ZSetTitleForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
const { documentId, teamId, title } = input;
const userId = ctx.user.id;
return await updateTitle({
title,
userId,
teamId,
documentId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
requestMetadata: ctx.metadata,
});
}),
@ -327,6 +312,7 @@ export const documentRouter = router({
setPasswordForDocument: authenticatedProcedure
.input(ZSetPasswordForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId, password } = input;
const key = DOCUMENSO_ENCRYPTION_KEY;
@ -341,10 +327,11 @@ export const documentRouter = router({
});
await upsertDocumentMeta({
userId: ctx.user.id,
teamId,
documentId,
password: securePassword,
userId: ctx.user.id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
requestMetadata: ctx.metadata,
});
}),
@ -354,23 +341,28 @@ export const documentRouter = router({
setSigningOrderForDocument: authenticatedProcedure
.input(ZSetSigningOrderForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId, signingOrder } = input;
return await upsertDocumentMeta({
userId: ctx.user.id,
teamId,
documentId,
signingOrder,
userId: ctx.user.id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
requestMetadata: ctx.metadata,
});
}),
/**
* @deprecated Remove after deployment.
*
* @private
*/
updateTypedSignatureSettings: authenticatedProcedure
.input(ZUpdateTypedSignatureSettingsMutationSchema)
.mutation(async ({ input, ctx }) => {
const { documentId, teamId, typedSignatureEnabled } = input;
const { teamId } = ctx;
const { documentId, typedSignatureEnabled } = input;
const document = await getDocumentById({
documentId,
@ -386,10 +378,11 @@ export const documentRouter = router({
}
return await upsertDocumentMeta({
userId: ctx.user.id,
teamId,
documentId,
typedSignatureEnabled,
userId: ctx.user.id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
requestMetadata: ctx.metadata,
});
}),
@ -403,27 +396,22 @@ export const documentRouter = router({
.meta({
openapi: {
method: 'POST',
path: '/document/{documentId}/distribute',
path: '/document/distribute',
summary: 'Distribute document',
description: 'Send the document out to recipients based on your distribution method',
tags: ['Documents'],
tags: ['Document'],
},
})
.input(ZSendDocumentMutationSchema)
.output(ZSendDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { documentId, teamId, meta } = input;
const { teamId } = ctx;
const { documentId, meta = {} } = input;
if (
meta.message ||
meta.subject ||
meta.timezone ||
meta.dateFormat ||
meta.redirectUrl ||
meta.distributionMethod ||
meta.emailSettings
) {
if (Object.values(meta).length > 0) {
await upsertDocumentMeta({
userId: ctx.user.id,
teamId,
documentId,
subject: meta.subject,
message: meta.message,
@ -431,9 +419,9 @@ export const documentRouter = router({
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
userId: ctx.user.id,
emailSettings: meta.emailSettings,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
language: meta.language,
requestMetadata: ctx.metadata,
});
}
@ -441,31 +429,38 @@ export const documentRouter = router({
userId: ctx.user.id,
documentId,
teamId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
requestMetadata: ctx.metadata,
});
}),
/**
* @public
*
* Todo: Refactor to redistributeDocument.
*/
resendDocument: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/{documentId}/resend',
summary: 'Resend document',
path: '/document/redistribute',
summary: 'Redistribute document',
description:
'Resend the document to recipients who have not signed. Will use the distribution method set in the document.',
tags: ['Documents'],
'Redistribute the document to the provided recipients who have not actioned the document. Will use the distribution method set in the document',
tags: ['Document'],
},
})
.input(ZResendDocumentMutationSchema)
.output(z.void())
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId, recipients } = input;
return await resendDocument({
userId: ctx.user.id,
...input,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
teamId,
documentId,
recipients,
requestMetadata: ctx.metadata,
});
}),
@ -476,17 +471,21 @@ export const documentRouter = router({
.meta({
openapi: {
method: 'POST',
path: '/document/{documentId}/duplicate',
path: '/document/duplicate',
summary: 'Duplicate document',
tags: ['Documents'],
tags: ['Document'],
},
})
.input(ZDuplicateDocumentMutationSchema)
.output(ZDuplicateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { documentId } = input;
return await duplicateDocument({
userId: ctx.user.id,
...input,
userId: user.id,
teamId,
documentId,
});
}),
@ -512,6 +511,8 @@ export const documentRouter = router({
findDocumentAuditLogs: authenticatedProcedure
.input(ZFindDocumentAuditLogsQuerySchema)
.query(async ({ input, ctx }) => {
const { teamId } = ctx;
const {
page,
perPage,
@ -523,13 +524,14 @@ export const documentRouter = router({
} = input;
return await findDocumentAuditLogs({
userId: ctx.user.id,
teamId,
page,
perPage,
documentId,
cursor,
filterForRecentActivity,
orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined,
userId: ctx.user.id,
});
}),
@ -539,7 +541,8 @@ export const documentRouter = router({
downloadAuditLogs: authenticatedProcedure
.input(ZDownloadAuditLogsMutationSchema)
.mutation(async ({ input, ctx }) => {
const { documentId, teamId } = input;
const { teamId } = ctx;
const { documentId } = input;
const document = await getDocumentById({
documentId,
@ -570,7 +573,8 @@ export const documentRouter = router({
downloadCertificate: authenticatedProcedure
.input(ZDownloadCertificateMutationSchema)
.mutation(async ({ input, ctx }) => {
const { documentId, teamId } = input;
const { teamId } = ctx;
const { documentId } = input;
const document = await getDocumentById({
documentId,

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