Compare commits

..

16 Commits

Author SHA1 Message Date
Mythie
8be52e2fa3 fix: final reference to created column 2023-09-14 14:50:17 +10:00
Mythie
0d702e9189 fix: remove further references to created column 2023-09-14 13:37:38 +10:00
Mythie
425db8fc1f fix: remove references to created column 2023-09-14 13:32:16 +10:00
Mythie
2356f58e7b fix: implement feedback 2023-09-14 13:21:03 +10:00
Mythie
6c12ed4afc fix: update migration for timestamp columns 2023-09-14 13:07:55 +10:00
Lucas Smith
d76ee7f33c Merge branch 'feat/refresh' into feat/universal-upload 2023-09-14 12:53:58 +10:00
Mythie
f8534b2c3d fix: add dashboard header border on scroll 2023-09-14 12:51:59 +10:00
Mythie
9014f01276 feat: universal upload
Implementation of a universal upload allowing for multiple storage backends
starting with `database` and `s3`.

Allows clients to put and retrieve files from either client or server using
a blend of client and server actions.
2023-09-14 12:47:47 +10:00
Timur Ercan
b255eb21e5 Merge pull request #369 from documenso/fix/building-documenso-description
fix: update building documenso article description
2023-09-13 14:45:24 +02:00
Timur Ercan
9a58178ea5 Merge branch 'feat/refresh' into fix/building-documenso-description 2023-09-13 14:42:41 +02:00
Timur Ercan
3c36eedfba chore: phrasing 2023-09-13 14:42:27 +02:00
Timur Ercan
46dfaa70a3 Update apps/marketing/content/blog/building-documenso-pt1.mdx
Co-authored-by: Adithya Krishna  <aadithya794@gmail.com>
2023-09-13 14:39:01 +02:00
Lucas Smith
61da354a48 Merge pull request #361 from documenso/feat/admin-ui-metrics
feat: admin ui for metrics
2023-09-13 21:55:09 +10:00
Lucas Smith
ed4cbe9fa6 Merge branch 'feat/refresh' into feat/universal-upload 2023-09-12 20:51:31 +10:00
Timur Ercan
e8b5b3b24a fix: update building documenso article description 2023-09-11 15:22:09 +02:00
Mythie
a2ef9468ae feat: separate document data from document 2023-09-07 19:27:21 +10:00
57 changed files with 2745 additions and 385 deletions

View File

@@ -15,6 +15,20 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool. # Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
NEXT_PRIVATE_UPLOAD_ENDPOINT=
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
NEXT_PRIVATE_UPLOAD_REGION=
# REQUIRED: Defines the bucket to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_BUCKET=
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
# [[SMTP]] # [[SMTP]]
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels # OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"

View File

@@ -1,6 +1,6 @@
--- ---
title: 'Building Documenso — Part 1: Certificates' title: 'Building Documenso — Part 1: Certificates'
description: In today's fast-paced world, productivity and efficiency are crucial for success, both in personal and professional endeavors. We all strive to make the most of our time and energy to achieve our goals effectively. However, it's not always easy to stay on track and maintain peak performance. In this blog post, we'll explore 10 valuable tips to help you boost productivity and efficiency in your daily life. description: This is the first part of the new Building Documenso series, where I describe the challenges and design choices that we make while building the worlds most open signing platform.
authorName: 'Timur Ercan' authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg' authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder' authorRole: 'Co-Founder'
@@ -79,7 +79,7 @@ There werent any deeper reasons we choose WiseKey, other than they offered wh
Do you have questions or thoughts about this? As always, let me know in the comments, on <a href="http://twitter.com/eltimuro" target="_blank">twitter.com/eltimuro</a> Do you have questions or thoughts about this? As always, let me know in the comments, on <a href="http://twitter.com/eltimuro" target="_blank">twitter.com/eltimuro</a>
or directly: <a href="https://documen.so/timur" target="_blank">documen.so/timur</a> or directly: <a href="https://documen.so/timur" target="_blank">documen.so/timur</a>
Join the self-hoster community here: <a href="https://documenso.slack.com/" target="_blank">https://documenso.slack.com/</a> Join the self-hoster community here: <a href="https://documen.so/discord" target="_blank">https://documen.so/discord</a>
Best from Hamburg Best from Hamburg

View File

