mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
Merge pull request #357 from documenso/feat/universal-upload
feat: universal upload
This commit is contained in:
14
.env.example
14
.env.example
@ -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"
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -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>;
|
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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
1808
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}`,
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
5
packages/lib/constants/time.ts
Normal file
5
packages/lib/constants/time.ts
Normal 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;
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
19
packages/lib/server-only/document/create-document.ts
Normal file
19
packages/lib/server-only/document/create-document.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -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
|
||||||
|
|||||||
@ -11,5 +11,8 @@ export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) =>
|
|||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
documentData: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export const getDocumentAndSenderByToken = async ({
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
User: true,
|
User: true,
|
||||||
|
documentData: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
5
packages/lib/universal/id.ts
Normal file
5
packages/lib/universal/id.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { customAlphabet } from 'nanoid';
|
||||||
|
|
||||||
|
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10);
|
||||||
|
|
||||||
|
export { nanoid } from 'nanoid';
|
||||||
22
packages/lib/universal/upload/delete-file.ts
Normal file
22
packages/lib/universal/upload/delete-file.ts
Normal 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);
|
||||||
|
};
|
||||||
51
packages/lib/universal/upload/get-file.ts
Normal file
51
packages/lib/universal/upload/get-file.ts
Normal 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;
|
||||||
|
};
|
||||||
59
packages/lib/universal/upload/put-file.ts
Normal file
59
packages/lib/universal/upload/put-file.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
104
packages/lib/universal/upload/server-actions.ts
Normal file
104
packages/lib/universal/upload/server-actions.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
58
packages/lib/universal/upload/update-file.ts
Normal file
58
packages/lib/universal/upload/update-file.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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;
|
||||||
@ -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
|
||||||
|
);
|
||||||
@ -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;
|
||||||
@ -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";
|
||||||
@ -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;
|
||||||
@ -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";
|
||||||
@ -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 {
|
||||||
|
|||||||
5
packages/prisma/types/document-with-data.ts
Normal file
5
packages/prisma/types/document-with-data.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Document, DocumentData } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type DocumentWithData = Document & {
|
||||||
|
documentData?: DocumentData | null;
|
||||||
|
};
|
||||||
@ -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 }) => {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
7
packages/tsconfig/process-env.d.ts
vendored
7
packages/tsconfig/process-env.d.ts
vendored
@ -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;
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user