feat: team api tokens

This commit is contained in:
Mythie
2024-02-22 13:39:34 +11:00
parent 22e3a79a72
commit 2abcdd7533
36 changed files with 903 additions and 214 deletions

View File

@@ -61,7 +61,7 @@ export const DeleteDocumentDialog = ({
const onDelete = async () => {
try {
await deleteDocument({ id, status });
await deleteDocument({ id });
} catch {
toast({
title: 'Something went wrong',

View File

@@ -0,0 +1,85 @@
import { DateTime } from 'luxon';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button';
import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog';
import { LocaleDate } from '~/components/formatter/locale-date';
import { ApiTokenForm } from '~/components/forms/token';
type ApiTokensPageProps = {
params: {
teamUrl: string;
};
};
export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
return (
<div>
<h3 className="text-2xl font-semibold">API Tokens</h3>
<p className="text-muted-foreground mt-2 text-sm">
On this page, you can create new API tokens and manage the existing ones.
</p>
<hr className="my-4" />
<ApiTokenForm className="max-w-xl" teamId={team.id} />
<hr className="mb-4 mt-8" />
<h4 className="text-xl font-medium">Your existing tokens</h4>
{tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
Your tokens will be shown here once you create them.
</p>
</div>
)}
{tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
Created on <LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
Token doesn't have an expiration date
</p>
)}
</div>
<div>
<DeleteTokenDialog token={token} teamId={team.id}>
<Button variant="destructive">Delete</Button>
</DeleteTokenDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -32,12 +32,18 @@ import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteTokenDialogProps = {
teamId?: number;
token: Pick<ApiToken, 'id' | 'name'>;
onDelete?: () => void;
children?: React.ReactNode;
};
export default function DeleteTokenDialog({ token, onDelete, children }: DeleteTokenDialogProps) {
export default function DeleteTokenDialog({
teamId,
token,
onDelete,
children,
}: DeleteTokenDialogProps) {
const router = useRouter();
const { toast } = useToast();
@@ -70,6 +76,7 @@ export default function DeleteTokenDialog({ token, onDelete, children }: DeleteT
try {
await deleteTokenMutation({
id: token.id,
teamId,
});
toast({

View File

@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import { CreditCard, Settings, Users } from 'lucide-react';
import { Braces, CreditCard, Settings, Users } from 'lucide-react';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
@@ -21,6 +21,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const settingsPath = `/t/${teamUrl}/settings`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const billingPath = `/t/${teamUrl}/settings/billing`;
return (
@@ -48,6 +49,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
<Link href={tokensPath}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
>
<Braces className="mr-2 h-5 w-5" />
API Tokens
</Button>
</Link>
{IS_BILLING_ENABLED() && (
<Link href={billingPath}>
<Button

View File

@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react';
import { Braces, CreditCard, Key, User } from 'lucide-react';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
@@ -21,6 +21,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
const settingsPath = `/t/${teamUrl}/settings`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const billingPath = `/t/${teamUrl}/settings/billing`;
return (
@@ -56,6 +57,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
<Link href={tokensPath}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
>
<Braces className="mr-2 h-5 w-5" />
API Tokens
</Button>
</Link>
{IS_BILLING_ENABLED() && (
<Link href={billingPath}>
<Button

View File

@@ -46,9 +46,10 @@ type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
export type ApiTokenFormProps = {
className?: string;
teamId?: number;
};
export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
export const ApiTokenForm = ({ className, teamId }: ApiTokenFormProps) => {
const router = useRouter();
const [, copy] = useCopyToClipboard();
@@ -96,6 +97,7 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
try {
await createTokenMutation({
teamId,
tokenName,
expirationDate: noExpirationDate ? null : expirationDate,
});

View File

@@ -47,6 +47,9 @@
"apps/*",
"packages/*"
],
"dependencies": {
"next-runtime-env": "^3.2.0"
},
"overrides": {
"next-auth": {
"next": "14.0.3"

View File

@@ -1,7 +1,6 @@
import { initContract } from '@ts-rest/core';
import {
ZSendDocumentForSigningMutationSchema as SendDocumentMutationSchema,
ZAuthorizationHeadersSchema,
ZCreateDocumentFromTemplateMutationResponseSchema,
ZCreateDocumentFromTemplateMutationSchema,
@@ -13,6 +12,7 @@ import {
ZDeleteFieldMutationSchema,
ZDeleteRecipientMutationSchema,
ZGetDocumentsQuerySchema,
ZSendDocumentForSigningMutationSchema,
ZSuccessfulDocumentResponseSchema,
ZSuccessfulFieldResponseSchema,
ZSuccessfulGetDocumentResponseSchema,
@@ -72,13 +72,13 @@ export const ApiContractV1 = c.router(
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Upload a new document and get a presigned URL',
summary: 'Create a new document from an existing template',
},
sendDocument: {
method: 'POST',
path: '/api/v1/documents/:id/send',
body: SendDocumentMutationSchema,
body: ZSendDocumentForSigningMutationSchema,
responses: {
200: ZSuccessfulSigningResponseSchema,
400: ZUnsuccessfulResponseSchema,

View File

@@ -18,6 +18,7 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
@@ -25,11 +26,16 @@ import { ApiContractV1 } from './contract';
import { authenticatedMiddleware } from './middleware/authenticated';
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
getDocuments: authenticatedMiddleware(async (args, user) => {
getDocuments: authenticatedMiddleware(async (args, user, team) => {
const page = Number(args.query.page) || 1;
const perPage = Number(args.query.perPage) || 10;
const { data: documents, totalPages } = await findDocuments({ page, perPage, userId: user.id });
const { data: documents, totalPages } = await findDocuments({
page,
perPage,
userId: user.id,
teamId: team?.id,
});
return {
status: 200,
@@ -40,13 +46,19 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}),
getDocument: authenticatedMiddleware(async (args, user) => {
getDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
try {
const document = await getDocumentById({ id: Number(documentId), userId: user.id });
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
const recipients = await getRecipientsForDocument({
documentId: Number(documentId),
teamId: team?.id,
userId: user.id,
});
@@ -67,16 +79,29 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
deleteDocument: authenticatedMiddleware(async (args, user) => {
deleteDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
try {
const document = await getDocumentById({ id: Number(documentId), userId: user.id });
const deletedDocument = await deleteDocument({
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
status: document.status,
teamId: team?.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
const deletedDocument = await deleteDocument({
id: document.id,
userId: user.id,
teamId: team?.id,
});
return {
@@ -93,7 +118,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
createDocument: authenticatedMiddleware(async (args, user) => {
createDocument: authenticatedMiddleware(async (args, user, team) => {
const { body } = args;
try {
@@ -118,13 +143,17 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const document = await createDocument({
title: body.title,
userId: user.id,
teamId: team?.id,
documentDataId: documentData.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const recipients = await setRecipientsForDocument({
userId: user.id,
teamId: team?.id,
documentId: document.id,
recipients: body.recipients,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
return {
@@ -151,7 +180,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
createDocumentFromTemplate: authenticatedMiddleware(async (args, user) => {
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => {
const { body, params } = args;
const templateId = Number(params.templateId);
@@ -161,14 +190,16 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const document = await createDocumentFromTemplate({
templateId,
userId: user.id,
teamId: team?.id,
recipients: body.recipients,
});
await updateDocument({
documentId: document.id,
userId: user.id,
teamId: team?.id,
data: {
title: body.title,
title: fileName,
},
});
@@ -180,6 +211,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
message: body.meta.message,
dateFormat: body.meta.dateFormat,
timezone: body.meta.timezone,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
}
@@ -198,10 +230,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}),
sendDocument: authenticatedMiddleware(async (args, user) => {
sendDocument: authenticatedMiddleware(async (args, user, team) => {
const { id } = args.params;
const document = await getDocumentById({ id: Number(id), userId: user.id });
const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id });
if (!document) {
return {
@@ -212,11 +244,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}
if (document.status === 'PENDING') {
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already waiting for signing',
message: 'Document is already complete',
},
};
}
@@ -258,6 +290,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
await sendDocument({
documentId: Number(id),
userId: user.id,
teamId: team?.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
return {
@@ -276,13 +310,14 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
createRecipient: authenticatedMiddleware(async (args, user) => {
createRecipient: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
const { name, email, role } = args.body;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document) {
@@ -306,6 +341,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const recipients = await getRecipientsForDocument({
documentId: Number(documentId),
userId: user.id,
teamId: team?.id,
});
const recipientAlreadyExists = recipients.some((recipient) => recipient.email === email);
@@ -323,6 +359,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const newRecipients = await setRecipientsForDocument({
documentId: Number(documentId),
userId: user.id,
teamId: team?.id,
recipients: [
...recipients,
{
@@ -331,6 +368,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
role,
},
],
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const newRecipient = newRecipients.find((recipient) => recipient.email === email);
@@ -356,13 +394,14 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
updateRecipient: authenticatedMiddleware(async (args, user) => {
updateRecipient: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId, recipientId } = args.params;
const { name, email, role } = args.body;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document) {
@@ -386,9 +425,12 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const updatedRecipient = await updateRecipient({
documentId: Number(documentId),
recipientId: Number(recipientId),
userId: user.id,
teamId: team?.id,
email,
name,
role,
requestMetadata: extractNextApiRequestMetadata(args.req),
}).catch(() => null);
if (!updatedRecipient) {
@@ -409,12 +451,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}),
deleteRecipient: authenticatedMiddleware(async (args, user) => {
deleteRecipient: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId, recipientId } = args.params;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document) {
@@ -438,6 +481,9 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const deletedRecipient = await deleteRecipient({
documentId: Number(documentId),
recipientId: Number(recipientId),
userId: user.id,
teamId: team?.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
}).catch(() => null);
if (!deletedRecipient) {
@@ -458,13 +504,14 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}),
createField: authenticatedMiddleware(async (args, user) => {
createField: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document) {
@@ -511,12 +558,15 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const field = await createField({
documentId: Number(documentId),
recipientId: Number(recipientId),
userId: user.id,
teamId: team?.id,
type,
pageNumber,
pageX,
pageY,
pageWidth,
pageHeight,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const remappedField = {
@@ -542,13 +592,14 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}),
updateField: authenticatedMiddleware(async (args, user) => {
updateField: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId, fieldId } = args.params;
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document) {
@@ -594,6 +645,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const updatedField = await updateField({
fieldId: Number(fieldId),
userId: user.id,
teamId: team?.id,
documentId: Number(documentId),
recipientId: recipientId ? Number(recipientId) : undefined,
type,
@@ -602,6 +655,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
pageY,
pageWidth,
pageHeight,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const remappedField = {
@@ -627,7 +681,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}),
deleteField: authenticatedMiddleware(async (args, user) => {
deleteField: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId, fieldId } = args.params;
const document = await getDocumentById({
@@ -684,6 +738,9 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const deletedField = await deleteField({
documentId: Number(documentId),
fieldId: Number(fieldId),
userId: user.id,
teamId: team?.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
}).catch(() => null);
if (!deletedField) {

View File

@@ -1,7 +1,7 @@
import type { NextApiRequest } from 'next';
import { getUserByApiToken } from '@documenso/lib/server-only/public-api/get-user-by-token';
import type { User } from '@documenso/prisma/client';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import type { Team, User } from '@documenso/prisma/client';
export const authenticatedMiddleware = <
T extends {
@@ -12,7 +12,7 @@ export const authenticatedMiddleware = <
body: unknown;
},
>(
handler: (args: T, user: User) => Promise<R>,
handler: (args: T, user: User, team?: Team | null) => Promise<R>,
) => {
return async (args: T) => {
try {
@@ -25,9 +25,9 @@ export const authenticatedMiddleware = <
throw new Error('Token was not provided for authenticated middleware');
}
const user = await getUserByApiToken({ token });
const apiToken = await getApiTokenByToken({ token });
return await handler(args, user);
return await handler(args, apiToken.user, apiToken.team);
} catch (_err) {
console.log({ _err });
return {

View File

@@ -25,6 +25,7 @@ export type TDeleteDocumentMutationSchema = typeof ZDeleteDocumentMutationSchema
export const ZSuccessfulDocumentResponseSchema = z.object({
id: z.number(),
userId: z.number(),
teamId: z.number().nullish(),
title: z.string(),
status: z.string(),
documentDataId: z.string(),

View File

@@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
export interface FindDocumentsOptions {
term?: string;

View File

@@ -14,39 +14,45 @@ import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
export type DeleteDocumentOptions = {
id: number;
userId: number;
status: DocumentStatus;
teamId?: number;
};
export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => {
export const deleteDocument = async ({ id, userId, teamId }: DeleteDocumentOptions) => {
// if the document is a draft, hard-delete
if (status === DocumentStatus.DRAFT) {
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
const document = await prisma.document.findUnique({
where: {
id,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
documentMeta: true,
User: true,
},
});
if (!document) {
throw new Error('Document not found');
}
const { status, User: user } = document;
// if the document is pending, send cancellation emails to all recipients
if (status === DocumentStatus.PENDING) {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const document = await prisma.document.findUnique({
where: {
id,
status,
userId,
},
include: {
Recipient: true,
documentMeta: true,
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.Recipient.length > 0) {
await Promise.all(
document.Recipient.map(async (recipient) => {
@@ -81,6 +87,7 @@ export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptio
return await prisma.document.update({
where: {
id,
teamId,
},
data: {
deletedAt: new Date().toISOString(),

View File

@@ -16,9 +16,8 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { getDocumentWhereInput } from './get-document-by-id';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { getDocumentWhereInput } from './get-document-by-id';
export type ResendDocumentOptions = {
documentId: number;

View File

@@ -20,12 +20,14 @@ import {
export type SendDocumentOptions = {
documentId: number;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const sendDocument = async ({
documentId,
userId,
teamId,
requestMetadata,
}: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
@@ -42,20 +44,21 @@ export const sendDocument = async ({
const document = await prisma.document.findUnique({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,

View File

@@ -5,16 +5,36 @@ import type { Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
export type UpdateDocumentOptions = {
documentId: number;
data: Prisma.DocumentUpdateInput;
userId: number;
documentId: number;
teamId?: number;
};
export const updateDocument = async ({ documentId, userId, data }: UpdateDocumentOptions) => {
export const updateDocument = async ({
documentId,
userId,
teamId,
data,
}: UpdateDocumentOptions) => {
return await prisma.document.update({
where: {
id: documentId,
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
data: {
...data,

View File

@@ -7,6 +7,7 @@ import { prisma } from '@documenso/prisma';
export type UpdateTitleOptions = {
userId: number;
teamId?: number;
documentId: number;
title: string;
requestMetadata?: RequestMetadata;
@@ -14,6 +15,7 @@ export type UpdateTitleOptions = {
export const updateTitle = async ({
userId,
teamId,
documentId,
title,
requestMetadata,
@@ -28,20 +30,21 @@ export const updateTitle = async ({
const document = await tx.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
});

View File

@@ -1,8 +1,13 @@
import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client';
import type { FieldType, Team } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type CreateFieldOptions = {
documentId: number;
userId: number;
teamId?: number;
recipientId: number;
type: FieldType;
pageNumber: number;
@@ -10,10 +15,13 @@ export type CreateFieldOptions = {
pageY: number;
pageWidth: number;
pageHeight: number;
requestMetadata?: RequestMetadata;
};
export const createField = async ({
documentId,
userId,
teamId,
recipientId,
type,
pageNumber,
@@ -21,7 +29,62 @@ export const createField = async ({
pageY,
pageWidth,
pageHeight,
requestMetadata,
}: CreateFieldOptions) => {
const document = await prisma.document.findFirst({
select: {
id: true,
},
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
if (!document) {
throw new Error('Document not found');
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
const field = await prisma.field.create({
data: {
documentId,
@@ -35,6 +98,28 @@ export const createField = async ({
customText: '',
inserted: false,
},
include: {
Recipient: true,
},
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_CREATED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',
fieldRecipientId: recipientId,
fieldType: field.type,
},
requestMetadata,
}),
});
return field;

View File

@@ -1,16 +1,89 @@
import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type DeleteFieldOptions = {
fieldId: number;
documentId: number;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const deleteField = async ({ fieldId, documentId }: DeleteFieldOptions) => {
export const deleteField = async ({
fieldId,
userId,
teamId,
documentId,
requestMetadata,
}: DeleteFieldOptions) => {
const field = await prisma.field.delete({
where: {
id: fieldId,
documentId,
Document: {
id: documentId,
...(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,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
},
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_DELETED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',
fieldRecipientId: field.recipientId ?? -1,
fieldType: field.type,
},
requestMetadata,
}),
});
return field;

View File

@@ -1,9 +1,14 @@
import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client';
import type { FieldType, Team } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type UpdateFieldOptions = {
fieldId: number;
documentId: number;
userId: number;
teamId?: number;
recipientId?: number;
type?: FieldType;
pageNumber?: number;
@@ -11,11 +16,14 @@ export type UpdateFieldOptions = {
pageY?: number;
pageWidth?: number;
pageHeight?: number;
requestMetadata?: RequestMetadata;
};
export const updateField = async ({
fieldId,
documentId,
userId,
teamId,
recipientId,
type,
pageNumber,
@@ -23,11 +31,29 @@ export const updateField = async ({
pageY,
pageWidth,
pageHeight,
requestMetadata,
}: UpdateFieldOptions) => {
const field = await prisma.field.update({
where: {
id: fieldId,
documentId,
Document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
},
data: {
recipientId,
@@ -38,6 +64,58 @@ export const updateField = async ({
width: pageWidth,
height: pageHeight,
},
include: {
Recipient: true,
},
});
if (!field) {
throw new Error('Field not found');
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_UPDATED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',
fieldRecipientId: recipientId ?? -1,
fieldType: field.type,
},
requestMetadata,
}),
});
return field;

View File

@@ -2,6 +2,7 @@ import type { Duration } from 'luxon';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
// temporary choice for testing only
import * as timeConstants from '../../constants/time';
@@ -14,14 +15,16 @@ type TimeConstants = typeof timeConstants & {
type CreateApiTokenInput = {
userId: number;
teamId?: number;
tokenName: string;
expirationDate: string | null;
expiresIn: string | null;
};
export const createApiToken = async ({
userId,
teamId,
tokenName,
expirationDate,
expiresIn,
}: CreateApiTokenInput) => {
const apiToken = `api_${alphaid(16)}`;
@@ -29,23 +32,36 @@ export const createApiToken = async ({
const timeConstantsRecords: TimeConstants = timeConstants;
const dbToken = await prisma.apiToken.create({
if (teamId) {
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
role: TeamMemberRole.ADMIN,
},
});
if (!member) {
throw new Error('You do not have permission to create a token for this team');
}
}
const storedToken = await prisma.apiToken.create({
data: {
token: hashedToken,
name: tokenName,
userId,
expires: expirationDate
? DateTime.now().plus(timeConstantsRecords[expirationDate]).toJSDate()
: null,
token: hashedToken,
expires: expiresIn ? DateTime.now().plus(timeConstantsRecords[expiresIn]).toJSDate() : null,
userId: teamId ? null : userId,
teamId,
},
});
if (!dbToken) {
if (!storedToken) {
throw new Error('Failed to create the API token');
}
return {
id: dbToken.id,
id: storedToken.id,
token: apiToken,
};
};

View File

@@ -1,15 +1,32 @@
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
export type DeleteTokenByIdOptions = {
id: number;
userId: number;
teamId?: number;
};
export const deleteTokenById = async ({ id, userId }: DeleteTokenByIdOptions) => {
export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOptions) => {
if (teamId) {
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
role: TeamMemberRole.ADMIN,
},
});
if (!member) {
throw new Error('You do not have permission to delete this token');
}
}
return await prisma.apiToken.delete({
where: {
id,
userId,
userId: teamId ? null : userId,
teamId,
},
});
};

View File

@@ -0,0 +1,36 @@
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
export type GetUserTokensOptions = {
userId: number;
teamId: number;
};
export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => {
const teamMember = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
},
});
if (teamMember?.role !== TeamMemberRole.ADMIN) {
throw new Error('You do not have permission to view tokens for this team');
}
return await prisma.apiToken.findMany({
where: {
teamId,
},
select: {
id: true,
name: true,
algorithm: true,
createdAt: true,
expires: true,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@@ -0,0 +1,41 @@
import { prisma } from '@documenso/prisma';
import { hashString } from '../auth/hash';
export const getApiTokenByToken = async ({ token }: { token: string }) => {
const hashedToken = hashString(token);
const apiToken = await prisma.apiToken.findFirst({
where: {
token: hashedToken,
},
include: {
team: true,
user: true,
},
});
if (!apiToken) {
throw new Error('Invalid token');
}
if (apiToken.expires && apiToken.expires < new Date()) {
throw new Error('Expired token');
}
if (apiToken.team) {
apiToken.user = await prisma.user.findFirst({
where: {
id: apiToken.team.ownerUserId,
},
});
}
const { user } = apiToken;
if (!user) {
throw new Error('Invalid token');
}
return { ...apiToken, user };
};

View File

@@ -1,37 +0,0 @@
import { prisma } from '@documenso/prisma';
import { hashString } from '../auth/hash';
export const getUserByApiToken = async ({ token }: { token: string }) => {
const hashedToken = hashString(token);
const user = await prisma.user.findFirst({
where: {
ApiToken: {
some: {
token: hashedToken,
},
},
},
include: {
ApiToken: true,
},
});
if (!user) {
throw new Error('Invalid token');
}
const retrievedToken = user.ApiToken.find((apiToken) => apiToken.token === hashedToken);
// This should be impossible but we need to satisfy TypeScript
if (!retrievedToken) {
throw new Error('Invalid token');
}
if (retrievedToken.expires && retrievedToken.expires < new Date()) {
throw new Error('Expired token');
}
return user;
};

View File

@@ -1,16 +1,46 @@
import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import { SendStatus } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type DeleteRecipientOptions = {
documentId: number;
recipientId: number;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const deleteRecipient = async ({ documentId, recipientId }: DeleteRecipientOptions) => {
export const deleteRecipient = async ({
documentId,
recipientId,
userId,
teamId,
requestMetadata,
}: DeleteRecipientOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
documentId,
Document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
},
});
@@ -22,11 +52,55 @@ export const deleteRecipient = async ({ documentId, recipientId }: DeleteRecipie
throw new Error('Can not delete a recipient that has already been sent a document');
}
const deletedRecipient = await prisma.recipient.delete({
const user = await prisma.user.findFirstOrThrow({
where: {
id: recipient.id,
id: userId,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
const deletedRecipient = await prisma.$transaction(async (tx) => {
const deleted = await tx.recipient.delete({
where: {
id: recipient.id,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'RECIPIENT_DELETED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
},
requestMetadata,
}),
});
return deleted;
});
return deletedRecipient;
};

View File

@@ -3,11 +3,13 @@ import { prisma } from '@documenso/prisma';
export interface GetRecipientsForDocumentOptions {
documentId: number;
userId: number;
teamId?: number;
}
export const getRecipientsForDocument = async ({
documentId,
userId,
teamId,
}: GetRecipientsForDocumentOptions) => {
const recipients = await prisma.recipient.findMany({
where: {
@@ -18,6 +20,7 @@ export const getRecipientsForDocument = async ({
userId,
},
{
teamId,
team: {
members: {
some: {

View File

@@ -11,6 +11,7 @@ import { SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetRecipientsForDocumentOptions {
userId: number;
teamId?: number;
documentId: number;
recipients: {
id?: number | null;
@@ -23,6 +24,7 @@ export interface SetRecipientsForDocumentOptions {
export const setRecipientsForDocument = async ({
userId,
teamId,
documentId,
recipients,
requestMetadata,
@@ -30,20 +32,21 @@ export const setRecipientsForDocument = 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,
}),
},
});
@@ -106,7 +109,7 @@ export const setRecipientsForDocument = async ({
});
const persistedRecipients = await prisma.$transaction(async (tx) => {
await Promise.all(
return await Promise.all(
linkedRecipients.map(async (recipient) => {
const upsertedRecipient = await tx.recipient.upsert({
where: {

View File

@@ -1,5 +1,9 @@
import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client';
import type { RecipientRole, Team } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
export type UpdateRecipientOptions = {
documentId: number;
@@ -7,6 +11,9 @@ export type UpdateRecipientOptions = {
email?: string;
name?: string;
role?: RecipientRole;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const updateRecipient = async ({
@@ -15,11 +22,52 @@ export const updateRecipient = async ({
email,
name,
role,
userId,
teamId,
requestMetadata,
}: UpdateRecipientOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
documentId,
Document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
@@ -27,15 +75,43 @@ export const updateRecipient = async ({
throw new Error('Recipient not found');
}
const updatedRecipient = await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
email: email?.toLowerCase() ?? recipient.email,
name: name ?? recipient.name,
role: role ?? recipient.role,
},
const updatedRecipient = await prisma.$transaction(async (tx) => {
const persisted = await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
email: email?.toLowerCase() ?? recipient.email,
name: name ?? recipient.name,
role: role ?? recipient.role,
},
});
const changes = diffRecipientChanges(recipient, persisted);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: documentId,
user: {
id: team?.id ?? user.id,
name: team?.name ?? user.name,
email: team ? '' : user.email,
},
requestMetadata,
data: {
changes,
recipientId,
recipientEmail: persisted.email,
recipientName: persisted.name,
recipientRole: persisted.role,
},
}),
});
return persisted;
}
});
return updatedRecipient;

View File

@@ -5,6 +5,7 @@ import type { RecipientRole } from '@documenso/prisma/client';
export type CreateDocumentFromTemplateOptions = {
templateId: number;
userId: number;
teamId?: number;
recipients?: {
name?: string;
email: string;
@@ -15,25 +16,27 @@ export type CreateDocumentFromTemplateOptions = {
export const createDocumentFromTemplate = async ({
templateId,
userId,
teamId,
recipients,
}: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
},
],
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "ApiToken" ADD COLUMN "teamId" INTEGER,
ALTER COLUMN "userId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -19,19 +19,19 @@ enum Role {
}
model User {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
name String?
customerId String? @unique
email String @unique
customerId String? @unique
email String @unique
emailVerified DateTime?
password String?
source String?
signature String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
lastSignedIn DateTime @default(now())
roles Role[] @default([USER])
identityProvider IdentityProvider @default(DOCUMENSO)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
lastSignedIn DateTime @default(now())
roles Role[] @default([USER])
identityProvider IdentityProvider @default(DOCUMENSO)
accounts Account[]
sessions Session[]
Document Document[]
@@ -41,12 +41,12 @@ model User {
ownedPendingTeams TeamPending[]
teamMembers TeamMember[]
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
VerificationToken VerificationToken[]
ApiToken ApiToken[]
Template Template[]
securityAuditLogs UserSecurityAuditLog[]
Template Template[]
securityAuditLogs UserSecurityAuditLog[]
@@index([email])
}
@@ -105,8 +105,10 @@ model ApiToken {
algorithm ApiTokenAlgorithm @default(SHA512)
expires DateTime?
createdAt DateTime @default(now())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
teamId Int?
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
}
enum SubscriptionStatus {
@@ -225,15 +227,15 @@ model DocumentData {
}
model DocumentMeta {
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
}
enum ReadStatus {
@@ -372,6 +374,7 @@ model Team {
document Document[]
templates Template[]
ApiToken ApiToken[]
}
model TeamPending {

View File

@@ -46,12 +46,13 @@ export const apiTokenRouter = router({
.input(ZCreateTokenMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { tokenName, expirationDate } = input;
const { tokenName, teamId, expirationDate } = input;
return await createApiToken({
userId: ctx.user.id,
teamId,
tokenName,
expirationDate,
expiresIn: expirationDate,
});
} catch (e) {
throw new TRPCError({
@@ -65,10 +66,11 @@ export const apiTokenRouter = router({
.input(ZDeleteTokenByIdMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { id } = input;
const { id, teamId } = input;
return await deleteTokenById({
id,
teamId,
userId: ctx.user.id,
});
} catch (e) {

View File

@@ -7,6 +7,7 @@ export const ZGetApiTokenByIdQuerySchema = z.object({
export type TGetApiTokenByIdQuerySchema = z.infer<typeof ZGetApiTokenByIdQuerySchema>;
export const ZCreateTokenMutationSchema = z.object({
teamId: z.number().optional(),
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }),
expirationDate: z.string().nullable(),
});
@@ -15,6 +16,7 @@ export type TCreateTokenMutationSchema = z.infer<typeof ZCreateTokenMutationSche
export const ZDeleteTokenByIdMutationSchema = z.object({
id: z.number().min(1),
teamId: z.number().optional(),
});
export type TDeleteTokenByIdMutationSchema = z.infer<typeof ZDeleteTokenByIdMutationSchema>;

View File

@@ -107,11 +107,11 @@ export const documentRouter = router({
.input(ZDeleteDraftDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { id, status } = input;
const { id } = input;
const userId = ctx.user.id;
return await deleteDocument({ id, userId, status });
return await deleteDocument({ id, userId });
} catch (err) {
console.error(err);

View File

@@ -1,7 +1,7 @@
import { z } from 'zod';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
export const ZGetDocumentByIdQuerySchema = z.object({
id: z.number().min(1),
@@ -102,7 +102,6 @@ export type TSendDocumentMutationSchema = z.infer<typeof ZSendDocumentMutationSc
export const ZDeleteDraftDocumentMutationSchema = z.object({
id: z.number().min(1),
status: z.nativeEnum(DocumentStatus),
});
export type TDeleteDraftDocumentMutationSchema = z.infer<typeof ZDeleteDraftDocumentMutationSchema>;