@@ -8,9 +8,16 @@ const { parsed: env } = require('dotenv').config({
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const config = { const config = {
experimental: {
serverActions: true,
},
reactStrictMode: true, reactStrictMode: true,
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'], transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
env, modularizeImports: {
'lucide-react': {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
},
},
}; };
module.exports = withContentlayer(config); module.exports = withContentlayer(config);

View File

@@ -8,8 +8,11 @@ import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf'; import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
import { redis } from '@documenso/lib/server-only/redis'; import { redis } from '@documenso/lib/server-only/redis';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { updateFile } from '@documenso/lib/universal/upload/update-file';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { import {
DocumentDataType,
DocumentStatus, DocumentStatus,
FieldType, FieldType,
ReadStatus, ReadStatus,
@@ -85,16 +88,34 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const now = new Date(); const now = new Date();
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
const { id: documentDataId } = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: bytes64,
initialData: bytes64,
},
});
const document = await prisma.document.create({ const document = await prisma.document.create({
data: { data: {
title: 'Documenso Supporter Pledge.pdf', title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED, status: DocumentStatus.COMPLETED,
userId: user.id, userId: user.id,
document: readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'), documentDataId,
created: now, },
include: {
documentData: true,
}, },
}); });
const { documentData } = document;
if (!documentData) {
throw new Error(`Document ${document.id} has no document data`);
}
const recipient = await prisma.recipient.create({ const recipient = await prisma.recipient.create({
data: { data: {
name: user.name ?? '', name: user.name ?? '',
@@ -121,17 +142,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}, },
}); });
let pdfData = await getFile(documentData).then((data) =>
Buffer.from(data).toString('base64'),
);
if (signatureDataUrl) { if (signatureDataUrl) {
document.document = await insertImageInPDF( pdfData = await insertImageInPDF(
document.document, pdfData,
signatureDataUrl, signatureDataUrl,
Number(field.positionX), Number(field.positionX),
Number(field.positionY), Number(field.positionY),
field.page, field.page,
); );
} else { } else {
document.document = await insertTextInPDF( pdfData = await insertTextInPDF(
document.document, pdfData,
signatureText ?? '', signatureText ?? '',
Number(field.positionX), Number(field.positionX),
Number(field.positionY), Number(field.positionY),
@@ -139,6 +164,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
); );
} }
const { data: newData } = await updateFile({
type: documentData.type,
oldData: documentData.initialData,
newData: Buffer.from(pdfData, 'base64').toString('binary'),
});
await Promise.all([ await Promise.all([
prisma.signature.create({ prisma.signature.create({
data: { data: {
@@ -148,12 +179,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
typedSignature: signatureDataUrl ? '' : signatureText, typedSignature: signatureDataUrl ? '' : signatureText,
}, },
}), }),
prisma.document.update({ prisma.documentData.update({
where: { where: {
id: document.id, id: documentData.id,
}, },
data: { data: {
document: document.document, data: newData,
}, },
}), }),
]); ]);

View File

@@ -10,6 +10,7 @@ const { parsed: env } = require('dotenv').config({
const config = { const config = {
experimental: { experimental: {
serverActions: true, serverActions: true,
serverActionsBodySizeLimit: '50mb',
}, },
reactStrictMode: true, reactStrictMode: true,
transpilePackages: [ transpilePackages: [
@@ -20,7 +21,6 @@ const config = {
'@documenso/email', '@documenso/email',
], ],
env: { env: {
...env,
APP_VERSION: version, APP_VERSION: version,
}, },
modularizeImports: { modularizeImports: {

View File

@@ -24,7 +24,6 @@
"lucide-react": "^0.214.0", "lucide-react": "^0.214.0",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"nanoid": "^4.0.2",
"next": "13.4.12", "next": "13.4.12",
"next-auth": "4.22.3", "next-auth": "4.22.3",
"next-plausible": "^3.10.1", "next-plausible": "^3.10.1",

View File

@@ -1,34 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { TCreateDocumentRequestSchema, ZCreateDocumentResponseSchema } from './types';
export const useCreateDocument = () => {
return useMutation(async ({ file }: TCreateDocumentRequestSchema) => {
const formData = new FormData();
formData.set('file', file);
const response = await fetch('/api/document/create', {
method: 'POST',
body: formData,
});
const body = await response.json();
if (response.status !== 200) {
throw new Error('Failed to create document');
}
const safeBody = ZCreateDocumentResponseSchema.safeParse(body);
if (!safeBody.success) {
throw new Error('Failed to create document');
}
if ('error' in safeBody.data) {
throw new Error(safeBody.data.error);
}
return safeBody.data;
});
};

View File

@@ -1,19 +0,0 @@
import { z } from 'zod';
export const ZCreateDocumentRequestSchema = z.object({
file: z.instanceof(File),
});
export type TCreateDocumentRequestSchema = z.infer<typeof ZCreateDocumentRequestSchema>;
export const ZCreateDocumentResponseSchema = z
.object({
id: z.number(),
})
.or(
z.object({
error: z.string(),
}),
);
export type TCreateDocumentResponseSchema = z.infer<typeof ZCreateDocumentResponseSchema>;

View File

@@ -4,7 +4,8 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Document, Field, Recipient, User } from '@documenso/prisma/client'; import { Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields'; import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
@@ -28,9 +29,10 @@ import { completeDocument } from '~/components/forms/edit-document/add-subject.a
export type EditDocumentFormProps = { export type EditDocumentFormProps = {
className?: string; className?: string;
user: User; user: User;
document: Document; document: DocumentWithData;
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
dataUrl: string;
}; };
type EditDocumentStep = 'signers' | 'fields' | 'subject'; type EditDocumentStep = 'signers' | 'fields' | 'subject';
@@ -41,14 +43,13 @@ export const EditDocumentForm = ({
recipients, recipients,
fields, fields,
user: _user, user: _user,
dataUrl,
}: EditDocumentFormProps) => { }: EditDocumentFormProps) => {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const [step, setStep] = useState<EditDocumentStep>('signers'); const [step, setStep] = useState<EditDocumentStep>('signers');
const documentUrl = `data:application/pdf;base64,${document.document}`;
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = { const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
signers: { signers: {
title: 'Add Signers', title: 'Add Signers',
@@ -151,11 +152,11 @@ export const EditDocumentForm = ({
return ( return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}> <div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card <Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7" className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer document={documentUrl} /> <LazyPDFViewer document={dataUrl} />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,20 +0,0 @@
'use client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewerProps } from '@documenso/ui/primitives/pdf-viewer';
export type LoadablePDFCard = PDFViewerProps & {
className?: string;
pdfClassName?: string;
};
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
return (
<Card className={className} gradient {...props}>
<CardContent className="p-2">
<LazyPDFViewer className={pdfClassName} {...props} />
</CardContent>
</Card>
);
};

View File

@@ -7,6 +7,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@@ -36,10 +37,16 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
userId: session.id, userId: session.id,
}).catch(() => null); }).catch(() => null);
if (!document) { if (!document || !document.documentData) {
redirect('/documents'); redirect('/documents');
} }
const { documentData } = document;
const documentDataUrl = await getFile(documentData)
.then((buffer) => Buffer.from(buffer).toString('base64'))
.then((data) => `data:application/pdf;base64,${data}`);
const [recipients, fields] = await Promise.all([ const [recipients, fields] = await Promise.all([
await getRecipientsForDocument({ await getRecipientsForDocument({
documentId, documentId,
@@ -86,12 +93,13 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
user={session} user={session}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
dataUrl={documentDataUrl}
/> />
)} )}
{document.status === InternalDocumentStatus.COMPLETED && ( {document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl"> <div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer document={`data:application/pdf;base64,${document.document}`} /> <LazyPDFViewer document={documentDataUrl} />
</div> </div>
)} )}
</div> </div>

View File

@@ -15,7 +15,10 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client'; import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/client';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -47,17 +50,26 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const onDownloadClick = () => { const onDownloadClick = async () => {
let decodedDocument = row.document; let document: DocumentWithData | null = null;
try { if (!recipient) {
decodedDocument = atob(decodedDocument); document = await trpc.document.getDocumentById.query({
} catch (err) { id: row.id,
// We're just going to ignore this error and try to download the document });
console.error(err); } else {
document = await trpc.document.getDocumentByToken.query({
token: recipient.token,
});
} }
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0))); const documentData = document?.documentData;
if (!documentData) {
return;
}
const documentBytes = await getFile(documentData);
const blob = new Blob([documentBytes], { const blob = new Blob([documentBytes], {
type: 'application/pdf', type: 'application/pdf',

View File

@@ -53,8 +53,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
columns={[ columns={[
{ {
header: 'Created', header: 'Created',
accessorKey: 'created', accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />, cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
}, },
{ {
header: 'Title', header: 'Title',

View File

@@ -39,7 +39,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
userId: user.id, userId: user.id,
status, status,
orderBy: { orderBy: {
column: 'created', column: 'createdAt',
direction: 'desc', direction: 'desc',
}, },
page, page,

View File

@@ -1,29 +1,45 @@
'use client'; 'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCreateDocument } from '~/api/document/create/fetcher';
export type UploadDocumentProps = { export type UploadDocumentProps = {
className?: string; className?: string;
}; };
export const UploadDocument = ({ className }: UploadDocumentProps) => { export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const { isLoading, mutateAsync: createDocument } = useCreateDocument(); const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
const onFileDrop = async (file: File) => { const onFileDrop = async (file: File) => {
try { try {
setIsLoading(true);
const { type, data } = await putFile(file);
const { id: documentDataId } = await createDocumentData({
type,
data,
});
const { id } = await createDocument({ const { id } = await createDocument({
file: file, title: file.name,
documentDataId,
}); });
toast({ toast({
@@ -41,6 +57,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
description: 'An error occurred while uploading your document.', description: 'An error occurred while uploading your document.',
variant: 'destructive', variant: 'destructive',
}); });
} finally {
setIsLoading(false);
} }
}; };

View File

@@ -21,16 +21,18 @@ export default async function BillingSettingsPage() {
redirect('/settings/profile'); redirect('/settings/profile');
} }
let subscription = await getSubscriptionByUserId({ userId: user.id }); const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => {
if (sub) {
return sub;
}
// If we don't have a customer record, create one as well as an empty subscription. // If we don't have a customer record, create one as well as an empty subscription.
if (!subscription?.customerId) { return createCustomer({ user });
subscription = await createCustomer({ user }); });
}
let billingPortalUrl = ''; let billingPortalUrl = '';
if (subscription?.customerId) { if (subscription.customerId) {
billingPortalUrl = await getPortalSession({ billingPortalUrl = await getPortalSession({
customerId: subscription.customerId, customerId: subscription.customerId,
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`, returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,

View File

@@ -1,44 +1,42 @@
'use client'; 'use client';
import { HTMLAttributes } from 'react'; import { HTMLAttributes, useState } from 'react';
import { Download } from 'lucide-react'; import { Download } from 'lucide-react';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentData } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & { export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
disabled?: boolean; disabled?: boolean;
fileName?: string; fileName?: string;
document?: string; documentData?: DocumentData;
}; };
export const DownloadButton = ({ export const DownloadButton = ({
className, className,
fileName, fileName,
document, documentData,
disabled, disabled,
...props ...props
}: DownloadButtonProps) => { }: DownloadButtonProps) => {
/** const { toast } = useToast();
* Convert the document from base64 to a blob and download it.
*/ const [isLoading, setIsLoading] = useState(false);
const onDownloadClick = () => {
if (!document) { const onDownloadClick = async () => {
try {
setIsLoading(true);
if (!documentData) {
return; return;
} }
let decodedDocument = document; const bytes = await getFile(documentData);
try { const blob = new Blob([bytes], {
decodedDocument = atob(document);
} catch (err) {
// We're just going to ignore this error and try to download the document
console.error(err);
}
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
const blob = new Blob([documentBytes], {
type: 'application/pdf', type: 'application/pdf',
}); });
@@ -50,6 +48,17 @@ export const DownloadButton = ({
link.click(); link.click();
window.URL.revokeObjectURL(link.href); window.URL.revokeObjectURL(link.href);
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while downloading your document.',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
}; };
return ( return (
@@ -57,8 +66,9 @@ export const DownloadButton = ({
type="button" type="button"
variant="outline" variant="outline"
className={className} className={className}
disabled={disabled || !document} disabled={disabled || !documentData}
onClick={onDownloadClick} onClick={onDownloadClick}
loading={isLoading}
{...props} {...props}
> >
<Download className="mr-2 h-5 w-5" /> <Download className="mr-2 h-5 w-5" />

View File

@@ -30,15 +30,21 @@ export default async function CompletedSigningPage({
token, token,
}).catch(() => null); }).catch(() => null);
if (!document) { if (!document || !document.documentData) {
return notFound(); return notFound();
} }
const { documentData } = document;
const [fields, recipient] = await Promise.all([ const [fields, recipient] = await Promise.all([
getFieldsForToken({ token }), getFieldsForToken({ token }),
getRecipientByToken({ token }), getRecipientByToken({ token }).catch(() => null),
]); ]);
if (!recipient) {
return notFound();
}
const recipientName = const recipientName =
recipient.name || recipient.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText || fields.find((field) => field.type === FieldType.NAME)?.customText ||
@@ -91,7 +97,7 @@ export default async function CompletedSigningPage({
<DownloadButton <DownloadButton
className="flex-1" className="flex-1"
fileName={document.title} fileName={document.title}
document={document.status === DocumentStatus.COMPLETED ? document.document : undefined} documentData={documentData}
disabled={document.status !== DocumentStatus.COMPLETED} disabled={document.status !== DocumentStatus.COMPLETED}
/> />
</div> </div>

View File

@@ -8,6 +8,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { FieldType } from '@documenso/prisma/client'; import { FieldType } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@@ -36,17 +37,21 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
token, token,
}).catch(() => null), }).catch(() => null),
getFieldsForToken({ token }), getFieldsForToken({ token }),
getRecipientByToken({ token }), getRecipientByToken({ token }).catch(() => null),
viewedDocument({ token }), viewedDocument({ token }),
]); ]);
if (!document) { if (!document || !document.documentData || !recipient) {
return notFound(); return notFound();
} }
const user = await getServerComponentSession(); const { documentData } = document;
const documentUrl = `data:application/pdf;base64,${document.document}`; const documentDataUrl = await getFile(documentData)
.then((buffer) => Buffer.from(buffer).toString('base64'))
.then((data) => `data:application/pdf;base64,${data}`);
const user = await getServerComponentSession();
return ( return (
<SigningProvider email={recipient.email} fullName={recipient.name} signature={user?.signature}> <SigningProvider email={recipient.email} fullName={recipient.name} signature={user?.signature}>
@@ -67,7 +72,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer document={documentUrl} /> <LazyPDFViewer document={documentDataUrl} />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { HTMLAttributes } from 'react'; import { HTMLAttributes, useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
@@ -17,10 +17,23 @@ export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
}; };
export const Header = ({ className, user, ...props }: HeaderProps) => { export const Header = ({ className, user, ...props }: HeaderProps) => {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return ( return (
<header <header
className={cn( className={cn(
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b backdrop-blur', 'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
scrollY > 5 && 'border-b-border',
className, className,
)} )}
{...props} {...props}

View File

@@ -1,88 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next';
import formidable, { type File } from 'formidable';
import { readFileSync } from 'fs';
import { getServerSession } from '@documenso/lib/next-auth/get-server-session';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import {
TCreateDocumentRequestSchema,
TCreateDocumentResponseSchema,
} from '~/api/document/create/types';
export const config = {
api: {
bodyParser: false,
},
};
export type TFormidableCreateDocumentRequestSchema = {
file: File;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<TCreateDocumentResponseSchema>,
) {
const user = await getServerSession({ req, res });
if (!user) {
return res.status(401).json({
error: 'Unauthorized',
});
}
try {
const form = formidable();
const { file } = await new Promise<TFormidableCreateDocumentRequestSchema>(
(resolve, reject) => {
form.parse(req, (err, fields, files) => {
if (err) {
reject(err);
}
// We had intended to do this with Zod but we can only validate it
// as a persistent file which does not include the properties that we
// need.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
resolve({ ...fields, ...files } as any);
});
},
);
const fileBuffer = readFileSync(file.filepath);
const document = await prisma.document.create({
data: {
title: file.originalFilename ?? file.newFilename,
status: DocumentStatus.DRAFT,
userId: user.id,
document: fileBuffer.toString('base64'),
created: new Date(),
},
});
return res.status(200).json({
id: document.id,
});
} catch (err) {
console.error(err);
return res.status(500).json({
error: 'Internal server error',
});
}
}
/**
* This is a hack to ensure that the types are correct.
*/
type FormidableSatisfiesCreateDocument =
keyof TCreateDocumentRequestSchema extends keyof TFormidableCreateDocumentRequestSchema
? true
: never;
true satisfies FormidableSatisfiesCreateDocument;

View File

@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { nanoid } from 'nanoid';
import { JWT, getToken } from 'next-auth/jwt'; import { JWT, getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags'; import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import { nanoid } from '@documenso/lib/universal/id';
import PostHogServerClient from '~/helpers/get-post-hog-server-client'; import PostHogServerClient from '~/helpers/get-post-hog-server-client';

View File

@@ -10,6 +10,7 @@ import { redis } from '@documenso/lib/server-only/redis';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { import {
DocumentDataType,
DocumentStatus, DocumentStatus,
FieldType, FieldType,
ReadStatus, ReadStatus,
@@ -85,16 +86,34 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const now = new Date(); const now = new Date();
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
const { id: documentDataId } = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: bytes64,
initialData: bytes64,
},
});
const document = await prisma.document.create({ const document = await prisma.document.create({
data: { data: {
title: 'Documenso Supporter Pledge.pdf', title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED, status: DocumentStatus.COMPLETED,
userId: user.id, userId: user.id,
document: readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'), documentDataId,
created: now, },
include: {
documentData: true,
}, },
}); });
const { documentData } = document;
if (!documentData) {
throw new Error(`Document ${document.id} has no document data`);
}
const recipient = await prisma.recipient.create({ const recipient = await prisma.recipient.create({
data: { data: {
name: user.name ?? '', name: user.name ?? '',
@@ -122,16 +141,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}); });
if (signatureDataUrl) { if (signatureDataUrl) {
document.document = await insertImageInPDF( documentData.data = await insertImageInPDF(
document.document, documentData.data,
signatureDataUrl, signatureDataUrl,
field.positionX.toNumber(), field.positionX.toNumber(),
field.positionY.toNumber(), field.positionY.toNumber(),
field.page, field.page,
); );
} else { } else {
document.document = await insertTextInPDF( documentData.data = await insertTextInPDF(
document.document, documentData.data,
signatureText ?? '', signatureText ?? '',
field.positionX.toNumber(), field.positionX.toNumber(),
field.positionY.toNumber(), field.positionY.toNumber(),
@@ -153,7 +172,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: document.id, id: document.id,
}, },
data: { data: {
document: document.document, documentData: {
update: {
data: documentData.data,
},
},
}, },
}), }),
]); ]);

1808
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,13 +16,12 @@
"worker:test": "tsup worker/index.ts --format esm" "worker:test": "tsup worker/index.ts --format esm"
}, },
"dependencies": { "dependencies": {
"@documenso/tsconfig": "*",
"@documenso/tailwind-config": "*",
"@documenso/ui": "*",
"@react-email/components": "^0.0.7", "@react-email/components": "^0.0.7",
"nodemailer": "^6.9.3" "nodemailer": "^6.9.3"
}, },
"devDependencies": { "devDependencies": {
"@documenso/tsconfig": "*",
"@documenso/tailwind-config": "*",
"@types/nodemailer": "^6.4.8", "@types/nodemailer": "^6.4.8",
"tsup": "^7.1.0" "tsup": "^7.1.0"
} }

View File

@@ -4,8 +4,5 @@ const path = require('path');
module.exports = { module.exports = {
...baseConfig, ...baseConfig,
content: [ content: [`templates/**/*.{ts,tsx}`],
`templates/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
],
}; };

View File

@@ -0,0 +1,5 @@
export const ONE_SECOND = 1000;
export const ONE_MINUTE = ONE_SECOND * 60;
export const ONE_HOUR = ONE_MINUTE * 60;
export const ONE_DAY = ONE_HOUR * 24;
export const ONE_WEEK = ONE_DAY * 7;

View File

@@ -12,10 +12,15 @@
], ],
"scripts": {}, "scripts": {},
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
"@aws-sdk/signature-v4-crt": "^3.410.0",
"@documenso/email": "*", "@documenso/email": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@next-auth/prisma-adapter": "1.0.7", "@next-auth/prisma-adapter": "1.0.7",
"@pdf-lib/fontkit": "^1.1.1", "@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@upstash/redis": "^1.20.6", "@upstash/redis": "^1.20.6",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"luxon": "^3.4.0", "luxon": "^3.4.0",

View File

@@ -0,0 +1,19 @@
'use server';
import { prisma } from '@documenso/prisma';
import { DocumentDataType } from '@documenso/prisma/client';
export type CreateDocumentDataOptions = {
type: DocumentDataType;
data: string;
};
export const createDocumentData = async ({ type, data }: CreateDocumentDataOptions) => {
return await prisma.documentData.create({
data: {
type,
data,
initialData: data,
},
});
};

View File

@@ -0,0 +1,19 @@
'use server';
import { prisma } from '@documenso/prisma';
export type CreateDocumentOptions = {
title: string;
userId: number;
documentDataId: string;
};
export const createDocument = async ({ userId, title, documentDataId }: CreateDocumentOptions) => {
return await prisma.document.create({
data: {
title,
documentDataId,
userId,
},
});
};

View File

@@ -32,7 +32,7 @@ export const findDocuments = async ({
}, },
}); });
const orderByColumn = orderBy?.column ?? 'created'; const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc'; const orderByDirection = orderBy?.direction ?? 'desc';
const termFilters = !term const termFilters = !term

View File

@@ -11,5 +11,8 @@ export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) =>
id, id,
userId, userId,
}, },
include: {
documentData: true,
},
}); });
}; };

View File

@@ -17,6 +17,7 @@ export const getDocumentAndSenderByToken = async ({
}, },
include: { include: {
User: true, User: true,
documentData: true,
}, },
}); });

View File

@@ -1,10 +1,13 @@
'use server'; 'use server';
import path from 'node:path';
import { PDFDocument } from 'pdf-lib'; import { PDFDocument } from 'pdf-lib';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
export type SealDocumentOptions = { export type SealDocumentOptions = {
@@ -18,8 +21,17 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
where: { where: {
id: documentId, id: documentId,
}, },
include: {
documentData: true,
},
}); });
const { documentData } = document;
if (!documentData) {
throw new Error(`Document ${document.id} has no document data`);
}
if (document.status !== DocumentStatus.COMPLETED) { if (document.status !== DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has not been completed`); throw new Error(`Document ${document.id} has not been completed`);
} }
@@ -48,7 +60,7 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
} }
// !: Need to write the fields onto the document as a hard copy // !: Need to write the fields onto the document as a hard copy
const { document: pdfData } = document; const pdfData = await getFile(documentData);
const doc = await PDFDocument.load(pdfData); const doc = await PDFDocument.load(pdfData);
@@ -58,13 +70,20 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
const pdfBytes = await doc.save(); const pdfBytes = await doc.save();
await prisma.document.update({ const { name, ext } = path.parse(document.title);
const { data: newData } = await putFile({
name: `${name}_signed${ext}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(Buffer.from(pdfBytes)),
});
await prisma.documentData.update({
where: { where: {
id: document.id, id: documentData.id,
status: DocumentStatus.COMPLETED,
}, },
data: { data: {
document: Buffer.from(pdfBytes).toString('base64'), data: newData,
}, },
}); });
}; };

