mirror of
https://github.com/documenso/documenso.git
synced 2025-11-24 05:32:12 +10:00
chore: merged api
This commit is contained in:
@ -14,6 +14,7 @@
|
|||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@documenso/api": "*",
|
||||||
"@documenso/assets": "*",
|
"@documenso/assets": "*",
|
||||||
"@documenso/ee": "*",
|
"@documenso/ee": "*",
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
|
|||||||
74
apps/web/src/app/(dashboard)/settings/tokens/page.tsx
Normal file
74
apps/web/src/app/(dashboard)/settings/tokens/page.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export default async function ApiTokensPage() {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const tokens = await getUserTokens({ userId: user.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" />
|
||||||
|
|
||||||
|
<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}>
|
||||||
|
<Button variant="destructive">Delete</Button>
|
||||||
|
</DeleteTokenDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
apps/web/src/app/api/v1/openapi/page.tsx
Normal file
3
apps/web/src/app/api/v1/openapi/page.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export { OpenApiDocsPage as default } from '@documenso/api/v1/api-documentation';
|
||||||
@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Braces,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
Lock,
|
Lock,
|
||||||
@ -98,6 +99,13 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/settings/tokens" className="cursor-pointer">
|
||||||
|
<Braces className="mr-2 h-4 w-4" />
|
||||||
|
API Tokens
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{isBillingEnabled && (
|
{isBillingEnabled && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/billing" className="cursor-pointer">
|
<Link href="/settings/billing" className="cursor-pointer">
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
import { Braces, CreditCard, Lock, User, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -64,6 +64,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/settings/tokens">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Braces className="mr-2 h-5 w-5" />
|
||||||
|
API Tokens
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{isBillingEnabled && (
|
{isBillingEnabled && (
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
import { Braces, CreditCard, Lock, User, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -67,6 +67,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/settings/tokens">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Braces className="mr-2 h-5 w-5" />
|
||||||
|
API Tokens
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{isBillingEnabled && (
|
{isBillingEnabled && (
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
export const EXPIRATION_DATES = {
|
||||||
|
ONE_WEEK: '7 days',
|
||||||
|
ONE_MONTH: '1 month',
|
||||||
|
THREE_MONTHS: '3 months',
|
||||||
|
SIX_MONTHS: '6 months',
|
||||||
|
ONE_YEAR: '12 months',
|
||||||
|
} as const;
|
||||||
@ -0,0 +1,178 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { ApiToken } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type DeleteTokenDialogProps = {
|
||||||
|
token: Pick<ApiToken, 'id' | 'name'>;
|
||||||
|
onDelete?: () => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeleteTokenDialog({ token, onDelete, children }: DeleteTokenDialogProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const deleteMessage = `delete ${token.name}`;
|
||||||
|
|
||||||
|
const ZDeleteTokenDialogSchema = z.object({
|
||||||
|
tokenName: z.literal(deleteMessage, {
|
||||||
|
errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZDeleteTokenDialogSchema>;
|
||||||
|
|
||||||
|
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
onDelete?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<TDeleteTokenByIdMutationSchema>({
|
||||||
|
resolver: zodResolver(ZDeleteTokenDialogSchema),
|
||||||
|
values: {
|
||||||
|
tokenName: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await deleteTokenMutation({
|
||||||
|
id: token.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Token deleted',
|
||||||
|
description: 'The token was deleted successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting to delete this token. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [isOpen, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild={true}>
|
||||||
|
{children ?? (
|
||||||
|
<Button className="mr-4" variant="destructive">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Are you sure you want to delete this token?</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
Please note that this action is irreversible. Once confirmed, your token will be
|
||||||
|
permanently deleted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tokenName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Confirm by typing:{' '}
|
||||||
|
<span className="font-sm text-destructive font-semibold">
|
||||||
|
{deleteMessage}
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" type="text" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex w-full flex-nowrap gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!form.formState.isValid}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
I'm sure! Delete it
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
apps/web/src/components/forms/token.tsx
Normal file
255
apps/web/src/components/forms/token.tsx
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
|
||||||
|
import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { Switch } from '@documenso/ui/primitives/switch';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { EXPIRATION_DATES } from '../(dashboard)/settings/token/contants';
|
||||||
|
|
||||||
|
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.extend({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
|
||||||
|
|
||||||
|
export type ApiTokenFormProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [, copy] = useCopyToClipboard();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [newlyCreatedToken, setNewlyCreatedToken] = useState('');
|
||||||
|
const [noExpirationDate, setNoExpirationDate] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({
|
||||||
|
onSuccess(data) {
|
||||||
|
setNewlyCreatedToken(data.token);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<TCreateTokenFormSchema>({
|
||||||
|
resolver: zodResolver(ZCreateTokenFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
tokenName: '',
|
||||||
|
expirationDate: '',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyToken = async (token: string) => {
|
||||||
|
try {
|
||||||
|
const copied = await copy(token);
|
||||||
|
|
||||||
|
if (!copied) {
|
||||||
|
throw new Error('Unable to copy the token');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Token copied to clipboard',
|
||||||
|
description: 'The token was copied to your clipboard.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Unable to copy token',
|
||||||
|
description: 'We were unable to copy the token to your clipboard. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
|
||||||
|
try {
|
||||||
|
await createTokenMutation({
|
||||||
|
tokenName,
|
||||||
|
expirationDate: noExpirationDate ? null : expirationDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Token created',
|
||||||
|
description: 'A new token was created successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') {
|
||||||
|
toast({
|
||||||
|
title: 'An error occurred',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting create the new token. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(className)}>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<fieldset className="mt-6 flex w-full flex-col gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tokenName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel className="text-muted-foreground">Token name</FormLabel>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<FormControl className="flex-1">
|
||||||
|
<Input type="text" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormDescription className="text-xs italic">
|
||||||
|
Please enter a meaningful name for your token. This will help you identify it
|
||||||
|
later.
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="expirationDate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel className="text-muted-foreground">Token expiration date</FormLabel>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<FormControl className="flex-1">
|
||||||
|
<Select onValueChange={field.onChange} disabled={noExpirationDate}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Choose..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{date}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="">
|
||||||
|
<FormLabel className="text-muted-foreground mt-2">Never expire</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="block md:py-1.5">
|
||||||
|
<Switch
|
||||||
|
className="bg-background"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
setNoExpirationDate((prev) => !prev);
|
||||||
|
field.onChange(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="hidden md:inline-flex"
|
||||||
|
disabled={!form.formState.isDirty}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
Create token
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="md:hidden">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!form.formState.isDirty}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
Create token
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{newlyCreatedToken && (
|
||||||
|
<Card className="mt-8" gradient>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Your token was created successfully! Make sure to copy it because you won't be able to
|
||||||
|
see it again!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="bg-muted-foreground/10 my-4 rounded-md px-2.5 py-1 font-mono text-sm">
|
||||||
|
{newlyCreatedToken}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={() => void copyToken(newlyCreatedToken)}>
|
||||||
|
Copy token
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
apps/web/src/pages/api/v1/[...ts-rest].tsx
Normal file
17
apps/web/src/pages/api/v1/[...ts-rest].tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
import { createNextRouter } from '@documenso/api/next';
|
||||||
|
import { ApiContractV1 } from '@documenso/api/v1/contract';
|
||||||
|
import { ApiContractV1Implementation } from '@documenso/api/v1/implementation';
|
||||||
|
|
||||||
|
const nextRouteHandler = createNextRouter(ApiContractV1, ApiContractV1Implementation, {
|
||||||
|
responseValidation: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// TODO: Dirty hack to make ts-rest handler work with next.js in a more intuitive way.
|
||||||
|
req.query['ts-rest'] = Array.isArray(req.query['ts-rest']) ? req.query['ts-rest'] : []; // Make `ts-rest` an array.
|
||||||
|
req.query['ts-rest'].unshift('api', 'v1'); // Prepend our base path to the array.
|
||||||
|
|
||||||
|
return await nextRouteHandler(req, res);
|
||||||
|
}
|
||||||
7
apps/web/src/pages/api/v1/openapi.json.ts
Normal file
7
apps/web/src/pages/api/v1/openapi.json.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
|
||||||
|
|
||||||
|
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
res.status(200).json(OpenAPIV1);
|
||||||
|
}
|
||||||
1970
package-lock.json
generated
1970
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -47,9 +47,6 @@
|
|||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
|
||||||
"next-runtime-env": "^3.2.0"
|
|
||||||
},
|
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"next-auth": {
|
"next-auth": {
|
||||||
"next": "14.0.3"
|
"next": "14.0.3"
|
||||||
|
|||||||
1
packages/api/index.ts
Normal file
1
packages/api/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
1
packages/api/next.ts
Normal file
1
packages/api/next.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { createNextRouter } from '@ts-rest/next';
|
||||||
30
packages/api/package.json
Normal file
30
packages/api/package.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@documenso/api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "./index.ts",
|
||||||
|
"types": "./index.ts",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"clean": "rimraf node_modules"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.ts",
|
||||||
|
"next.ts",
|
||||||
|
"v1/"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@documenso/lib": "*",
|
||||||
|
"@documenso/prisma": "*",
|
||||||
|
"@ts-rest/core": "^3.30.5",
|
||||||
|
"@ts-rest/next": "^3.30.5",
|
||||||
|
"@ts-rest/open-api": "^3.33.0",
|
||||||
|
"@types/swagger-ui-react": "^4.18.3",
|
||||||
|
"luxon": "^3.4.0",
|
||||||
|
"superjson": "^1.13.1",
|
||||||
|
"swagger-ui-react": "^5.11.0",
|
||||||
|
"ts-pattern": "^5.0.5",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
packages/api/tsconfig.json
Normal file
8
packages/api/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@documenso/tsconfig/react-library.json",
|
||||||
|
"include": ["."],
|
||||||
|
"exclude": ["dist", "build", "node_modules"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/api/v1/api-documentation.tsx
Normal file
10
packages/api/v1/api-documentation.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import SwaggerUI from 'swagger-ui-react';
|
||||||
|
import 'swagger-ui-react/swagger-ui.css';
|
||||||
|
|
||||||
|
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
|
||||||
|
|
||||||
|
export const OpenApiDocsPage = () => {
|
||||||
|
return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />;
|
||||||
|
};
|
||||||
191
packages/api/v1/contract.ts
Normal file
191
packages/api/v1/contract.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import { initContract } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ZSendDocumentForSigningMutationSchema as SendDocumentMutationSchema,
|
||||||
|
ZAuthorizationHeadersSchema,
|
||||||
|
ZCreateDocumentFromTemplateMutationResponseSchema,
|
||||||
|
ZCreateDocumentFromTemplateMutationSchema,
|
||||||
|
ZCreateDocumentMutationResponseSchema,
|
||||||
|
ZCreateDocumentMutationSchema,
|
||||||
|
ZCreateFieldMutationSchema,
|
||||||
|
ZCreateRecipientMutationSchema,
|
||||||
|
ZDeleteDocumentMutationSchema,
|
||||||
|
ZDeleteFieldMutationSchema,
|
||||||
|
ZDeleteRecipientMutationSchema,
|
||||||
|
ZGetDocumentsQuerySchema,
|
||||||
|
ZSuccessfulDocumentResponseSchema,
|
||||||
|
ZSuccessfulFieldResponseSchema,
|
||||||
|
ZSuccessfulGetDocumentResponseSchema,
|
||||||
|
ZSuccessfulRecipientResponseSchema,
|
||||||
|
ZSuccessfulResponseSchema,
|
||||||
|
ZSuccessfulSigningResponseSchema,
|
||||||
|
ZUnsuccessfulResponseSchema,
|
||||||
|
ZUpdateFieldMutationSchema,
|
||||||
|
ZUpdateRecipientMutationSchema,
|
||||||
|
} from './schema';
|
||||||
|
|
||||||
|
const c = initContract();
|
||||||
|
|
||||||
|
export const ApiContractV1 = c.router(
|
||||||
|
{
|
||||||
|
getDocuments: {
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/documents',
|
||||||
|
query: ZGetDocumentsQuerySchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Get all documents',
|
||||||
|
},
|
||||||
|
|
||||||
|
getDocument: {
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/documents/:id',
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulGetDocumentResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Get a single document',
|
||||||
|
},
|
||||||
|
|
||||||
|
createDocument: {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/documents',
|
||||||
|
body: ZCreateDocumentMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZCreateDocumentMutationResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Upload a new document and get a presigned URL',
|
||||||
|
},
|
||||||
|
|
||||||
|
createDocumentFromTemplate: {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/templates/:templateId/create-document',
|
||||||
|
body: ZCreateDocumentFromTemplateMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZCreateDocumentFromTemplateMutationResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Upload a new document and get a presigned URL',
|
||||||
|
},
|
||||||
|
|
||||||
|
sendDocument: {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/documents/:id/send',
|
||||||
|
body: SendDocumentMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulSigningResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Send a document for signing',
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDocument: {
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/api/v1/documents/:id',
|
||||||
|
body: ZDeleteDocumentMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulDocumentResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Delete a document',
|
||||||
|
},
|
||||||
|
|
||||||
|
createRecipient: {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/documents/:id/recipients',
|
||||||
|
body: ZCreateRecipientMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulRecipientResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Create a recipient for a document',
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRecipient: {
|
||||||
|
method: 'PATCH',
|
||||||
|
path: '/api/v1/documents/:id/recipients/:recipientId',
|
||||||
|
body: ZUpdateRecipientMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulRecipientResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Update a recipient for a document',
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRecipient: {
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/api/v1/documents/:id/recipients/:recipientId',
|
||||||
|
body: ZDeleteRecipientMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulRecipientResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Delete a recipient from a document',
|
||||||
|
},
|
||||||
|
|
||||||
|
createField: {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/documents/:id/fields',
|
||||||
|
body: ZCreateFieldMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulFieldResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Create a field for a document',
|
||||||
|
},
|
||||||
|
|
||||||
|
updateField: {
|
||||||
|
method: 'PATCH',
|
||||||
|
path: '/api/v1/documents/:id/fields/:fieldId',
|
||||||
|
body: ZUpdateFieldMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulFieldResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Update a field for a document',
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteField: {
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/api/v1/documents/:id/fields/:fieldId',
|
||||||
|
body: ZDeleteFieldMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulFieldResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Delete a field from a document',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
baseHeaders: ZAuthorizationHeadersSchema,
|
||||||
|
},
|
||||||
|
);
|
||||||
59
packages/api/v1/examples/01-create-and-send-document.ts
Normal file
59
packages/api/v1/examples/01-create-and-send-document.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { status, body } = await client.createDocument({
|
||||||
|
body: {
|
||||||
|
title: 'My Document',
|
||||||
|
recipients: [
|
||||||
|
{
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
role: 'SIGNER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Jane Doe',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
role: 'APPROVER',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
subject: 'Please sign this document',
|
||||||
|
message: 'Hey {signer.name}, please sign the following document: {document.name}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to create document');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { uploadUrl, documentId } = body;
|
||||||
|
|
||||||
|
await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
},
|
||||||
|
body: '<raw-binary-data>',
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.sendDocument({
|
||||||
|
params: {
|
||||||
|
id: documentId.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
43
packages/api/v1/examples/02-add-a-field.ts
Normal file
43
packages/api/v1/examples/02-add-a-field.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = '1';
|
||||||
|
const recipientId = 1;
|
||||||
|
|
||||||
|
const { status, body } = await client.createField({
|
||||||
|
params: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: 'SIGNATURE',
|
||||||
|
pageHeight: 2.5, // percent of page to occupy in height
|
||||||
|
pageWidth: 5, // percent of page to occupy in width
|
||||||
|
pageX: 10, // percent from left
|
||||||
|
pageY: 10, // percent from top
|
||||||
|
pageNumber: 1,
|
||||||
|
recipientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to create field');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: fieldId } = body;
|
||||||
|
|
||||||
|
console.log(`Field created with id: ${fieldId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
39
packages/api/v1/examples/03-update-a-field.ts
Normal file
39
packages/api/v1/examples/03-update-a-field.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = '1';
|
||||||
|
const fieldId = '1';
|
||||||
|
|
||||||
|
const { status } = await client.updateField({
|
||||||
|
params: {
|
||||||
|
id: documentId,
|
||||||
|
fieldId,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: 'SIGNATURE',
|
||||||
|
pageHeight: 2.5, // percent of page to occupy in height
|
||||||
|
pageWidth: 5, // percent of page to occupy in width
|
||||||
|
pageX: 10, // percent from left
|
||||||
|
pageY: 10, // percent from top
|
||||||
|
pageNumber: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to update field');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
31
packages/api/v1/examples/04-remove-a-field.ts
Normal file
31
packages/api/v1/examples/04-remove-a-field.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = '1';
|
||||||
|
const fieldId = '1';
|
||||||
|
|
||||||
|
const { status } = await client.deleteField({
|
||||||
|
params: {
|
||||||
|
id: documentId,
|
||||||
|
fieldId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to remove field');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
38
packages/api/v1/examples/05-add-a-recipient.ts
Normal file
38
packages/api/v1/examples/05-add-a-recipient.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = '1';
|
||||||
|
|
||||||
|
const { status, body } = await client.createRecipient({
|
||||||
|
params: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
role: 'APPROVER',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to add recipient');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: recipientId } = body;
|
||||||
|
|
||||||
|
console.log(`Recipient added with id: ${recipientId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
34
packages/api/v1/examples/06-update-a-recipient.ts
Normal file
34
packages/api/v1/examples/06-update-a-recipient.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = '1';
|
||||||
|
const recipientId = '1';
|
||||||
|
|
||||||
|
const { status } = await client.updateRecipient({
|
||||||
|
params: {
|
||||||
|
id: documentId,
|
||||||
|
recipientId,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'Johnathon Doe',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to update recipient');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
31
packages/api/v1/examples/07-remove-a-recipient.ts
Normal file
31
packages/api/v1/examples/07-remove-a-recipient.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = '1';
|
||||||
|
const recipientId = '1';
|
||||||
|
|
||||||
|
const { status } = await client.deleteRecipient({
|
||||||
|
params: {
|
||||||
|
id: documentId,
|
||||||
|
recipientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to update recipient');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
31
packages/api/v1/examples/08-get-a-document.ts
Normal file
31
packages/api/v1/examples/08-get-a-document.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = '1';
|
||||||
|
|
||||||
|
const { status, body } = await client.getDocument({
|
||||||
|
params: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to get document');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Got document with id: ${documentId} and title: ${body.title}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
37
packages/api/v1/examples/09-paginate-all-documents.ts
Normal file
37
packages/api/v1/examples/09-paginate-all-documents.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = 1;
|
||||||
|
const perPage = 10;
|
||||||
|
|
||||||
|
const { status, body } = await client.getDocuments({
|
||||||
|
query: {
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to get documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const document of body.documents) {
|
||||||
|
console.log(`Got document with id: ${document.id} and title: ${document.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Total documents: ${body.totalPages * perPage}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
720
packages/api/v1/implementation.ts
Normal file
720
packages/api/v1/implementation.ts
Normal file
@ -0,0 +1,720 @@
|
|||||||
|
import { createNextRoute } from '@ts-rest/next';
|
||||||
|
|
||||||
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
|
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||||
|
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
||||||
|
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||||
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
|
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||||
|
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
|
||||||
|
import { createField } from '@documenso/lib/server-only/field/create-field';
|
||||||
|
import { deleteField } from '@documenso/lib/server-only/field/delete-field';
|
||||||
|
import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id';
|
||||||
|
import { updateField } from '@documenso/lib/server-only/field/update-field';
|
||||||
|
import { deleteRecipient } from '@documenso/lib/server-only/recipient/delete-recipient';
|
||||||
|
import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id';
|
||||||
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||||
|
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
|
||||||
|
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||||
|
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||||
|
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from './contract';
|
||||||
|
import { authenticatedMiddleware } from './middleware/authenticated';
|
||||||
|
|
||||||
|
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||||
|
getDocuments: authenticatedMiddleware(async (args, user) => {
|
||||||
|
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 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
documents,
|
||||||
|
totalPages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getDocument: authenticatedMiddleware(async (args, user) => {
|
||||||
|
const { id: documentId } = args.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const document = await getDocumentById({ id: Number(documentId), userId: user.id });
|
||||||
|
const recipients = await getRecipientsForDocument({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...document,
|
||||||
|
recipients,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteDocument: authenticatedMiddleware(async (args, user) => {
|
||||||
|
const { id: documentId } = args.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const document = await getDocumentById({ id: Number(documentId), userId: user.id });
|
||||||
|
|
||||||
|
const deletedDocument = await deleteDocument({
|
||||||
|
id: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
status: document.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: deletedDocument,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
createDocument: authenticatedMiddleware(async (args, user) => {
|
||||||
|
const { body } = args;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
message: 'Create document is not available without S3 transport.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
|
||||||
|
|
||||||
|
const { url, key } = await getPresignPostUrl(fileName, 'application/pdf');
|
||||||
|
|
||||||
|
const documentData = await createDocumentData({
|
||||||
|
data: key,
|
||||||
|
type: DocumentDataType.S3_PATH,
|
||||||
|
});
|
||||||
|
|
||||||
|
const document = await createDocument({
|
||||||
|
title: body.title,
|
||||||
|
userId: user.id,
|
||||||
|
documentDataId: documentData.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipients = await setRecipientsForDocument({
|
||||||
|
userId: user.id,
|
||||||
|
documentId: document.id,
|
||||||
|
recipients: body.recipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
uploadUrl: url,
|
||||||
|
documentId: document.id,
|
||||||
|
recipients: recipients.map((recipient) => ({
|
||||||
|
recipientId: recipient.id,
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
token: recipient.token,
|
||||||
|
role: recipient.role,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'An error has occured while uploading the file',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
createDocumentFromTemplate: authenticatedMiddleware(async (args, user) => {
|
||||||
|
const { body, params } = args;
|
||||||
|
|
||||||
|
const templateId = Number(params.templateId);
|
||||||
|
|
||||||
|
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
|
||||||
|
|
||||||
|
const document = await createDocumentFromTemplate({
|
||||||
|
templateId,
|
||||||
|
userId: user.id,
|
||||||
|
recipients: body.recipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateDocument({
|
||||||
|
documentId: document.id,
|
||||||
|
userId: user.id,
|
||||||
|
data: {
|
||||||
|
title: body.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (body.meta) {
|
||||||
|
await upsertDocumentMeta({
|
||||||
|
documentId: document.id,
|
||||||
|
userId: user.id,
|
||||||
|
subject: body.meta.subject,
|
||||||
|
message: body.meta.message,
|
||||||
|
dateFormat: body.meta.dateFormat,
|
||||||
|
timezone: body.meta.timezone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
documentId: document.id,
|
||||||
|
recipients: document.Recipient.map((recipient) => ({
|
||||||
|
recipientId: recipient.id,
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
token: recipient.token,
|
||||||
|
role: recipient.role,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
sendDocument: authenticatedMiddleware(async (args, user) => {
|
||||||
|
const { id } = args.params;
|
||||||
|
|
||||||
|
const document = await getDocumentById({ id: Number(id), userId: user.id });
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === 'PENDING') {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is already waiting for signing',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// await setRecipientsForDocument({
|
||||||
|
// userId: user.id,
|
||||||
|
// documentId: Number(id),
|
||||||
|
// recipients: [
|
||||||
|
// {
|
||||||
|
// email: body.signerEmail,
|
||||||
|
// name: body.signerName ?? '',
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// });
|
||||||
|
|
||||||
|
// await setFieldsForDocument({
|
||||||
|
// documentId: Number(id),
|
||||||
|
// userId: user.id,
|
||||||
|
// fields: body.fields.map((field) => ({
|
||||||
|
// signerEmail: body.signerEmail,
|
||||||
|
// type: field.fieldType,
|
||||||
|
// pageNumber: field.pageNumber,
|
||||||
|
// pageX: field.pageX,
|
||||||
|
// pageY: field.pageY,
|
||||||
|
// pageWidth: field.pageWidth,
|
||||||
|
// pageHeight: field.pageHeight,
|
||||||
|
// })),
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (body.emailBody || body.emailSubject) {
|
||||||
|
// await upsertDocumentMeta({
|
||||||
|
// documentId: Number(id),
|
||||||
|
// subject: body.emailSubject ?? '',
|
||||||
|
// message: body.emailBody ?? '',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
await sendDocument({
|
||||||
|
documentId: Number(id),
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
message: 'Document sent for signing successfully',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
message: 'An error has occured while sending the document for signing',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
createRecipient: authenticatedMiddleware(async (args, user) => {
|
||||||
|
const { id: documentId } = args.params;
|
||||||
|
const { name, email, role } = args.body;
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is already completed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipients = await getRecipientsForDocument({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientAlreadyExists = recipients.some((recipient) => recipient.email === email);
|
||||||
|
|
||||||
|
if (recipientAlreadyExists) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Recipient already exists',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newRecipients = await setRecipientsForDocument({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
recipients: [
|
||||||
|
...recipients,
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const newRecipient = newRecipients.find((recipient) => recipient.email === email);
|
||||||
|
|
||||||
|
if (!newRecipient) {
|
||||||
|
throw new Error('Recipient not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...newRecipient,
|
||||||
|
documentId: Number(documentId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
message: 'An error has occured while creating the recipient',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateRecipient: authenticatedMiddleware(async (args, user) => {
|
||||||
|
const { id: documentId, recipientId } = args.params;
|
||||||
|
const { name, email, role } = args.body;
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is already completed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRecipient = await updateRecipient({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
recipientId: Number(recipientId),
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!updatedRecipient) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Recipient not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...updatedRecipient,
|
||||||
|
documentId: Number(documentId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteRecipient: authenticatedMiddleware(async (args, user) => {
|
||||||
|
const { id: documentId, recipientId } = args.params;
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is already completed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedRecipient = await deleteRecipient({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
recipientId: Number(recipientId),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!deletedRecipient) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Unable to delete recipient',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...deletedRecipient,
|
||||||
|
documentId: Number(documentId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
createField: authenticatedMiddleware(async (args, user) => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is already completed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = await getRecipientById({
|
||||||
|
id: Number(recipientId),
|
||||||
|
documentId: Number(documentId),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Recipient not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Recipient has already signed the document',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = await createField({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
recipientId: Number(recipientId),
|
||||||
|
type,
|
||||||
|
pageNumber,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remappedField = {
|
||||||
|
id: field.id,
|
||||||
|
documentId: field.documentId,
|
||||||
|
recipientId: field.recipientId ?? -1,
|
||||||
|
type: field.type,
|
||||||
|
pageNumber: field.page,
|
||||||
|
pageX: Number(field.positionX),
|
||||||
|
pageY: Number(field.positionY),
|
||||||
|
pageWidth: Number(field.width),
|
||||||
|
pageHeight: Number(field.height),
|
||||||
|
customText: field.customText,
|
||||||
|
inserted: field.inserted,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...remappedField,
|
||||||
|
documentId: Number(documentId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateField: authenticatedMiddleware(async (args, user) => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is already completed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = await getRecipientById({
|
||||||
|
id: Number(recipientId),
|
||||||
|
documentId: Number(documentId),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Recipient not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Recipient has already signed the document',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedField = await updateField({
|
||||||
|
fieldId: Number(fieldId),
|
||||||
|
documentId: Number(documentId),
|
||||||
|
recipientId: recipientId ? Number(recipientId) : undefined,
|
||||||
|
type,
|
||||||
|
pageNumber,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remappedField = {
|
||||||
|
id: updatedField.id,
|
||||||
|
documentId: updatedField.documentId,
|
||||||
|
recipientId: updatedField.recipientId ?? -1,
|
||||||
|
type: updatedField.type,
|
||||||
|
pageNumber: updatedField.page,
|
||||||
|
pageX: Number(updatedField.positionX),
|
||||||
|
pageY: Number(updatedField.positionY),
|
||||||
|
pageWidth: Number(updatedField.width),
|
||||||
|
pageHeight: Number(updatedField.height),
|
||||||
|
customText: updatedField.customText,
|
||||||
|
inserted: updatedField.inserted,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...remappedField,
|
||||||
|
documentId: Number(documentId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteField: authenticatedMiddleware(async (args, user) => {
|
||||||
|
const { id: documentId, fieldId } = args.params;
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is already completed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = await getFieldById({
|
||||||
|
fieldId: Number(fieldId),
|
||||||
|
documentId: Number(documentId),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!field) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Field not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = await getRecipientById({
|
||||||
|
id: Number(field.recipientId),
|
||||||
|
documentId: Number(documentId),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Recipient has already signed the document',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedField = await deleteField({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
fieldId: Number(fieldId),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!deletedField) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Unable to delete field',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const remappedField = {
|
||||||
|
id: deletedField.id,
|
||||||
|
documentId: deletedField.documentId,
|
||||||
|
recipientId: deletedField.recipientId ?? -1,
|
||||||
|
type: deletedField.type,
|
||||||
|
pageNumber: deletedField.page,
|
||||||
|
pageX: Number(deletedField.positionX),
|
||||||
|
pageY: Number(deletedField.positionY),
|
||||||
|
pageWidth: Number(deletedField.width),
|
||||||
|
pageHeight: Number(deletedField.height),
|
||||||
|
customText: deletedField.customText,
|
||||||
|
inserted: deletedField.inserted,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...remappedField,
|
||||||
|
documentId: Number(documentId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
41
packages/api/v1/middleware/authenticated.ts
Normal file
41
packages/api/v1/middleware/authenticated.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export const authenticatedMiddleware = <
|
||||||
|
T extends {
|
||||||
|
req: NextApiRequest;
|
||||||
|
},
|
||||||
|
R extends {
|
||||||
|
status: number;
|
||||||
|
body: unknown;
|
||||||
|
},
|
||||||
|
>(
|
||||||
|
handler: (args: T, user: User) => Promise<R>,
|
||||||
|
) => {
|
||||||
|
return async (args: T) => {
|
||||||
|
try {
|
||||||
|
const { authorization } = args.req.headers;
|
||||||
|
|
||||||
|
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
|
||||||
|
const [token] = (authorization || '').split('Bearer ').filter((s) => s.length > 0);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Token was not provided for authenticated middleware');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByApiToken({ token });
|
||||||
|
|
||||||
|
return await handler(args, user);
|
||||||
|
} catch (_err) {
|
||||||
|
console.log({ _err });
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
body: {
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
17
packages/api/v1/openapi.ts
Normal file
17
packages/api/v1/openapi.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { generateOpenApi } from '@ts-rest/open-api';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from './contract';
|
||||||
|
|
||||||
|
export const OpenAPIV1 = generateOpenApi(
|
||||||
|
ApiContractV1,
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
title: 'Documenso API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setOperationId: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
240
packages/api/v1/schema.ts
Normal file
240
packages/api/v1/schema.ts
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FieldType,
|
||||||
|
ReadStatus,
|
||||||
|
RecipientRole,
|
||||||
|
SendStatus,
|
||||||
|
SigningStatus,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documents
|
||||||
|
*/
|
||||||
|
export const ZGetDocumentsQuerySchema = z.object({
|
||||||
|
page: z.coerce.number().min(1).optional().default(1),
|
||||||
|
perPage: z.coerce.number().min(1).optional().default(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
|
||||||
|
|
||||||
|
export const ZDeleteDocumentMutationSchema = null;
|
||||||
|
|
||||||
|
export type TDeleteDocumentMutationSchema = typeof ZDeleteDocumentMutationSchema;
|
||||||
|
|
||||||
|
export const ZSuccessfulDocumentResponseSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
userId: z.number(),
|
||||||
|
title: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
documentDataId: z.string(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date(),
|
||||||
|
completedAt: z.date().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZSuccessfulGetDocumentResponseSchema = ZSuccessfulDocumentResponseSchema.extend({
|
||||||
|
recipients: z.lazy(() => z.array(ZSuccessfulRecipientResponseSchema)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSuccessfulGetDocumentResponseSchema = z.infer<
|
||||||
|
typeof ZSuccessfulGetDocumentResponseSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
|
||||||
|
|
||||||
|
export const ZSendDocumentForSigningMutationSchema = null;
|
||||||
|
|
||||||
|
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
||||||
|
|
||||||
|
export const ZUploadDocumentSuccessfulSchema = z.object({
|
||||||
|
url: z.string(),
|
||||||
|
key: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>;
|
||||||
|
|
||||||
|
export const ZCreateDocumentMutationSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
recipients: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
meta: z
|
||||||
|
.object({
|
||||||
|
subject: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
timezone: z.string(),
|
||||||
|
dateFormat: z.string(),
|
||||||
|
redirectUrl: z.string(),
|
||||||
|
})
|
||||||
|
.partial(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
|
||||||
|
|
||||||
|
export const ZCreateDocumentMutationResponseSchema = z.object({
|
||||||
|
uploadUrl: z.string().min(1),
|
||||||
|
documentId: z.number(),
|
||||||
|
recipients: z.array(
|
||||||
|
z.object({
|
||||||
|
recipientId: z.number(),
|
||||||
|
token: z.string(),
|
||||||
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateDocumentMutationResponseSchema = z.infer<
|
||||||
|
typeof ZCreateDocumentMutationResponseSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
recipients: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
meta: z
|
||||||
|
.object({
|
||||||
|
subject: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
timezone: z.string(),
|
||||||
|
dateFormat: z.string(),
|
||||||
|
redirectUrl: z.string(),
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
||||||
|
typeof ZCreateDocumentFromTemplateMutationSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||||
|
documentId: z.number(),
|
||||||
|
recipients: z.array(
|
||||||
|
z.object({
|
||||||
|
recipientId: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
token: z.string(),
|
||||||
|
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
|
||||||
|
typeof ZCreateDocumentFromTemplateMutationResponseSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ZCreateRecipientMutationSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recipients
|
||||||
|
*/
|
||||||
|
export type TCreateRecipientMutationSchema = z.infer<typeof ZCreateRecipientMutationSchema>;
|
||||||
|
|
||||||
|
export const ZUpdateRecipientMutationSchema = ZCreateRecipientMutationSchema.partial();
|
||||||
|
|
||||||
|
export type TUpdateRecipientMutationSchema = z.infer<typeof ZUpdateRecipientMutationSchema>;
|
||||||
|
|
||||||
|
export const ZDeleteRecipientMutationSchema = null;
|
||||||
|
|
||||||
|
export type TDeleteRecipientMutationSchema = typeof ZDeleteRecipientMutationSchema;
|
||||||
|
|
||||||
|
export const ZSuccessfulRecipientResponseSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
// !: This handles the fact that we have null documentId's for templates
|
||||||
|
// !: while we won't need the default we must add it to satisfy typescript
|
||||||
|
documentId: z.number().nullish().default(-1),
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
name: z.string(),
|
||||||
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
token: z.string(),
|
||||||
|
// !: Not used for now
|
||||||
|
// expired: z.string(),
|
||||||
|
signedAt: z.date().nullable(),
|
||||||
|
readStatus: z.nativeEnum(ReadStatus),
|
||||||
|
signingStatus: z.nativeEnum(SigningStatus),
|
||||||
|
sendStatus: z.nativeEnum(SendStatus),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields
|
||||||
|
*/
|
||||||
|
export const ZCreateFieldMutationSchema = z.object({
|
||||||
|
recipientId: z.number(),
|
||||||
|
type: z.nativeEnum(FieldType),
|
||||||
|
pageNumber: z.number(),
|
||||||
|
pageX: z.number(),
|
||||||
|
pageY: z.number(),
|
||||||
|
pageWidth: z.number(),
|
||||||
|
pageHeight: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateFieldMutationSchema = z.infer<typeof ZCreateFieldMutationSchema>;
|
||||||
|
|
||||||
|
export const ZUpdateFieldMutationSchema = ZCreateFieldMutationSchema.partial();
|
||||||
|
|
||||||
|
export type TUpdateFieldMutationSchema = z.infer<typeof ZUpdateFieldMutationSchema>;
|
||||||
|
|
||||||
|
export const ZDeleteFieldMutationSchema = null;
|
||||||
|
|
||||||
|
export type TDeleteFieldMutationSchema = typeof ZDeleteFieldMutationSchema;
|
||||||
|
|
||||||
|
export const ZSuccessfulFieldResponseSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
documentId: z.number(),
|
||||||
|
recipientId: z.number(),
|
||||||
|
type: z.nativeEnum(FieldType),
|
||||||
|
pageNumber: z.number(),
|
||||||
|
pageX: z.number(),
|
||||||
|
pageY: z.number(),
|
||||||
|
pageWidth: z.number(),
|
||||||
|
pageHeight: z.number(),
|
||||||
|
customText: z.string(),
|
||||||
|
inserted: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSuccessfulFieldResponseSchema = z.infer<typeof ZSuccessfulFieldResponseSchema>;
|
||||||
|
|
||||||
|
export const ZSuccessfulResponseSchema = z.object({
|
||||||
|
documents: ZSuccessfulDocumentResponseSchema.array(),
|
||||||
|
totalPages: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
|
||||||
|
|
||||||
|
export const ZSuccessfulSigningResponseSchema = z.object({
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General
|
||||||
|
*/
|
||||||
|
export const ZAuthorizationHeadersSchema = z.object({
|
||||||
|
authorization: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAuthorizationHeadersSchema = z.infer<typeof ZAuthorizationHeadersSchema>;
|
||||||
|
|
||||||
|
export const ZUnsuccessfulResponseSchema = z.object({
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TUnsuccessfulResponseSchema = z.infer<typeof ZUnsuccessfulResponseSchema>;
|
||||||
@ -1,5 +1,11 @@
|
|||||||
|
import { Duration } from 'luxon';
|
||||||
|
|
||||||
export const ONE_SECOND = 1000;
|
export const ONE_SECOND = 1000;
|
||||||
export const ONE_MINUTE = ONE_SECOND * 60;
|
export const ONE_MINUTE = ONE_SECOND * 60;
|
||||||
export const ONE_HOUR = ONE_MINUTE * 60;
|
export const ONE_HOUR = ONE_MINUTE * 60;
|
||||||
export const ONE_DAY = ONE_HOUR * 24;
|
export const ONE_DAY = ONE_HOUR * 24;
|
||||||
export const ONE_WEEK = ONE_DAY * 7;
|
export const ONE_WEEK = ONE_DAY * 7;
|
||||||
|
export const ONE_MONTH = Duration.fromObject({ months: 1 });
|
||||||
|
export const THREE_MONTHS = Duration.fromObject({ months: 3 });
|
||||||
|
export const SIX_MONTHS = Duration.fromObject({ months: 6 });
|
||||||
|
export const ONE_YEAR = Duration.fromObject({ years: 1 });
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
|
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
import { SALT_ROUNDS } from '../../constants/auth';
|
import { SALT_ROUNDS } from '../../constants/auth';
|
||||||
|
|
||||||
@ -12,3 +13,7 @@ export const hashSync = (password: string) => {
|
|||||||
export const compareSync = (password: string, hash: string) => {
|
export const compareSync = (password: string, hash: string) => {
|
||||||
return bcryptCompareSync(password, hash);
|
return bcryptCompareSync(password, hash);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const hashString = (input: string) => {
|
||||||
|
return crypto.createHash('sha512').update(input).digest('hex');
|
||||||
|
};
|
||||||
|
|||||||
41
packages/lib/server-only/field/create-field.ts
Normal file
41
packages/lib/server-only/field/create-field.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type CreateFieldOptions = {
|
||||||
|
documentId: number;
|
||||||
|
recipientId: number;
|
||||||
|
type: FieldType;
|
||||||
|
pageNumber: number;
|
||||||
|
pageX: number;
|
||||||
|
pageY: number;
|
||||||
|
pageWidth: number;
|
||||||
|
pageHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createField = async ({
|
||||||
|
documentId,
|
||||||
|
recipientId,
|
||||||
|
type,
|
||||||
|
pageNumber,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
}: CreateFieldOptions) => {
|
||||||
|
const field = await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
documentId,
|
||||||
|
recipientId,
|
||||||
|
type,
|
||||||
|
page: pageNumber,
|
||||||
|
positionX: pageX,
|
||||||
|
positionY: pageY,
|
||||||
|
width: pageWidth,
|
||||||
|
height: pageHeight,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return field;
|
||||||
|
};
|
||||||
17
packages/lib/server-only/field/delete-field.ts
Normal file
17
packages/lib/server-only/field/delete-field.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type DeleteFieldOptions = {
|
||||||
|
fieldId: number;
|
||||||
|
documentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteField = async ({ fieldId, documentId }: DeleteFieldOptions) => {
|
||||||
|
const field = await prisma.field.delete({
|
||||||
|
where: {
|
||||||
|
id: fieldId,
|
||||||
|
documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return field;
|
||||||
|
};
|
||||||
17
packages/lib/server-only/field/get-field-by-id.ts
Normal file
17
packages/lib/server-only/field/get-field-by-id.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type GetFieldByIdOptions = {
|
||||||
|
fieldId: number;
|
||||||
|
documentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFieldById = async ({ fieldId, documentId }: GetFieldByIdOptions) => {
|
||||||
|
const field = await prisma.field.findFirst({
|
||||||
|
where: {
|
||||||
|
id: fieldId,
|
||||||
|
documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return field;
|
||||||
|
};
|
||||||
44
packages/lib/server-only/field/update-field.ts
Normal file
44
packages/lib/server-only/field/update-field.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type UpdateFieldOptions = {
|
||||||
|
fieldId: number;
|
||||||
|
documentId: number;
|
||||||
|
recipientId?: number;
|
||||||
|
type?: FieldType;
|
||||||
|
pageNumber?: number;
|
||||||
|
pageX?: number;
|
||||||
|
pageY?: number;
|
||||||
|
pageWidth?: number;
|
||||||
|
pageHeight?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateField = async ({
|
||||||
|
fieldId,
|
||||||
|
documentId,
|
||||||
|
recipientId,
|
||||||
|
type,
|
||||||
|
pageNumber,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
}: UpdateFieldOptions) => {
|
||||||
|
const field = await prisma.field.update({
|
||||||
|
where: {
|
||||||
|
id: fieldId,
|
||||||
|
documentId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
recipientId,
|
||||||
|
type,
|
||||||
|
page: pageNumber,
|
||||||
|
positionX: pageX,
|
||||||
|
positionY: pageY,
|
||||||
|
width: pageWidth,
|
||||||
|
height: pageHeight,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return field;
|
||||||
|
};
|
||||||
51
packages/lib/server-only/public-api/create-api-token.ts
Normal file
51
packages/lib/server-only/public-api/create-api-token.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type { Duration } from 'luxon';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
// temporary choice for testing only
|
||||||
|
import * as timeConstants from '../../constants/time';
|
||||||
|
import { alphaid } from '../../universal/id';
|
||||||
|
import { hashString } from '../auth/hash';
|
||||||
|
|
||||||
|
type TimeConstants = typeof timeConstants & {
|
||||||
|
[key: string]: number | Duration;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateApiTokenInput = {
|
||||||
|
userId: number;
|
||||||
|
tokenName: string;
|
||||||
|
expirationDate: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createApiToken = async ({
|
||||||
|
userId,
|
||||||
|
tokenName,
|
||||||
|
expirationDate,
|
||||||
|
}: CreateApiTokenInput) => {
|
||||||
|
const apiToken = `api_${alphaid(16)}`;
|
||||||
|
|
||||||
|
const hashedToken = hashString(apiToken);
|
||||||
|
|
||||||
|
const timeConstantsRecords: TimeConstants = timeConstants;
|
||||||
|
|
||||||
|
const dbToken = await prisma.apiToken.create({
|
||||||
|
data: {
|
||||||
|
token: hashedToken,
|
||||||
|
name: tokenName,
|
||||||
|
userId,
|
||||||
|
expires: expirationDate
|
||||||
|
? DateTime.now().plus(timeConstantsRecords[expirationDate]).toJSDate()
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dbToken) {
|
||||||
|
throw new Error('Failed to create the API token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dbToken.id,
|
||||||
|
token: apiToken,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type DeleteTokenByIdOptions = {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTokenById = async ({ id, userId }: DeleteTokenByIdOptions) => {
|
||||||
|
return await prisma.apiToken.delete({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
23
packages/lib/server-only/public-api/get-all-user-tokens.ts
Normal file
23
packages/lib/server-only/public-api/get-all-user-tokens.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type GetUserTokensOptions = {
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserTokens = async ({ userId }: GetUserTokensOptions) => {
|
||||||
|
return await prisma.apiToken.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
algorithm: true,
|
||||||
|
createdAt: true,
|
||||||
|
expires: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
15
packages/lib/server-only/public-api/get-api-token-by-id.ts
Normal file
15
packages/lib/server-only/public-api/get-api-token-by-id.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type GetApiTokenByIdOptions = {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getApiTokenById = async ({ id, userId }: GetApiTokenByIdOptions) => {
|
||||||
|
return await prisma.apiToken.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
37
packages/lib/server-only/public-api/get-user-by-token.ts
Normal file
37
packages/lib/server-only/public-api/get-user-by-token.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
32
packages/lib/server-only/recipient/delete-recipient.ts
Normal file
32
packages/lib/server-only/recipient/delete-recipient.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { SendStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type DeleteRecipientOptions = {
|
||||||
|
documentId: number;
|
||||||
|
recipientId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteRecipient = async ({ documentId, recipientId }: DeleteRecipientOptions) => {
|
||||||
|
const recipient = await prisma.recipient.findFirst({
|
||||||
|
where: {
|
||||||
|
id: recipientId,
|
||||||
|
documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Error('Recipient not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.sendStatus !== SendStatus.NOT_SENT) {
|
||||||
|
throw new Error('Can not delete a recipient that has already been sent a document');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedRecipient = await prisma.recipient.delete({
|
||||||
|
where: {
|
||||||
|
id: recipient.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return deletedRecipient;
|
||||||
|
};
|
||||||
21
packages/lib/server-only/recipient/get-recipient-by-email.ts
Normal file
21
packages/lib/server-only/recipient/get-recipient-by-email.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type GetRecipientByEmailOptions = {
|
||||||
|
documentId: number;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRecipientByEmail = async ({ documentId, email }: GetRecipientByEmailOptions) => {
|
||||||
|
const recipient = await prisma.recipient.findFirst({
|
||||||
|
where: {
|
||||||
|
documentId,
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Error('Recipient not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipient;
|
||||||
|
};
|
||||||
21
packages/lib/server-only/recipient/get-recipient-by-id.ts
Normal file
21
packages/lib/server-only/recipient/get-recipient-by-id.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type GetRecipientByIdOptions = {
|
||||||
|
id: number;
|
||||||
|
documentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRecipientById = async ({ documentId, id }: GetRecipientByIdOptions) => {
|
||||||
|
const recipient = await prisma.recipient.findFirst({
|
||||||
|
where: {
|
||||||
|
documentId,
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Error('Recipient not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipient;
|
||||||
|
};
|
||||||
42
packages/lib/server-only/recipient/update-recipient.ts
Normal file
42
packages/lib/server-only/recipient/update-recipient.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type UpdateRecipientOptions = {
|
||||||
|
documentId: number;
|
||||||
|
recipientId: number;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
role?: RecipientRole;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateRecipient = async ({
|
||||||
|
documentId,
|
||||||
|
recipientId,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
}: UpdateRecipientOptions) => {
|
||||||
|
const recipient = await prisma.recipient.findFirst({
|
||||||
|
where: {
|
||||||
|
id: recipientId,
|
||||||
|
documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedRecipient;
|
||||||
|
};
|
||||||
@ -1,14 +1,21 @@
|
|||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { TCreateDocumentFromTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
|
import type { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export type CreateDocumentFromTemplateOptions = TCreateDocumentFromTemplateMutationSchema & {
|
export type CreateDocumentFromTemplateOptions = {
|
||||||
|
templateId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
recipients?: {
|
||||||
|
name?: string;
|
||||||
|
email: string;
|
||||||
|
role?: RecipientRole;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createDocumentFromTemplate = async ({
|
export const createDocumentFromTemplate = async ({
|
||||||
templateId,
|
templateId,
|
||||||
userId,
|
userId,
|
||||||
|
recipients,
|
||||||
}: CreateDocumentFromTemplateOptions) => {
|
}: CreateDocumentFromTemplateOptions) => {
|
||||||
const template = await prisma.template.findUnique({
|
const template = await prisma.template.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@ -63,7 +70,11 @@ export const createDocumentFromTemplate = async ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
include: {
|
include: {
|
||||||
Recipient: true,
|
Recipient: {
|
||||||
|
orderBy: {
|
||||||
|
id: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -88,5 +99,34 @@ export const createDocumentFromTemplate = async ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (recipients && recipients.length > 0) {
|
||||||
|
document.Recipient = await Promise.all(
|
||||||
|
recipients.map(async (recipient, index) => {
|
||||||
|
const existingRecipient = document.Recipient.at(index);
|
||||||
|
|
||||||
|
return await prisma.recipient.upsert({
|
||||||
|
where: {
|
||||||
|
documentId_email: {
|
||||||
|
documentId: document.id,
|
||||||
|
email: existingRecipient?.email ?? recipient.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
role: recipient.role,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
documentId: document.id,
|
||||||
|
email: recipient.email,
|
||||||
|
name: recipient.name,
|
||||||
|
role: recipient.role,
|
||||||
|
token: nanoid(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return document;
|
return document;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ApiTokenAlgorithm" AS ENUM ('SHA512');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ApiToken" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"algorithm" "ApiTokenAlgorithm" NOT NULL DEFAULT 'SHA512',
|
||||||
|
"expires" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ApiToken_token_key" ON "ApiToken"("token");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "ApiToken" DROP CONSTRAINT "ApiToken_userId_fkey";
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ApiToken" ALTER COLUMN "expires" DROP NOT NULL;
|
||||||
@ -43,9 +43,9 @@ model User {
|
|||||||
twoFactorSecret String?
|
twoFactorSecret String?
|
||||||
twoFactorEnabled Boolean @default(false)
|
twoFactorEnabled Boolean @default(false)
|
||||||
twoFactorBackupCodes String?
|
twoFactorBackupCodes String?
|
||||||
|
VerificationToken VerificationToken[]
|
||||||
VerificationToken VerificationToken[]
|
ApiToken ApiToken[]
|
||||||
Template Template[]
|
Template Template[]
|
||||||
securityAuditLogs UserSecurityAuditLog[]
|
securityAuditLogs UserSecurityAuditLog[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@ -94,6 +94,21 @@ model VerificationToken {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ApiTokenAlgorithm {
|
||||||
|
SHA512
|
||||||
|
}
|
||||||
|
|
||||||
|
model ApiToken {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
token String @unique
|
||||||
|
algorithm ApiTokenAlgorithm @default(SHA512)
|
||||||
|
expires DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
userId Int
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
enum SubscriptionStatus {
|
enum SubscriptionStatus {
|
||||||
ACTIVE
|
ACTIVE
|
||||||
PAST_DUE
|
PAST_DUE
|
||||||
|
|||||||
@ -17,6 +17,8 @@
|
|||||||
"@trpc/next": "^10.36.0",
|
"@trpc/next": "^10.36.0",
|
||||||
"@trpc/react-query": "^10.36.0",
|
"@trpc/react-query": "^10.36.0",
|
||||||
"@trpc/server": "^10.36.0",
|
"@trpc/server": "^10.36.0",
|
||||||
|
"@ts-rest/core": "^3.30.5",
|
||||||
|
"@ts-rest/next": "^3.30.5",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"superjson": "^1.13.1",
|
"superjson": "^1.13.1",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
|
|||||||
81
packages/trpc/server/api-token-router/router.ts
Normal file
81
packages/trpc/server/api-token-router/router.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
|
import { deleteTokenById } from '@documenso/lib/server-only/public-api/delete-api-token-by-id';
|
||||||
|
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
|
||||||
|
import { getApiTokenById } from '@documenso/lib/server-only/public-api/get-api-token-by-id';
|
||||||
|
|
||||||
|
import { authenticatedProcedure, router } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZCreateTokenMutationSchema,
|
||||||
|
ZDeleteTokenByIdMutationSchema,
|
||||||
|
ZGetApiTokenByIdQuerySchema,
|
||||||
|
} from './schema';
|
||||||
|
|
||||||
|
export const apiTokenRouter = router({
|
||||||
|
getTokens: authenticatedProcedure.query(async ({ ctx }) => {
|
||||||
|
try {
|
||||||
|
return await getUserTokens({ userId: ctx.user.id });
|
||||||
|
} catch (e) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to find your API tokens. Please try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
getTokenById: authenticatedProcedure
|
||||||
|
.input(ZGetApiTokenByIdQuerySchema)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { id } = input;
|
||||||
|
|
||||||
|
return await getApiTokenById({
|
||||||
|
id,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to find this API token. Please try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
createToken: authenticatedProcedure
|
||||||
|
.input(ZCreateTokenMutationSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { tokenName, expirationDate } = input;
|
||||||
|
|
||||||
|
return await createApiToken({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
tokenName,
|
||||||
|
expirationDate,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to create an API token. Please try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteTokenById: authenticatedProcedure
|
||||||
|
.input(ZDeleteTokenByIdMutationSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { id } = input;
|
||||||
|
|
||||||
|
return await deleteTokenById({
|
||||||
|
id,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to delete this API Token. Please try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
20
packages/trpc/server/api-token-router/schema.ts
Normal file
20
packages/trpc/server/api-token-router/schema.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZGetApiTokenByIdQuerySchema = z.object({
|
||||||
|
id: z.number().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TGetApiTokenByIdQuerySchema = z.infer<typeof ZGetApiTokenByIdQuerySchema>;
|
||||||
|
|
||||||
|
export const ZCreateTokenMutationSchema = z.object({
|
||||||
|
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }),
|
||||||
|
expirationDate: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateTokenMutationSchema = z.infer<typeof ZCreateTokenMutationSchema>;
|
||||||
|
|
||||||
|
export const ZDeleteTokenByIdMutationSchema = z.object({
|
||||||
|
id: z.number().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TDeleteTokenByIdMutationSchema = z.infer<typeof ZDeleteTokenByIdMutationSchema>;
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { adminRouter } from './admin-router/router';
|
import { adminRouter } from './admin-router/router';
|
||||||
|
import { apiTokenRouter } from './api-token-router/router';
|
||||||
import { authRouter } from './auth-router/router';
|
import { authRouter } from './auth-router/router';
|
||||||
import { cryptoRouter } from './crypto/router';
|
import { cryptoRouter } from './crypto/router';
|
||||||
import { documentRouter } from './document-router/router';
|
import { documentRouter } from './document-router/router';
|
||||||
@ -21,6 +22,7 @@ export const appRouter = router({
|
|||||||
recipient: recipientRouter,
|
recipient: recipientRouter,
|
||||||
admin: adminRouter,
|
admin: adminRouter,
|
||||||
shareLink: shareLinkRouter,
|
shareLink: shareLinkRouter,
|
||||||
|
apiToken: apiTokenRouter,
|
||||||
singleplayer: singleplayerRouter,
|
singleplayer: singleplayerRouter,
|
||||||
team: teamRouter,
|
team: teamRouter,
|
||||||
template: templateRouter,
|
template: templateRouter,
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "@documenso/tsconfig/react-library.json",
|
"extends": "@documenso/tsconfig/react-library.json",
|
||||||
"include": ["."],
|
"include": ["."],
|
||||||
"exclude": ["dist", "build", "node_modules"]
|
"exclude": ["dist", "build", "node_modules"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user