Compare commits

...

9 Commits

Author SHA1 Message Date
David Nguyen e1464ac2d3 fix: wip 2025-08-22 22:30:02 +10:00
David Nguyen e7e2aa9bd8 fix: migrate template metadata 2025-08-21 17:56:04 +10:00
Catalin Pit 231ef9c27e chore: add support option (#1853) 2025-08-19 20:59:03 +10:00
Catalin Pit 6f35342a83 feat: reset user 2fa from admin panel (#1943) 2025-08-19 13:09:05 +10:00
David Nguyen a51110d276 fix: prevent document unsigning on edit (#1963) 2025-08-18 13:48:51 +10:00
David Nguyen 7f81231467 fix: template e2e tests (#1969) 2025-08-18 12:42:36 +10:00
Lucas Smith 439262fd02 v1.12.2-rc.4 2025-08-16 19:16:29 +10:00
Lucas Smith 93a184355b chore: add translations (#1955) 2025-08-16 19:10:21 +10:00
David Nguyen 1dea0b8fab add dummy teamid (#1968) 2025-08-16 19:09:21 +10:00
57 changed files with 3118 additions and 1391 deletions
+2
View File
@@ -136,3 +136,5 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# OPTIONAL: The file to save the logger output to. Will disable stdout if provided.
NEXT_PRIVATE_LOGGER_FILE_PATH=
# [[PLAIN SUPPORT]]
NEXT_PRIVATE_PLAIN_API_KEY=
@@ -0,0 +1,159 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminUserResetTwoFactorDialogProps = {
className?: string;
user: User;
};
export const AdminUserResetTwoFactorDialog = ({
className,
user,
}: AdminUserResetTwoFactorDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const [email, setEmail] = useState('');
const [open, setOpen] = useState(false);
const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } =
trpc.admin.user.resetTwoFactor.useMutation();
const onResetTwoFactor = async () => {
try {
await resetTwoFactor({
userId: user.id,
});
toast({
title: _(msg`2FA Reset`),
description: _(msg`The user's two factor authentication has been reset successfully.`),
duration: 5000,
});
await revalidate();
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(AppErrorCode.NOT_FOUND, () => msg`User not found.`)
.with(
AppErrorCode.UNAUTHORIZED,
() => msg`You are not authorized to reset two factor authentcation for this user.`,
)
.otherwise(
() => msg`An error occurred while resetting two factor authentication for the user.`,
);
toast({
title: _(msg`Error`),
description: _(errorMessage),
variant: 'destructive',
duration: 7500,
});
}
};
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
if (!newOpen) {
setEmail('');
}
};
return (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="neutral"
>
<div>
<AlertTitle>Reset Two Factor Authentication</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
Reset the users two factor authentication. This action is irreversible and will
disable two factor authentication for the user.
</Trans>
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="destructive">
<Trans>Reset 2FA</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Reset Two Factor Authentication</Trans>
</DialogTitle>
</DialogHeader>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>
This action is irreversible. Please ensure you have informed the user before
proceeding.
</Trans>
</AlertDescription>
</Alert>
<div>
<DialogDescription>
<Trans>
To confirm, please enter the accounts email address <br />({user.email}).
</Trans>
</DialogDescription>
<Input
className="mt-2"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<DialogFooter>
<Button
variant="destructive"
disabled={email !== user.email}
onClick={onResetTwoFactor}
loading={isResettingTwoFactor}
>
<Trans>Reset 2FA</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Alert>
</div>
);
};
@@ -3,7 +3,7 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
import type { DocumentMeta, Recipient, Signature } from '@prisma/client';
import { type DocumentData, type Field, FieldType } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon';
@@ -48,7 +48,7 @@ export type EmbedDirectTemplateClientPageProps = {
documentData: DocumentData;
recipient: Recipient;
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
metadata?: DocumentMeta | null;
hidePoweredBy?: boolean;
allowWhiteLabelling?: boolean;
};
@@ -1,4 +1,4 @@
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
import type { DocumentMeta } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import { match } from 'ts-pattern';
@@ -33,7 +33,7 @@ import { DocumentSigningTextField } from '~/components/general/document-signing/
export type EmbedDocumentFieldsProps = {
fields: Field[];
metadata?: Pick<
DocumentMeta | TemplateMeta,
DocumentMeta,
| 'timezone'
| 'dateFormat'
| 'typedSignatureEnabled'
@@ -3,7 +3,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
import type { DocumentMeta } from '@prisma/client';
import {
type DocumentData,
type Field,
@@ -50,7 +50,7 @@ export type EmbedSignDocumentClientPageProps = {
recipient: RecipientWithFields;
fields: Field[];
completedFields: DocumentField[];
metadata?: DocumentMeta | TemplateMeta | null;
metadata?: DocumentMeta | null;
isCompleted?: boolean;
hidePoweredBy?: boolean;
allowWhitelabelling?: boolean;
@@ -0,0 +1,138 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZSupportTicketSchema = z.object({
subject: z.string().min(3, 'Subject is required'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
type TSupportTicket = z.infer<typeof ZSupportTicketSchema>;
export type SupportTicketFormProps = {
organisationId: string;
teamId?: string | null;
onSuccess?: () => void;
onClose?: () => void;
};
export const SupportTicketForm = ({
organisationId,
teamId,
onSuccess,
onClose,
}: SupportTicketFormProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { mutateAsync: submitSupportTicket, isPending } =
trpc.profile.submitSupportTicket.useMutation();
const form = useForm<TSupportTicket>({
resolver: zodResolver(ZSupportTicketSchema),
defaultValues: {
subject: '',
message: '',
},
});
const isLoading = form.formState.isLoading || isPending;
const onSubmit = async (data: TSupportTicket) => {
const { subject, message } = data;
try {
await submitSupportTicket({
subject,
message,
organisationId,
teamId,
});
toast({
title: t`Support ticket created`,
description: t`Your support request has been submitted. We'll get back to you soon!`,
});
if (onSuccess) {
onSuccess();
}
form.reset();
} catch (err) {
toast({
title: t`Failed to create support ticket`,
description: t`An error occurred. Please try again later.`,
variant: 'destructive',
});
}
};
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={isLoading} className="flex flex-col gap-4">
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Subject</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Message</Trans>
</FormLabel>
<FormControl>
<Textarea rows={5} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-2 flex flex-row gap-2">
<Button type="submit" size="sm" loading={isLoading}>
<Trans>Submit</Trans>
</Button>
{onClose && (
<Button variant="outline" size="sm" type="button" onClick={onClose}>
<Trans>Close</Trans>
</Button>
)}
</div>
</fieldset>
</form>
</Form>
</>
);
};
@@ -321,6 +321,19 @@ export const OrgMenuSwitcher = () => {
<Trans>Language</Trans>
</DropdownMenuItem>
{currentOrganisation && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link
to={{
pathname: `/o/${currentOrganisation.url}/support`,
search: currentTeam ? `?team=${currentTeam.id}` : '',
}}
>
<Trans>Support</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-muted-foreground hover:!text-muted-foreground px-4 py-2"
onSelect={async () => authClient.signOut()}
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { Document, Role, Subscription } from '@prisma/client';
import type { Role, Subscription } from '@prisma/client';
import { Edit, Loader } from 'lucide-react';
import { Link } from 'react-router';
@@ -20,7 +20,7 @@ type UserData = {
email: string;
roles: Role[];
subscriptions?: SubscriptionLite[] | null;
documents: DocumentLite[];
documentCount: number;
};
type SubscriptionLite = Pick<
@@ -28,8 +28,6 @@ type SubscriptionLite = Pick<
'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd'
>;
type DocumentLite = Pick<Document, 'id'>;
type AdminDashboardUsersTableProps = {
users: UserData[];
totalPages: number;
@@ -74,10 +72,7 @@ export const AdminDashboardUsersTable = ({
},
{
header: _(msg`Documents`),
accessorKey: 'documents',
cell: ({ row }) => {
return <div>{row.original.documents?.length}</div>;
},
accessorKey: 'documentCount',
},
{
header: '',
@@ -27,6 +27,7 @@ import { AdminOrganisationCreateDialog } from '~/components/dialogs/admin-organi
import { AdminUserDeleteDialog } from '~/components/dialogs/admin-user-delete-dialog';
import { AdminUserDisableDialog } from '~/components/dialogs/admin-user-disable-dialog';
import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-dialog';
import { AdminUserResetTwoFactorDialog } from '~/components/dialogs/admin-user-reset-two-factor-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
@@ -219,10 +220,11 @@ const AdminUserPage = ({ user }: { user: User }) => {
/>
</div>
<div className="mt-16 flex flex-col items-center gap-4">
{user && <AdminUserDeleteDialog user={user} />}
<div className="mt-16 flex flex-col gap-4">
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
{user && <AdminUserDeleteDialog user={user} />}
</div>
</div>
);
@@ -0,0 +1,125 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { BookIcon, HelpCircleIcon, Link2Icon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { Button } from '@documenso/ui/primitives/button';
import { SupportTicketForm } from '~/components/forms/support-ticket-form';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Support');
}
export default function SupportPage() {
const [showForm, setShowForm] = useState(false);
const { user } = useSession();
const organisation = useCurrentOrganisation();
const [searchParams] = useSearchParams();
const teamId = searchParams.get('team');
const subscriptionStatus = organisation.subscription?.status;
const handleSuccess = () => {
setShowForm(false);
};
const handleCloseForm = () => {
setShowForm(false);
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<div className="mb-8">
<h1 className="flex flex-row items-center gap-2 text-3xl font-bold">
<HelpCircleIcon className="text-muted-foreground h-8 w-8" />
<Trans>Support</Trans>
</h1>
<p className="text-muted-foreground mt-2">
<Trans>Your current plan includes the following support channels:</Trans>
</p>
<div className="mt-6 flex flex-col gap-4">
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<BookIcon className="text-muted-foreground h-5 w-5" />
<Link
to="https://docs.documenso.com"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
<Trans>Documentation</Trans>
</Link>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>Read our documentation to get started with Documenso.</Trans>
</p>
</div>
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Link2Icon className="text-muted-foreground h-5 w-5" />
<Link
to="https://documen.so/discord"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
<Trans>Discord</Trans>
</Link>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>
Join our community on{' '}
<Link
to="https://documen.so/discord"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
Discord
</Link>{' '}
for community support and discussion.
</Trans>
</p>
</div>
{organisation && IS_BILLING_ENABLED() && subscriptionStatus && (
<>
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Link2Icon className="text-muted-foreground h-5 w-5" />
<Trans>Contact us</Trans>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>We'll get back to you as soon as possible via email.</Trans>
</p>
<div className="mt-4">
{!showForm ? (
<Button variant="outline" size="sm" onClick={() => setShowForm(true)}>
<Trans>Create a support ticket</Trans>
</Button>
) : (
<SupportTicketForm
organisationId={organisation.id}
teamId={teamId}
onSuccess={handleSuccess}
onClose={handleCloseForm}
/>
)}
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -101,5 +101,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.12.2-rc.3"
"version": "1.12.2-rc.4"
}
+53 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.12.2-rc.3",
"version": "1.12.2-rc.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.12.2-rc.3",
"version": "1.12.2-rc.4",
"workspaces": [
"apps/*",
"packages/*"
@@ -89,7 +89,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.12.2-rc.3",
"version": "1.12.2-rc.4",
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",
@@ -3522,6 +3522,15 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@graphql-typed-document-node/core": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
"integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
"license": "MIT",
"peerDependencies": {
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.3.tgz",
@@ -11826,6 +11835,20 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@team-plain/typescript-sdk": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/@team-plain/typescript-sdk/-/typescript-sdk-5.9.0.tgz",
"integrity": "sha512-AHSXyt1kDt74m9YKZBCRCd6cQjB8QjUNr9cehtR2QHzZ/8yXJPzawPJDqOQ3ms5KvwuYrBx2qT3e6C/zrQ5UtA==",
"license": "MIT",
"dependencies": {
"@graphql-typed-document-node/core": "^3.2.0",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"graphql": "^16.6.0",
"lodash.get": "^4.4.2",
"zod": "3.22.4"
}
},
"node_modules/@theguild/remark-mermaid": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@theguild/remark-mermaid/-/remark-mermaid-0.0.5.tgz",
@@ -13235,7 +13258,6 @@
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -13248,6 +13270,23 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@@ -18771,7 +18810,6 @@
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"dev": true,
"funding": [
{
"type": "github",
@@ -19847,6 +19885,15 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"license": "MIT"
},
"node_modules/graphql": {
"version": "16.11.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz",
"integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==",
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
@@ -22329,7 +22376,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
@@ -30570,7 +30616,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -36583,6 +36628,7 @@
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@team-plain/typescript-sdk": "^5.9.0",
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.12.2-rc.3",
"version": "1.12.2-rc.4",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
+6
View File
@@ -554,6 +554,12 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
status: 200,
body: {
...template,
templateMeta: template.templateMeta
? {
...template.templateMeta,
templateId: template.id,
}
: null,
Field: template.fields.map((field) => ({
...field,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : null,
@@ -144,10 +144,11 @@ test('[TEMPLATES]: use template', async ({ page }) => {
await page.getByRole('button', { name: 'Use Template' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
// Get input with Email label placeholder.
await page.getByLabel('Email').click();
await page.getByLabel('Email').fill(teamMemberUser.email);
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
+1
View File
@@ -33,6 +33,7 @@
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@team-plain/typescript-sdk": "^5.9.0",
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",
+7
View File
@@ -0,0 +1,7 @@
import { PlainClient } from '@team-plain/typescript-sdk';
import { env } from '@documenso/lib/utils/env';
export const plainClient = new PlainClient({
apiKey: env('NEXT_PRIVATE_PLAIN_API_KEY') ?? '',
});
@@ -1,4 +1,4 @@
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
import type { DocumentMeta, DocumentVisibility } from '@prisma/client';
import {
DocumentSource,
RecipientRole,
@@ -46,7 +46,7 @@ export type CreateDocumentOptions = {
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
meta?: Partial<Omit<DocumentMeta, 'id' | 'templateId'>>;
requestMetadata: ApiRequestMetadata;
};
@@ -1,39 +1,32 @@
import type { Prisma } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentVisibility } from '../../types/document-visibility';
import { getTeamById } from '../team/get-team';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type GetDocumentByIdOptions = {
documentId: number;
userId: number;
teamId: number;
folderId?: string;
};
export const getDocumentById = async ({
documentId,
userId,
teamId,
folderId,
}: GetDocumentByIdOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumentByIdOptions) => {
const { documentWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
id: documentId,
},
validatedUserId: userId,
unvalidatedTeamId: teamId,
});
const document = await prisma.document.findFirst({
where: {
...documentWhereInput,
folderId,
},
const envelope = await prisma.envelope.findFirst({
where: documentWhereInput,
include: {
documentData: true,
documents: {
include: {
documentData: true,
},
},
documentMeta: true,
user: {
select: {
@@ -56,7 +49,7 @@ export const getDocumentById = async ({
},
});
if (!document) {
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document could not be found',
});
@@ -64,93 +57,3 @@ export const getDocumentById = async ({
return document;
};
export type GetDocumentWhereInputOptions = {
documentId: number;
userId: number;
teamId: number;
};
/**
* Generate the where input for a given Prisma document query.
*
* This will return a query that allows a user to get a document if they have valid access to it.
*/
export const getDocumentWhereInput = async ({
documentId,
userId,
teamId,
}: GetDocumentWhereInputOptions) => {
const team = await getTeamById({ teamId, userId });
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const teamVisibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
])
.with(TeamMemberRole.MANAGER, () => [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
])
.otherwise(() => [DocumentVisibility.EVERYONE]);
const documentOrInput: Prisma.DocumentWhereInput[] = [
// Allow access if they own the document.
{
userId,
},
// Or, if they belong to the team that the document is associated with.
{
visibility: {
in: teamVisibilityFilters,
},
teamId,
},
// Or, if they are a recipient of the document.
{
status: {
not: DocumentStatus.DRAFT,
},
recipients: {
some: {
email: user.email,
},
},
},
];
// Allow access to documents sent to or from the team email.
if (team.teamEmail) {
documentOrInput.push(
{
recipients: {
some: {
email: team.teamEmail.email,
},
},
},
{
user: {
email: team.teamEmail.email,
},
},
);
}
const documentWhereInput: Prisma.DocumentWhereUniqueInput = {
id: documentId,
OR: documentOrInput,
};
return {
documentWhereInput,
team,
};
};
@@ -1,13 +0,0 @@
import { prisma } from '@documenso/prisma';
export interface GetDocumentMetaByDocumentIdOptions {
id: number;
}
export const getDocumentMetaByDocumentId = async ({ id }: GetDocumentMetaByDocumentIdOptions) => {
return await prisma.documentMeta.findFirstOrThrow({
where: {
documentId: id,
},
});
};
@@ -0,0 +1,157 @@
import type { Prisma } from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentVisibility } from '../../types/document-visibility';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildEnvelopeIdQuery } from '../../utils/envelope';
import { getTeamById } from '../team/get-team';
export type GetEnvelopeByIdOptions = {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
};
export const getEnvelopeById = async ({ id, userId, teamId }: GetEnvelopeByIdOptions) => {
const { documentWhereInput } = await getEnvelopeWhereInput({
id,
validatedUserId: userId,
unvalidatedTeamId: teamId,
});
const document = await prisma.envelope.findFirst({
where: documentWhereInput,
include: {
documents: {
include: {
documentData: true,
},
},
documentMeta: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
select: {
email: true,
},
},
team: {
select: {
id: true,
url: true,
},
},
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document could not be found',
});
}
return document;
};
export type GetEnvelopeWhereInputOptions = {
id: EnvelopeIdOptions;
/**
* The user ID who has been authenticated.
*/
validatedUserId: number;
/**
* The unknown teamId from the request.
*/
unvalidatedTeamId: number;
};
/**
* Generate the where input for a given Prisma envelope query.
*
* This will return a query that allows a user to get a document if they have valid access to it.
*/
export const getEnvelopeWhereInput = async ({
id,
validatedUserId,
unvalidatedTeamId,
}: GetEnvelopeWhereInputOptions) => {
const team = await getTeamById({ teamId: unvalidatedTeamId, userId: validatedUserId });
const teamVisibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
])
.with(TeamMemberRole.MANAGER, () => [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
])
.otherwise(() => [DocumentVisibility.EVERYONE]);
const documentOrInput: Prisma.EnvelopeWhereInput[] = [
// Allow access if they own the document.
{
userId: validatedUserId,
},
// Or, if they belong to the team that the document is associated with.
{
visibility: {
in: teamVisibilityFilters,
},
teamId: team.id,
},
// Or, if they are a recipient of the document.
// ????????????? should recipients be able to do X?
// {
// status: {
// not: DocumentStatus.DRAFT,
// },
// recipients: {
// some: {
// email: user.email,
// },
// },
// },
];
// Allow access to documents sent to or from the team email.
if (team.teamEmail) {
documentOrInput.push(
{
recipients: {
some: {
email: team.teamEmail.email,
},
},
},
{
user: {
email: team.teamEmail.email,
},
},
);
}
const documentWhereInput: Prisma.EnvelopeWhereUniqueInput = {
...buildEnvelopeIdQuery(id),
OR: documentOrInput,
};
return {
documentWhereInput,
team,
};
};
@@ -134,6 +134,9 @@ export const setDocumentRecipients = async ({
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
const canPersistedRecipientBeModified =
existing && canRecipientBeModified(existing, document.fields);
if (
existing &&
hasRecipientBeenChanged(existing, recipient) &&
@@ -147,6 +150,7 @@ export const setDocumentRecipients = async ({
return {
...recipient,
_persisted: existing,
canPersistedRecipientBeModified,
};
});
@@ -162,6 +166,13 @@ export const setDocumentRecipients = async ({
});
}
if (recipient._persisted && !recipient.canPersistedRecipientBeModified) {
return {
...recipient._persisted,
clientId: recipient.clientId,
};
}
const upsertedRecipient = await tx.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
@@ -1,7 +1,5 @@
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
@@ -104,10 +102,7 @@ export const updateDocumentRecipients = async ({
});
}
if (
hasRecipientBeenChanged(originalRecipient, recipient) &&
!canRecipientBeModified(originalRecipient, document.fields)
) {
if (!canRecipientBeModified(originalRecipient, document.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify a recipient who has already interacted with the document',
});
@@ -203,9 +198,6 @@ export const updateDocumentRecipients = async ({
};
};
/**
* If you change this you MUST update the `hasRecipientBeenChanged` function.
*/
type RecipientData = {
id: number;
email?: string;
@@ -215,19 +207,3 @@ type RecipientData = {
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};
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 ||
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
);
};
@@ -1,4 +1,4 @@
import type { DocumentVisibility, Template, TemplateMeta } from '@prisma/client';
import type { DocumentMeta, DocumentVisibility, Template } from '@prisma/client';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
@@ -26,7 +26,7 @@ export type CreateTemplateOptions = {
publicDescription?: string;
type?: Template['type'];
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
meta?: Partial<Omit<DocumentMeta, 'id' | 'templateId'>>;
};
export const ZCreateTemplateResponseSchema = TemplateSchema;
@@ -1,4 +1,4 @@
import type { DocumentVisibility, Template, TemplateMeta } from '@prisma/client';
import type { DocumentMeta, DocumentVisibility, Template } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@@ -22,7 +22,7 @@ export type UpdateTemplateOptions = {
type?: Template['type'];
useLegacyFieldInsertion?: boolean;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
meta?: Partial<Omit<DocumentMeta, 'id' | 'templateId'>>;
};
export const updateTemplate = async ({
+3 -2
View File
@@ -1,4 +1,4 @@
import { DocumentStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@@ -25,9 +25,10 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
const serviceAccount = await deletedAccountServiceAccount();
// TODO: Send out cancellations for all pending docs
await prisma.document.updateMany({
await prisma.envelope.updateMany({
where: {
userId: user.id,
type: EnvelopeType.DOCUMENT,
status: {
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
},
+16 -5
View File
@@ -1,4 +1,4 @@
import { Prisma } from '@prisma/client';
import { EnvelopeType, Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@@ -34,12 +34,20 @@ export const findUsers = async ({
const [users, count] = await Promise.all([
prisma.user.findMany({
include: {
documents: {
select: {
_count: {
select: {
id: true,
envelopes: {
where: {
type: EnvelopeType.DOCUMENT,
},
},
},
},
id: true,
name: true,
email: true,
roles: true,
},
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
@@ -51,7 +59,10 @@ export const findUsers = async ({
]);
return {
users,
users: users.map((user) => ({
...user,
documentCount: user._count.envelopes,
})),
totalPages: Math.ceil(count / perPage),
};
};
@@ -0,0 +1,72 @@
import { plainClient } from '@documenso/lib/plain/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { getTeamById } from '../team/get-team';
type SubmitSupportTicketOptions = {
subject: string;
message: string;
userId: number;
organisationId: string;
teamId?: number | null;
};
export const submitSupportTicket = async ({
subject,
message,
userId,
organisationId,
teamId,
}: SubmitSupportTicketOptions) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
const team = teamId
? await getTeamById({
userId,
teamId,
})
: null;
const customMessage = `
Organisation: ${organisation.name} (${organisation.id})
Team: ${team ? `${team.name} (${team.id})` : 'No team provided'}
${message}`;
const res = await plainClient.createThread({
title: subject,
customerIdentifier: { emailAddress: user.email },
components: [{ componentText: { text: customMessage } }],
});
if (res.error) {
throw new Error(res.error.message);
}
return res;
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+17 -8
View File
@@ -1,11 +1,11 @@
import type { z } from 'zod';
import { z } from 'zod';
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import { DocumentSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentSchema';
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { LegacyDocumentSchema } from '@documenso/prisma/types/document-legacy-schema';
import { ZFieldSchema } from './field';
import { ZRecipientLiteSchema } from './recipient';
@@ -15,7 +15,7 @@ import { ZRecipientLiteSchema } from './recipient';
*
* Mainly used for returning a single document from the API.
*/
export const ZDocumentSchema = DocumentSchema.pick({
export const ZDocumentSchema = LegacyDocumentSchema.pick({
visibility: true,
status: true,
source: true,
@@ -31,9 +31,12 @@ export const ZDocumentSchema = DocumentSchema.pick({
completedAt: true,
deletedAt: true,
teamId: true,
templateId: true,
folderId: true,
}).extend({
// Which "Template" the document was created from. Legacy field for backwards compatibility.
// The actual field is now called `createdFromDocumentId`.
templateId: z.number().optional(),
// Todo: Maybe we want to alter this a bit since this returns a lot of data.
documentData: DocumentDataSchema.pick({
type: true,
@@ -82,7 +85,7 @@ export type TDocument = z.infer<typeof ZDocumentSchema>;
/**
* A lite version of the document response schema without relations.
*/
export const ZDocumentLiteSchema = DocumentSchema.pick({
export const ZDocumentLiteSchema = LegacyDocumentSchema.pick({
visibility: true,
status: true,
source: true,
@@ -98,9 +101,12 @@ export const ZDocumentLiteSchema = DocumentSchema.pick({
completedAt: true,
deletedAt: true,
teamId: true,
templateId: true,
folderId: true,
useLegacyFieldInsertion: true,
}).extend({
// Which "Template" the document was created from. Legacy field for backwards compatibility.
// The actual field is now called `createdFromDocumentId`.
templateId: z.number().optional(),
});
export type TDocumentLite = z.infer<typeof ZDocumentLiteSchema>;
@@ -108,7 +114,7 @@ export type TDocumentLite = z.infer<typeof ZDocumentLiteSchema>;
/**
* A version of the document response schema when returning multiple documents at once from a single API endpoint.
*/
export const ZDocumentManySchema = DocumentSchema.pick({
export const ZDocumentManySchema = LegacyDocumentSchema.pick({
visibility: true,
status: true,
source: true,
@@ -124,10 +130,13 @@ export const ZDocumentManySchema = DocumentSchema.pick({
completedAt: true,
deletedAt: true,
teamId: true,
templateId: true,
folderId: true,
useLegacyFieldInsertion: true,
}).extend({
// Which "Template" the document was created from. Legacy field for backwards compatibility.
// The actual field is now called `createdFromDocumentId`.
templateId: z.number().optional(),
user: UserSchema.pick({
id: true,
name: true,
+4 -2
View File
@@ -18,8 +18,6 @@ export const ZFieldSchema = FieldSchema.pick({
type: true,
id: true,
secondaryId: true,
documentId: true,
templateId: true,
recipientId: true,
page: true,
positionX: true,
@@ -29,6 +27,10 @@ export const ZFieldSchema = FieldSchema.pick({
customText: true,
inserted: true,
fieldMeta: true,
}).extend({
// Todo: Decide whether to make these two IDs backwards compatible.
documentId: z.number().optional(),
templateId: z.number().optional(),
});
export const ZFieldPageNumberSchema = z
+14 -6
View File
@@ -1,3 +1,5 @@
import { z } from 'zod';
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
@@ -15,8 +17,6 @@ export const ZRecipientSchema = RecipientSchema.pick({
signingStatus: true,
sendStatus: true,
id: true,
documentId: true,
templateId: true,
email: true,
name: true,
token: true,
@@ -28,6 +28,10 @@ export const ZRecipientSchema = RecipientSchema.pick({
rejectionReason: true,
}).extend({
fields: ZFieldSchema.array(),
// Todo: Decide whether to make these two IDs backwards compatible.
documentId: z.number().optional(),
templateId: z.number().optional(),
});
/**
@@ -39,8 +43,6 @@ export const ZRecipientLiteSchema = RecipientSchema.pick({
signingStatus: true,
sendStatus: true,
id: true,
documentId: true,
templateId: true,
email: true,
name: true,
token: true,
@@ -50,6 +52,10 @@ export const ZRecipientLiteSchema = RecipientSchema.pick({
authOptions: true,
signingOrder: true,
rejectionReason: true,
}).extend({
// Todo: Decide whether to make these two IDs backwards compatible.
documentId: z.number().optional(),
templateId: z.number().optional(),
});
/**
@@ -61,8 +67,6 @@ export const ZRecipientManySchema = RecipientSchema.pick({
signingStatus: true,
sendStatus: true,
id: true,
documentId: true,
templateId: true,
email: true,
name: true,
token: true,
@@ -83,4 +87,8 @@ export const ZRecipientManySchema = RecipientSchema.pick({
id: true,
url: true,
}).nullable(),
// Todo: Decide whether to make these two IDs backwards compatible.
documentId: z.number().optional(),
templateId: z.number().optional(),
});
+11 -7
View File
@@ -1,12 +1,12 @@
import type { z } from 'zod';
import { z } from 'zod';
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateDirectLinkSchema';
import { TemplateMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateMetaSchema';
import { TemplateSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { TemplateSchema } from '@documenso/prisma/types/template-legacy-schema';
import { ZFieldSchema } from './field';
import { ZRecipientLiteSchema } from './recipient';
@@ -39,7 +39,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
data: true,
initialData: true,
}),
templateMeta: TemplateMetaSchema.pick({
templateMeta: DocumentMetaSchema.pick({
id: true,
subject: true,
message: true,
@@ -51,13 +51,17 @@ export const ZTemplateSchema = TemplateSchema.pick({
drawSignatureEnabled: true,
allowDictateNextSigner: true,
distributionMethod: true,
templateId: true,
redirectUrl: true,
language: true,
emailSettings: true,
emailId: true,
emailReplyTo: true,
}).nullable(),
})
.extend({
// Legacy field for backwards compatibility. Needs to refer to the Envelope `secondaryTemplateId`.
templateId: z.number(),
})
.nullable(),
directLink: TemplateDirectLinkSchema.nullable(),
user: UserSchema.pick({
id: true,
@@ -129,7 +133,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
}).nullable(),
fields: ZFieldSchema.array(),
recipients: ZRecipientLiteSchema.array(),
templateMeta: TemplateMetaSchema.pick({
templateMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
}).nullable(),
+4 -9
View File
@@ -1,15 +1,10 @@
import type {
Document,
DocumentMeta,
OrganisationGlobalSettings,
TemplateMeta,
} from '@prisma/client';
import type { DocumentMeta, Envelope, OrganisationGlobalSettings } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder, DocumentStatus } from '@prisma/client';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones';
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
export const isDocumentCompleted = (document: Pick<Document, 'status'> | DocumentStatus) => {
export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | DocumentStatus) => {
const status = typeof document === 'string' ? document : document.status;
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
@@ -29,7 +24,7 @@ export const isDocumentCompleted = (document: Pick<Document, 'status'> | Documen
*/
export const extractDerivedDocumentMeta = (
settings: Omit<OrganisationGlobalSettings, 'id'>,
overrideMeta: Partial<DocumentMeta | TemplateMeta> | undefined | null,
overrideMeta: Partial<DocumentMeta> | undefined | null,
) => {
const meta = overrideMeta ?? {};
@@ -58,5 +53,5 @@ export const extractDerivedDocumentMeta = (
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
emailSettings:
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
} satisfies Omit<DocumentMeta, 'id' | 'documentId'>;
} satisfies Omit<DocumentMeta, 'id' | 'envelopeId'>;
};
+92
View File
@@ -0,0 +1,92 @@
import { EnvelopeType } from '@prisma/client';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError, AppErrorCode } from '../errors/app-error';
const envelopeDocumentPrefixId = 'document';
const envelopeTemplatePrefixId = 'template';
const envelopePrefixId = 'envelope';
const ZDocumentIdSchema = z.string().regex(/^document_\d+$/);
const ZTemplateIdSchema = z.string().regex(/^template_\d+$/);
const ZEnvelopeIdSchema = z.string().regex(/^envelope_\d+$/);
export type EnvelopeIdOptions =
| {
type: 'envelopeId';
id: string;
}
| {
type: 'documentId';
id: string | number;
}
| {
type: 'templateId';
id: string | number;
};
/**
* Parses an unknown document or template ID.
*
* @param id
* @param type
* @returns
*/
export const buildEnvelopeIdQuery = (options: EnvelopeIdOptions) => {
return match(options)
.with({ type: 'envelopeId' }, (value) => {
const parsed = ZEnvelopeIdSchema.safeParse(value.id);
if (!parsed.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid envelope ID',
});
}
return {
id: value.id,
};
})
.with({ type: 'documentId' }, (value) => ({
type: EnvelopeType.DOCUMENT,
secondaryId: parseDocumentIdToEnvelopeSecondaryId(value.id),
}))
.with({ type: 'templateId' }, (value) => ({
type: EnvelopeType.TEMPLATE,
secondaryId: parseTemplateIdToEnvelopeSecondaryId(value.id),
}))
.exhaustive();
};
export const parseDocumentIdToEnvelopeSecondaryId = (documentId: string | number) => {
if (typeof documentId === 'number') {
return `${envelopeDocumentPrefixId}_${documentId}`;
}
const parsed = ZDocumentIdSchema.safeParse(documentId);
if (!parsed.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid document ID',
});
}
return parsed.data;
};
export const parseTemplateIdToEnvelopeSecondaryId = (templateId: string | number) => {
if (typeof templateId === 'number') {
return `${envelopeTemplatePrefixId}_${templateId}`;
}
const parsed = ZTemplateIdSchema.safeParse(templateId);
if (!parsed.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid template ID',
});
}
return parsed.data;
};
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Recipient } from '@prisma/client';
import { type Recipient } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
@@ -0,0 +1,57 @@
-- DropForeignKey
ALTER TABLE "TemplateMeta" DROP CONSTRAINT "TemplateMeta_templateId_fkey";
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "templateId" INTEGER,
ALTER COLUMN "documentId" DROP NOT NULL;
-- [CUSTOM_CHANGE] Migrate existing TemplateMeta to DocumentMeta
INSERT INTO "DocumentMeta" (
"id",
"subject",
"message",
"timezone",
"password",
"dateFormat",
"redirectUrl",
"signingOrder",
"allowDictateNextSigner",
"typedSignatureEnabled",
"uploadSignatureEnabled",
"drawSignatureEnabled",
"language",
"distributionMethod",
"emailSettings",
"emailReplyTo",
"emailId",
"templateId"
)
SELECT
gen_random_uuid()::text, -- Generate new CUID-like IDs to avoid collisions
"subject",
"message",
"timezone",
"password",
"dateFormat",
"redirectUrl",
"signingOrder",
"allowDictateNextSigner",
"typedSignatureEnabled",
"uploadSignatureEnabled",
"drawSignatureEnabled",
"language",
"distributionMethod",
"emailSettings",
"emailReplyTo",
"emailId",
"templateId"
FROM "TemplateMeta";
-- DropTable
DROP TABLE "TemplateMeta";
-- CreateIndex
CREATE UNIQUE INDEX "DocumentMeta_templateId_key" ON "DocumentMeta"("templateId");
-- AddForeignKey
ALTER TABLE "DocumentMeta" ADD CONSTRAINT "DocumentMeta_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,261 @@
/*
Warnings:
- You are about to drop the column `authOptions` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `completedAt` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `createdAt` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `deletedAt` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `externalId` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `folderId` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `formValues` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `source` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `status` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `teamId` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `templateId` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `updatedAt` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `useLegacyFieldInsertion` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `userId` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `visibility` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `documentId` on the `DocumentAuditLog` table. All the data in the column will be lost.
- You are about to drop the column `documentId` on the `DocumentMeta` table. All the data in the column will be lost.
- You are about to drop the column `templateId` on the `DocumentMeta` table. All the data in the column will be lost.
- You are about to drop the column `documentId` on the `DocumentShareLink` table. All the data in the column will be lost.
- You are about to drop the column `documentId` on the `Field` table. All the data in the column will be lost.
- You are about to drop the column `templateId` on the `Field` table. All the data in the column will be lost.
- You are about to drop the column `documentId` on the `Recipient` table. All the data in the column will be lost.
- You are about to drop the column `templateId` on the `Recipient` table. All the data in the column will be lost.
- You are about to drop the column `templateId` on the `TemplateDirectLink` table. All the data in the column will be lost.
- You are about to drop the `Template` table. If the table is not empty, all the data it contains will be lost.
- A unique constraint covering the columns `[envelopeId,email]` on the table `DocumentShareLink` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[envelopeId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[envelopeId]` on the table `TemplateDirectLink` will be added. If there are existing duplicate values, this will fail.
- Added the required column `envelopeId` to the `DocumentAuditLog` table without a default value. This is not possible if the table is not empty.
- Added the required column `envelopeId` to the `DocumentShareLink` table without a default value. This is not possible if the table is not empty.
- Added the required column `envelopeId` to the `Field` table without a default value. This is not possible if the table is not empty.
- Added the required column `envelopeId` to the `Recipient` table without a default value. This is not possible if the table is not empty.
- Added the required column `envelopeId` to the `TemplateDirectLink` table without a default value. This is not possible if the table is not empty.
- Added the required column `type` to the `TemplateDirectLink` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "EnvelopeType" AS ENUM ('DOCUMENT', 'TEMPLATE');
-- CreateEnum
CREATE TYPE "TemplateDirectLinkType" AS ENUM ('PUBLIC', 'PRIVATE');
-- DropForeignKey
ALTER TABLE "Document" DROP CONSTRAINT "Document_folderId_fkey";
-- DropForeignKey
ALTER TABLE "Document" DROP CONSTRAINT "Document_teamId_fkey";
-- DropForeignKey
ALTER TABLE "Document" DROP CONSTRAINT "Document_templateId_fkey";
-- DropForeignKey
ALTER TABLE "Document" DROP CONSTRAINT "Document_userId_fkey";
-- DropForeignKey
ALTER TABLE "DocumentAuditLog" DROP CONSTRAINT "DocumentAuditLog_documentId_fkey";
-- DropForeignKey
ALTER TABLE "DocumentMeta" DROP CONSTRAINT "DocumentMeta_documentId_fkey";
-- DropForeignKey
ALTER TABLE "DocumentMeta" DROP CONSTRAINT "DocumentMeta_templateId_fkey";
-- DropForeignKey
ALTER TABLE "DocumentShareLink" DROP CONSTRAINT "DocumentShareLink_documentId_fkey";
-- DropForeignKey
ALTER TABLE "Field" DROP CONSTRAINT "Field_documentId_fkey";
-- DropForeignKey
ALTER TABLE "Field" DROP CONSTRAINT "Field_templateId_fkey";
-- DropForeignKey
ALTER TABLE "Recipient" DROP CONSTRAINT "Recipient_documentId_fkey";
-- DropForeignKey
ALTER TABLE "Recipient" DROP CONSTRAINT "Recipient_templateId_fkey";
-- DropForeignKey
ALTER TABLE "Template" DROP CONSTRAINT "Template_folderId_fkey";
-- DropForeignKey
ALTER TABLE "Template" DROP CONSTRAINT "Template_teamId_fkey";
-- DropForeignKey
ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDocumentDataId_fkey";
-- DropForeignKey
ALTER TABLE "Template" DROP CONSTRAINT "Template_userId_fkey";
-- DropForeignKey
ALTER TABLE "TemplateDirectLink" DROP CONSTRAINT "TemplateDirectLink_templateId_fkey";
-- DropIndex
DROP INDEX "Document_folderId_idx";
-- DropIndex
DROP INDEX "Document_status_idx";
-- DropIndex
DROP INDEX "Document_userId_idx";
-- DropIndex
DROP INDEX "DocumentMeta_documentId_key";
-- DropIndex
DROP INDEX "DocumentMeta_templateId_key";
-- DropIndex
DROP INDEX "DocumentShareLink_documentId_email_key";
-- DropIndex
DROP INDEX "Field_documentId_idx";
-- DropIndex
DROP INDEX "Field_templateId_idx";
-- DropIndex
DROP INDEX "Recipient_documentId_email_key";
-- DropIndex
DROP INDEX "Recipient_documentId_idx";
-- DropIndex
DROP INDEX "Recipient_templateId_email_key";
-- DropIndex
DROP INDEX "Recipient_templateId_idx";
-- DropIndex
DROP INDEX "TemplateDirectLink_templateId_key";
-- AlterTable
ALTER TABLE "Document" DROP COLUMN "authOptions",
DROP COLUMN "completedAt",
DROP COLUMN "createdAt",
DROP COLUMN "deletedAt",
DROP COLUMN "externalId",
DROP COLUMN "folderId",
DROP COLUMN "formValues",
DROP COLUMN "source",
DROP COLUMN "status",
DROP COLUMN "teamId",
DROP COLUMN "templateId",
DROP COLUMN "updatedAt",
DROP COLUMN "useLegacyFieldInsertion",
DROP COLUMN "userId",
DROP COLUMN "visibility";
-- AlterTable
ALTER TABLE "DocumentAuditLog" DROP COLUMN "documentId",
ADD COLUMN "envelopeId" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "DocumentMeta" DROP COLUMN "documentId",
DROP COLUMN "templateId",
ADD COLUMN "envelopeId" TEXT;
-- AlterTable
ALTER TABLE "DocumentShareLink" DROP COLUMN "documentId",
ADD COLUMN "envelopeId" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "Field" DROP COLUMN "documentId",
DROP COLUMN "templateId",
ADD COLUMN "envelopeId" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "Recipient" DROP COLUMN "documentId",
DROP COLUMN "templateId",
ADD COLUMN "envelopeId" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "TemplateDirectLink" DROP COLUMN "templateId",
ADD COLUMN "envelopeId" TEXT NOT NULL,
ADD COLUMN "type" "TemplateDirectLinkType" NOT NULL;
-- DropTable
DROP TABLE "Template";
-- DropEnum
DROP TYPE "TemplateType";
-- CreateTable
CREATE TABLE "Envelope" (
"id" TEXT NOT NULL,
"secondaryId" TEXT NOT NULL,
"externalId" TEXT,
"type" "EnvelopeType" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"completedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"title" TEXT NOT NULL,
"status" "DocumentStatus" NOT NULL DEFAULT 'DRAFT',
"source" "DocumentSource" NOT NULL,
"useLegacyFieldInsertion" BOOLEAN NOT NULL DEFAULT false,
"authOptions" JSONB,
"formValues" JSONB,
"visibility" "DocumentVisibility" NOT NULL DEFAULT 'EVERYONE',
"publicTitle" TEXT NOT NULL DEFAULT '',
"publicDescription" TEXT NOT NULL DEFAULT '',
"templateId" INTEGER,
"userId" INTEGER NOT NULL,
"teamId" INTEGER NOT NULL,
"folderId" TEXT,
"documentMetaId" TEXT NOT NULL,
CONSTRAINT "Envelope_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Envelope_secondaryId_key" ON "Envelope"("secondaryId");
-- CreateIndex
CREATE UNIQUE INDEX "Envelope_documentMetaId_key" ON "Envelope"("documentMetaId");
-- CreateIndex
CREATE UNIQUE INDEX "DocumentShareLink_envelopeId_email_key" ON "DocumentShareLink"("envelopeId", "email");
-- CreateIndex
CREATE INDEX "Field_envelopeId_idx" ON "Field"("envelopeId");
-- CreateIndex
CREATE INDEX "Recipient_envelopeId_idx" ON "Recipient"("envelopeId");
-- CreateIndex
CREATE UNIQUE INDEX "Recipient_envelopeId_email_key" ON "Recipient"("envelopeId", "email");
-- CreateIndex
CREATE UNIQUE INDEX "TemplateDirectLink_envelopeId_key" ON "TemplateDirectLink"("envelopeId");
-- AddForeignKey
ALTER TABLE "Envelope" ADD CONSTRAINT "Envelope_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Envelope" ADD CONSTRAINT "Envelope_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Envelope" ADD CONSTRAINT "Envelope_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Envelope" ADD CONSTRAINT "Envelope_documentMetaId_fkey" FOREIGN KEY ("documentMetaId") REFERENCES "DocumentMeta"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DocumentAuditLog" ADD CONSTRAINT "DocumentAuditLog_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Field" ADD CONSTRAINT "Field_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DocumentShareLink" ADD CONSTRAINT "DocumentShareLink_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TemplateDirectLink" ADD CONSTRAINT "TemplateDirectLink_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
/*
Warnings:
- Added the required column `envelopeId` to the `Document` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "envelopeId" TEXT NOT NULL;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+86 -128
View File
@@ -64,8 +64,7 @@ model User {
twoFactorBackupCodes String?
folders Folder[]
documents Document[]
templates Template[]
envelopes Envelope[]
verificationTokens VerificationToken[]
apiTokens ApiToken[]
@@ -348,8 +347,7 @@ model Folder {
pinned Boolean @default(false)
parentId String?
parent Folder? @relation("FolderToFolder", fields: [parentId], references: [id], onDelete: Cascade)
documents Document[]
templates Template[]
envelopes Envelope[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
subfolders Folder[] @relation("FolderToFolder")
@@ -362,53 +360,79 @@ model Folder {
@@index([type])
}
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';", "import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';"])
model Document {
id Int @id @default(autoincrement())
qrToken String? /// @zod.string.describe("The token for viewing the document using the QR code on the certificate.")
externalId String? /// @zod.string.describe("A custom external ID you can use to identify the document.")
enum EnvelopeType {
DOCUMENT
TEMPLATE
}
model Envelope {
id String @id @default(cuid())
secondaryId String @unique
externalId String? /// @zod.string.describe("A custom external ID you can use to identify the document.")
type EnvelopeType
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
completedAt DateTime?
deletedAt DateTime?
title String
status DocumentStatus @default(DRAFT)
source DocumentSource
useLegacyFieldInsertion Boolean @default(false)
documents Document[]
recipients Recipient[]
fields Field[]
shareLinks DocumentShareLink[]
auditLogs DocumentAuditLog[]
// Envelope settings
authOptions Json? /// [DocumentAuthOptions] @zod.custom.use(ZDocumentAuthOptionsSchema)
formValues Json? /// [DocumentFormValues] @zod.custom.use(ZDocumentFormValuesSchema)
visibility DocumentVisibility @default(EVERYONE)
// Template specific fields.
publicTitle String @default("")
publicDescription String @default("")
directLink TemplateDirectLink?
templateId Int? // Todo: Migrate from templateId -> This @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
// Relations
userId Int /// @zod.number.describe("The ID of the user that created this document.")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
authOptions Json? /// [DocumentAuthOptions] @zod.custom.use(ZDocumentAuthOptionsSchema)
formValues Json? /// [DocumentFormValues] @zod.custom.use(ZDocumentFormValuesSchema)
visibility DocumentVisibility @default(EVERYONE)
title String
status DocumentStatus @default(DRAFT)
recipients Recipient[]
fields Field[]
shareLinks DocumentShareLink[]
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId String?
documentMetaId String @unique
documentMeta DocumentMeta @relation(fields: [documentMetaId], references: [id])
}
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';", "import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';"])
model Document {
id Int @id @default(autoincrement())
qrToken String? /// @zod.string.describe("The token for viewing the document using the QR code on the certificate.")
title String
documentDataId String
documentMeta DocumentMeta?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
completedAt DateTime?
deletedAt DateTime?
templateId Int?
source DocumentSource
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
useLegacyFieldInsertion Boolean @default(false)
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull)
auditLogs DocumentAuditLog[]
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId String?
envelopeId String
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
@@unique([documentDataId])
@@index([userId])
@@index([status])
@@index([folderId])
}
model DocumentAuditLog {
id String @id @default(cuid())
documentId Int
envelopeId String
createdAt DateTime @default(now())
type String
data Json
@@ -420,7 +444,7 @@ model DocumentAuditLog {
userAgent String?
ipAddress String?
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
}
enum DocumentDataType {
@@ -440,7 +464,6 @@ model DocumentData {
data String
initialData String
document Document?
template Template?
}
enum DocumentDistributionMethod {
@@ -456,8 +479,6 @@ model DocumentMeta {
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?
signingOrder DocumentSigningOrder @default(PARALLEL)
allowDictateNextSigner Boolean @default(false)
@@ -472,6 +493,9 @@ model DocumentMeta {
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
emailReplyTo String?
emailId String?
envelopeId String?
envelope Envelope?
}
enum ReadStatus {
@@ -501,8 +525,7 @@ enum RecipientRole {
/// @zod.import(["import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
model Recipient {
id Int @id @default(autoincrement())
documentId Int?
templateId Int?
envelopeId String
email String @db.VarChar(255)
name String @default("") @db.VarChar(255)
token String
@@ -516,15 +539,12 @@ model Recipient {
readStatus ReadStatus @default(NOT_OPENED)
signingStatus SigningStatus @default(NOT_SIGNED)
sendStatus SendStatus @default(NOT_SENT)
document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
fields Field[]
signatures Signature[]
@@unique([documentId, email])
@@unique([templateId, email])
@@index([documentId])
@@index([templateId])
@@unique([envelopeId, email])
@@index([envelopeId])
@@index([token])
}
@@ -546,8 +566,7 @@ enum FieldType {
model Field {
id Int @id @default(autoincrement())
secondaryId String @unique @default(cuid())
documentId Int?
templateId Int?
envelopeId String
recipientId Int
type FieldType
page Int /// @zod.number.describe("The page number of the field on the document. Starts from 1.")
@@ -557,14 +576,12 @@ model Field {
height Decimal @default(-1)
customText String
inserted Boolean
document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
signature Signature?
fieldMeta Json? /// [FieldMeta] @zod.custom.use(ZFieldMetaNotOptionalSchema)
@@index([documentId])
@@index([templateId])
@@index([envelopeId])
@@index([recipientId])
}
@@ -586,13 +603,13 @@ model DocumentShareLink {
id Int @id @default(autoincrement())
email String
slug String @unique
documentId Int
envelopeId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
@@unique([documentId, email])
@@unique([envelopeId, email])
}
enum OrganisationType {
@@ -803,8 +820,7 @@ model Team {
profile TeamProfile?
documents Document[]
templates Template[]
envelopes Envelope[]
folders Folder[]
apiTokens ApiToken[]
webhooks Webhook[]
@@ -837,84 +853,26 @@ model TeamEmailVerification {
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
}
enum TemplateType {
// TODO: USE THIS
// TODO: USE THIS
// TODO: USE THIS
// TODO: USE THIS
enum TemplateDirectLinkType {
PUBLIC
PRIVATE
}
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
model TemplateMeta {
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
signingOrder DocumentSigningOrder? @default(PARALLEL)
allowDictateNextSigner Boolean @default(false)
distributionMethod DocumentDistributionMethod @default(EMAIL)
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
drawSignatureEnabled Boolean @default(true)
templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
redirectUrl String?
language String @default("en")
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
emailReplyTo String?
emailId String?
}
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
model Template {
id Int @id @default(autoincrement())
externalId String?
type TemplateType @default(PRIVATE)
title String
visibility DocumentVisibility @default(EVERYONE)
authOptions Json? /// [DocumentAuthOptions] @zod.custom.use(ZDocumentAuthOptionsSchema)
templateMeta TemplateMeta?
templateDocumentDataId String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
publicTitle String @default("")
publicDescription String @default("")
useLegacyFieldInsertion Boolean @default(false)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
recipients Recipient[]
fields Field[]
directLink TemplateDirectLink?
documents Document[]
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId String?
@@unique([templateDocumentDataId])
@@index([userId])
}
model TemplateDirectLink {
id String @id @unique @default(cuid())
templateId Int @unique
token String @unique
createdAt DateTime @default(now())
id String @id @unique @default(cuid())
envelopeId String @unique
token String @unique
createdAt DateTime @default(now())
enabled Boolean
type TemplateDirectLinkType
directTemplateRecipientId Int
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
}
model SiteSettings {
@@ -0,0 +1,59 @@
/**
* Legacy Document schema to confirm backwards API compatibility since
* we migrated Documents to Envelopes.
*/
import { z } from 'zod';
import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
import DocumentStatusSchema from '../generated/zod/inputTypeSchemas/DocumentStatusSchema';
import DocumentVisibilitySchema from '../generated/zod/inputTypeSchemas/DocumentVisibilitySchema';
const DocumentSourceSchema = z.enum(['DOCUMENT', 'TEMPLATE', 'TEMPLATE_DIRECT_LINK']);
const DocumentTypeSchema = z.enum(['DOCUMENT', 'PUBLIC_TEMPLATE', 'PRIVATE_TEMPLATE']);
/////////////////////////////////////////
// DOCUMENT SCHEMA
/////////////////////////////////////////
export const LegacyDocumentSchema = z.object({
type: DocumentTypeSchema,
visibility: DocumentVisibilitySchema,
status: DocumentStatusSchema,
source: DocumentSourceSchema,
id: z.number(),
qrToken: z
.string()
.describe('The token for viewing the document using the QR code on the certificate.')
.nullable(),
externalId: z
.string()
.describe('A custom external ID you can use to identify the document.')
.nullable(),
secondaryDocumentId: z.number(),
secondaryTemplateId: z.number(),
publicTitle: z.string(),
publicDescription: z.string(),
createdFromDocumentId: z.number().nullable(),
userId: z.number().describe('The ID of the user that created this document.'),
teamId: z.number(),
/**
* [DocumentAuthOptions]
*/
authOptions: ZDocumentAuthOptionsSchema.nullable(),
/**
* [DocumentFormValues]
*/
formValues: ZDocumentFormValuesSchema.nullable(),
title: z.string(),
documentDataId: z.string(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
completedAt: z.coerce.date().nullable(),
deletedAt: z.coerce.date().nullable(),
useLegacyFieldInsertion: z.boolean(),
folderId: z.string().nullable(),
});
export type Document = z.infer<typeof LegacyDocumentSchema>;
@@ -0,0 +1,36 @@
/**
* Legacy Template schema to confirm backwards API compatibility since
* we removed the "Template" prisma schema model.
*/
import { z } from 'zod';
import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { DocumentVisibilitySchema } from '../generated/zod/inputTypeSchemas/DocumentVisibilitySchema';
export const TemplateTypeSchema = z.enum(['PUBLIC', 'PRIVATE']);
export type TemplateTypeType = `${z.infer<typeof TemplateTypeSchema>}`;
export const TemplateSchema = z.object({
type: TemplateTypeSchema,
visibility: DocumentVisibilitySchema,
id: z.number(),
externalId: z.string().nullable(),
title: z.string(),
/**
* [DocumentAuthOptions]
*/
authOptions: ZDocumentAuthOptionsSchema.nullable(),
templateDocumentDataId: z.string(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
publicTitle: z.string(),
publicDescription: z.string(),
useLegacyFieldInsertion: z.boolean(),
userId: z.number(),
teamId: z.number(),
folderId: z.string().nullable(),
});
export type Template = z.infer<typeof TemplateSchema>;
@@ -0,0 +1,50 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZResetTwoFactorRequestSchema,
ZResetTwoFactorResponseSchema,
} from './reset-two-factor-authentication.types';
export const resetTwoFactorRoute = adminProcedure
.input(ZResetTwoFactorRequestSchema)
.output(ZResetTwoFactorResponseSchema)
.mutation(async ({ input, ctx }) => {
const { userId } = input;
ctx.logger.info({
input: {
userId,
},
});
return await resetTwoFactor({ userId });
});
export type ResetTwoFactorOptions = {
userId: number;
};
export const resetTwoFactor = async ({ userId }: ResetTwoFactorOptions) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'User not found' });
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: false,
twoFactorBackupCodes: null,
twoFactorSecret: null,
},
});
};
@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZResetTwoFactorRequestSchema = z.object({
userId: z.number(),
});
export const ZResetTwoFactorResponseSchema = z.void();
export type TResetTwoFactorRequest = z.infer<typeof ZResetTwoFactorRequestSchema>;
export type TResetTwoFactorResponse = z.infer<typeof ZResetTwoFactorResponseSchema>;
@@ -21,6 +21,7 @@ import { deleteSubscriptionClaimRoute } from './delete-subscription-claim';
import { findAdminOrganisationsRoute } from './find-admin-organisations';
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { getAdminOrganisationRoute } from './get-admin-organisation';
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import {
ZAdminDeleteDocumentMutationSchema,
ZAdminDeleteUserMutationSchema,
@@ -51,6 +52,9 @@ export const adminRouter = router({
stripe: {
createCustomer: createStripeCustomerRoute,
},
user: {
resetTwoFactor: resetTwoFactorRoute,
},
// Todo: migrate old routes
findDocuments: adminProcedure.input(ZAdminFindDocumentsQuerySchema).query(async ({ input }) => {
@@ -94,7 +94,7 @@ export const createEmbeddingTemplateRoute = procedure
emailSettings: meta.emailSettings,
};
await prisma.templateMeta.upsert({
await prisma.documentMeta.upsert({
where: {
templateId: template.id,
},
@@ -1,8 +1,10 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { SetAvatarImageOptions } from '@documenso/lib/server-only/profile/set-avatar-image';
import { setAvatarImage } from '@documenso/lib/server-only/profile/set-avatar-image';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { submitSupportTicket } from '@documenso/lib/server-only/user/submit-support-ticket';
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
import { adminProcedure, authenticatedProcedure, router } from '../trpc';
@@ -10,6 +12,7 @@ import {
ZFindUserSecurityAuditLogsSchema,
ZRetrieveUserByIdQuerySchema,
ZSetProfileImageMutationSchema,
ZSubmitSupportTicketMutationSchema,
ZUpdateProfileMutationSchema,
} from './schema';
@@ -91,4 +94,28 @@ export const profileRouter = router({
requestMetadata: ctx.metadata,
});
}),
submitSupportTicket: authenticatedProcedure
.input(ZSubmitSupportTicketMutationSchema)
.mutation(async ({ input, ctx }) => {
const { subject, message, organisationId, teamId } = input;
const userId = ctx.user.id;
const parsedTeamId = teamId ? Number(teamId) : null;
if (Number.isNaN(parsedTeamId)) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid team ID provided',
});
}
return await submitSupportTicket({
subject,
message,
userId,
organisationId,
teamId: parsedTeamId,
});
}),
});
@@ -27,3 +27,12 @@ export const ZSetProfileImageMutationSchema = z.object({
});
export type TSetProfileImageMutationSchema = z.infer<typeof ZSetProfileImageMutationSchema>;
export const ZSubmitSupportTicketMutationSchema = z.object({
organisationId: z.string(),
teamId: z.string().min(1).nullish(),
subject: z.string().min(3, 'Subject is required'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
export type TSupportTicketRequest = z.infer<typeof ZSubmitSupportTicketMutationSchema>;
+1
View File
@@ -141,6 +141,7 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path })
return await next({
ctx: {
...ctx,
teamId: ctx.teamId || -1,
logger: trpcSessionLogger,
user: ctx.user,
session: ctx.session,
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, Field, Recipient, TemplateMeta } from '@prisma/client';
import type { DocumentMeta, Field, Recipient } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
@@ -36,7 +36,7 @@ const getRecipientDisplayText = (recipient: { name: string; email: string }) =>
export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[];
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
documentMeta?: Pick<DocumentMeta, 'dateFormat'>;
showFieldStatus?: boolean;
@@ -1,5 +1,5 @@
import { useLingui } from '@lingui/react';
import type { DocumentMeta, Signature, TemplateMeta } from '@prisma/client';
import type { DocumentMeta, Signature } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { ChevronDown } from 'lucide-react';
@@ -27,7 +27,7 @@ type FieldIconProps = {
fieldMeta?: TFieldMetaSchema | null;
signature?: Signature | null;
};
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
documentMeta?: Pick<DocumentMeta, 'dateFormat'>;
};
/**
+1
View File
@@ -47,6 +47,7 @@
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
"NEXT_PUBLIC_DISABLE_SIGNUP",
"NEXT_PRIVATE_PLAIN_API_KEY",
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
"NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_DIRECT_DATABASE_URL",