View File

@@ -1,8 +1,8 @@
import { nanoid } from 'nanoid';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { nanoid } from '../../universal/id';
export interface SetRecipientsForDocumentOptions { export interface SetRecipientsForDocumentOptions {
userId: number; userId: number;
documentId: number; documentId: number;

View File

@@ -1,5 +1,8 @@
{ {
"extends": "@documenso/tsconfig/react-library.json", "extends": "@documenso/tsconfig/react-library.json",
"compilerOptions": {
"types": ["@documenso/tsconfig/process-env.d.ts"]
},
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"], "include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
"exclude": ["dist", "build", "node_modules"] "exclude": ["dist", "build", "node_modules"]
} }

View File

@@ -0,0 +1,5 @@
import { customAlphabet } from 'nanoid';
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10);
export { nanoid } from 'nanoid';

View File

@@ -0,0 +1,22 @@
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
import { deleteS3File } from './server-actions';
export type DeleteFileOptions = {
type: DocumentDataType;
data: string;
};
export const deleteFile = async ({ type, data }: DeleteFileOptions) => {
return await match(type)
.with(DocumentDataType.S3_PATH, async () => deleteFileFromS3(data))
.otherwise(() => {
return;
});
};
const deleteFileFromS3 = async (key: string) => {
await deleteS3File(key);
};

View File

@@ -0,0 +1,51 @@
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
import { getPresignGetUrl } from './server-actions';
export type GetFileOptions = {
type: DocumentDataType;
data: string;
};
export const getFile = async ({ type, data }: GetFileOptions) => {
return await match(type)
.with(DocumentDataType.BYTES, () => getFileFromBytes(data))
.with(DocumentDataType.BYTES_64, () => getFileFromBytes64(data))
.with(DocumentDataType.S3_PATH, async () => getFileFromS3(data))
.exhaustive();
};
const getFileFromBytes = (data: string) => {
const encoder = new TextEncoder();
const binaryData = encoder.encode(data);
return binaryData;
};
const getFileFromBytes64 = (data: string) => {
const binaryData = base64.decode(data);
return binaryData;
};
const getFileFromS3 = async (key: string) => {
const { url } = await getPresignGetUrl(key);
const response = await fetch(url, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`Failed to get file "${key}", failed with status code ${response.status}`);
}
const buffer = await response.arrayBuffer();
const binaryData = new Uint8Array(buffer);
return binaryData;
};

View File

@@ -0,0 +1,59 @@
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
import { createDocumentData } from '../../server-only/document-data/create-document-data';
import { getPresignPostUrl } from './server-actions';
type File = {
name: string;
type: string;
arrayBuffer: () => Promise<ArrayBuffer>;
};
export const putFile = async (file: File) => {
const { type, data } = await match(process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file))
.otherwise(async () => putFileInDatabase(file));
return await createDocumentData({ type, data });
};
const putFileInDatabase = async (file: File) => {
const contents = await file.arrayBuffer();
const binaryData = new Uint8Array(contents);
const asciiData = base64.encode(binaryData);
return {
type: DocumentDataType.BYTES_64,
data: asciiData,
};
};
const putFileInS3 = async (file: File) => {
const { url, key } = await getPresignPostUrl(file.name, file.type);
const body = await file.arrayBuffer();
const reponse = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
},
body,
});
if (!reponse.ok) {
throw new Error(
`Failed to upload file "${file.name}", failed with status code ${reponse.status}`,
);
}
return {
type: DocumentDataType.S3_PATH,
data: key,
};
};

View File

@@ -0,0 +1,104 @@
'use server';
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import slugify from '@sindresorhus/slugify';
import path from 'node:path';
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
import { getServerComponentSession } from '../../next-auth/get-server-session';
import { alphaid } from '../id';
export const getPresignPostUrl = async (fileName: string, contentType: string) => {
const client = getS3Client();
const user = await getServerComponentSession();
// Get the basename and extension for the file
const { name, ext } = path.parse(fileName);
let key = `${alphaid(12)}/${slugify(name)}${ext}`;
if (user) {
key = `${user.id}/${key}`;
}
const putObjectCommand = new PutObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(client, putObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
};
export const getAbsolutePresignPostUrl = async (key: string) => {
const client = getS3Client();
const putObjectCommand = new PutObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key,
});
const url = await getSignedUrl(client, putObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
};
export const getPresignGetUrl = async (key: string) => {
const client = getS3Client();
const getObjectCommand = new GetObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key,
});
const url = await getSignedUrl(client, getObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
};
export const deleteS3File = async (key: string) => {
const client = getS3Client();
await client.send(
new DeleteObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key,
}),
);
};
const getS3Client = () => {
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
throw new Error('Invalid upload transport');
}
const hasCredentials =
process.env.NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID &&
process.env.NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY;
return new S3Client({
endpoint: process.env.NEXT_PRIVATE_UPLOAD_ENDPOINT || undefined,
region: process.env.NEXT_PRIVATE_UPLOAD_REGION || 'us-east-1',
credentials: hasCredentials
? {
accessKeyId: String(process.env.NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID),
secretAccessKey: String(process.env.NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY),
}
: undefined,
});
};

View File

@@ -0,0 +1,58 @@
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
import { getAbsolutePresignPostUrl } from './server-actions';
export type UpdateFileOptions = {
type: DocumentDataType;
oldData: string;
newData: string;
};
export const updateFile = async ({ type, oldData, newData }: UpdateFileOptions) => {
return await match(type)
.with(DocumentDataType.BYTES, () => updateFileWithBytes(newData))
.with(DocumentDataType.BYTES_64, () => updateFileWithBytes64(newData))
.with(DocumentDataType.S3_PATH, async () => updateFileWithS3(oldData, newData))
.exhaustive();
};
const updateFileWithBytes = (data: string) => {
return {
type: DocumentDataType.BYTES,
data,
};
};
const updateFileWithBytes64 = (data: string) => {
const encoder = new TextEncoder();
const binaryData = encoder.encode(data);
const asciiData = base64.encode(binaryData);
return {
type: DocumentDataType.BYTES_64,
data: asciiData,
};
};
const updateFileWithS3 = async (key: string, data: string) => {
const { url } = await getAbsolutePresignPostUrl(key);
const response = await fetch(url, {
method: 'PUT',
body: data,
});
if (!response.ok) {
throw new Error(`Failed to update file "${key}", failed with status code ${response.status}`);
}
return {
type: DocumentDataType.S3_PATH,
data: key,
};
};

View File

@@ -0,0 +1,19 @@
-- CreateEnum
CREATE TYPE "DocumentDataType" AS ENUM ('S3_PATH', 'BYTES', 'BYTES_64');
-- CreateTable
CREATE TABLE "DocumentData" (
"id" TEXT NOT NULL,
"type" "DocumentDataType" NOT NULL,
"data" TEXT NOT NULL,
"initialData" TEXT NOT NULL,
"documentId" INTEGER NOT NULL,
CONSTRAINT "DocumentData_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "DocumentData_documentId_key" ON "DocumentData"("documentId");
-- AddForeignKey
ALTER TABLE "DocumentData" ADD CONSTRAINT "DocumentData_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,14 @@
INSERT INTO
"DocumentData" ("id", "type", "data", "initialData", "documentId") (
SELECT
CAST(gen_random_uuid() AS TEXT),
'BYTES_64',
d."document",
d."document",
d."id"
FROM
"Document" d
WHERE
d."id" IS NOT NULL
AND d."document" IS NOT NULL
);

View File

@@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "createdAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "updatedAt" TIMESTAMP(3);
-- DefaultValues
UPDATE "Document"
SET
"createdAt" = COALESCE("created"::TIMESTAMP, NOW()),
"updatedAt" = COALESCE("created"::TIMESTAMP, NOW());
-- AlterColumn
ALTER TABLE "Document" ALTER COLUMN "createdAt" SET DEFAULT NOW();
ALTER TABLE "Document" ALTER COLUMN "createdAt" SET NOT NULL;
-- AlterColumn
ALTER TABLE "Document" ALTER COLUMN "updatedAt" SET DEFAULT NOW();
ALTER TABLE "Document" ALTER COLUMN "updatedAt" SET NOT NULL;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `document` on the `Document` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Document" DROP COLUMN "document";

View File

@@ -0,0 +1,23 @@
-- DropForeignKey
ALTER TABLE "DocumentData" DROP CONSTRAINT "DocumentData_documentId_fkey";
-- DropIndex
DROP INDEX "DocumentData_documentId_key";
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "documentDataId" TEXT;
-- Reverse relation foreign key ids
UPDATE "Document" SET "documentDataId" = "DocumentData"."id" FROM "DocumentData" WHERE "Document"."id" = "DocumentData"."documentId";
-- AlterColumn
ALTER TABLE "Document" ALTER COLUMN "documentDataId" SET NOT NULL;
-- AlterTable
ALTER TABLE "DocumentData" DROP COLUMN "documentId";
-- CreateIndex
CREATE UNIQUE INDEX "Document_documentDataId_key" ON "Document"("documentDataId");
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_documentDataId_fkey" FOREIGN KEY ("documentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `created` on the `Document` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Document" DROP COLUMN "created";

View File

@@ -92,14 +92,32 @@ enum DocumentStatus {
model Document { model Document {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
created DateTime @default(now())
userId Int userId Int
User User @relation(fields: [userId], references: [id], onDelete: Cascade) User User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String title String
status DocumentStatus @default(DRAFT) status DocumentStatus @default(DRAFT)
document String
Recipient Recipient[] Recipient Recipient[]
Field Field[] Field Field[]
documentDataId String
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@unique([documentDataId])
}
enum DocumentDataType {
S3_PATH
BYTES
BYTES_64
}
model DocumentData {
id String @id @default(cuid())
type DocumentDataType
data String
initialData String
Document Document?
} }
enum ReadStatus { enum ReadStatus {

View File

@@ -0,0 +1,5 @@
import { Document, DocumentData } from '@documenso/prisma/client';
export type DocumentWithData = Document & {
documentData?: DocumentData | null;
};

View File

@@ -1,17 +1,81 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { authenticatedProcedure, router } from '../trpc'; import { authenticatedProcedure, procedure, router } from '../trpc';
import { import {
ZCreateDocumentMutationSchema,
ZGetDocumentByIdQuerySchema,
ZGetDocumentByTokenQuerySchema,
ZSendDocumentMutationSchema, ZSendDocumentMutationSchema,
ZSetFieldsForDocumentMutationSchema, ZSetFieldsForDocumentMutationSchema,
ZSetRecipientsForDocumentMutationSchema, ZSetRecipientsForDocumentMutationSchema,
} from './schema'; } from './schema';
export const documentRouter = router({ export const documentRouter = router({
getDocumentById: authenticatedProcedure
.input(ZGetDocumentByIdQuerySchema)
.query(async ({ input, ctx }) => {
try {
const { id } = input;
return await getDocumentById({
id,
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find this document. Please try again later.',
});
}
}),
getDocumentByToken: procedure.input(ZGetDocumentByTokenQuerySchema).query(async ({ input }) => {
try {
const { token } = input;
return await getDocumentAndSenderByToken({
token,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find this document. Please try again later.',
});
}
}),
createDocument: authenticatedProcedure
.input(ZCreateDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { title, documentDataId } = input;
return await createDocument({
userId: ctx.user.id,
title,
documentDataId,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this document. Please try again later.',
});
}
}),
setRecipientsForDocument: authenticatedProcedure setRecipientsForDocument: authenticatedProcedure
.input(ZSetRecipientsForDocumentMutationSchema) .input(ZSetRecipientsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {

View File

@@ -2,6 +2,25 @@ import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client'; import { FieldType } from '@documenso/prisma/client';
export const ZGetDocumentByIdQuerySchema = z.object({
id: z.number().min(1),
});
export type TGetDocumentByIdQuerySchema = z.infer<typeof ZGetDocumentByIdQuerySchema>;
export const ZGetDocumentByTokenQuerySchema = z.object({
token: z.string().min(1),
});
export type TGetDocumentByTokenQuerySchema = z.infer<typeof ZGetDocumentByTokenQuerySchema>;
export const ZCreateDocumentMutationSchema = z.object({
title: z.string().min(1),
documentDataId: z.string().min(1),
});
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
export const ZSetRecipientsForDocumentMutationSchema = z.object({ export const ZSetRecipientsForDocumentMutationSchema = z.object({
documentId: z.number(), documentId: z.number(),
recipients: z.array( recipients: z.array(

View File

@@ -13,6 +13,13 @@ declare namespace NodeJS {
NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
NEXT_PUBLIC_UPLOAD_TRANSPORT?: 'database' | 's3';
NEXT_PRIVATE_UPLOAD_ENDPOINT?: string;
NEXT_PRIVATE_UPLOAD_REGION?: string;
NEXT_PRIVATE_UPLOAD_BUCKET?: string;
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID?: string;
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY?: string;
NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'smtp-auth' | 'smtp-api'; NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'smtp-auth' | 'smtp-api';
NEXT_PRIVATE_MAILCHANNELS_API_KEY?: string; NEXT_PRIVATE_MAILCHANNELS_API_KEY?: string;

View File

@@ -15,6 +15,7 @@
"lint": "eslint \"**/*.ts*\"" "lint": "eslint \"**/*.ts*\""
}, },
"devDependencies": { "devDependencies": {
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*", "@documenso/tsconfig": "*",
"@types/react": "18.2.18", "@types/react": "18.2.18",
"@types/react-dom": "18.2.7", "@types/react-dom": "18.2.7",
@@ -22,6 +23,7 @@
"typescript": "^5.1.6" "typescript": "^5.1.6"
}, },
"dependencies": { "dependencies": {
"@documenso/lib": "*",
"@radix-ui/react-accordion": "^1.1.1", "@radix-ui/react-accordion": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.0.3", "@radix-ui/react-alert-dialog": "^1.0.3",
"@radix-ui/react-aspect-ratio": "^1.0.2", "@radix-ui/react-aspect-ratio": "^1.0.2",
@@ -51,7 +53,6 @@
"class-variance-authority": "^0.6.0", "class-variance-authority": "^0.6.0",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"cmdk": "^0.2.0", "cmdk": "^0.2.0",
"date-fns": "^2.30.0",
"framer-motion": "^10.12.8", "framer-motion": "^10.12.8",
"lucide-react": "^0.214.0", "lucide-react": "^0.214.0",
"next": "13.4.12", "next": "13.4.12",

View File

@@ -5,12 +5,12 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Caveat } from 'next/font/google'; import { Caveat } from 'next/font/google';
import { Check, ChevronsUpDown, Info } from 'lucide-react'; import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { nanoid } from 'nanoid';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { nanoid } from '@documenso/lib/universal/id';
import { Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client'; import { Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';

View File

@@ -5,9 +5,9 @@ import React, { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react'; import { Plus, Trash } from 'lucide-react';
import { nanoid } from 'nanoid';
import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { nanoid } from '@documenso/lib/universal/id';
import { Field, Recipient, SendStatus } from '@documenso/prisma/client'; import { Field, Recipient, SendStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';

View File

@@ -27,6 +27,12 @@
"NEXT_PRIVATE_NEXT_AUTH_SECRET", "NEXT_PRIVATE_NEXT_AUTH_SECRET",
"NEXT_PRIVATE_GOOGLE_CLIENT_ID", "NEXT_PRIVATE_GOOGLE_CLIENT_ID",
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET", "NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
"NEXT_PUBLIC_UPLOAD_TRANSPORT",
"NEXT_PRIVATE_UPLOAD_ENDPOINT",
"NEXT_PRIVATE_UPLOAD_REGION",
"NEXT_PRIVATE_UPLOAD_BUCKET",
"NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID",
"NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY",
"NEXT_PRIVATE_SMTP_TRANSPORT", "NEXT_PRIVATE_SMTP_TRANSPORT",
"NEXT_PRIVATE_MAILCHANNELS_API_KEY", "NEXT_PRIVATE_MAILCHANNELS_API_KEY",
"NEXT_PRIVATE_MAILCHANNELS_ENDPOINT", "NEXT_PRIVATE_MAILCHANNELS_ENDPOINT",