Compare commits

..

22 Commits

Author SHA1 Message Date
pit
a03e74d660 feat: chat with pdf 2023-10-25 09:29:34 +03:00
1c34eddd10 chore: add prisma studio command (#576)
Co-authored-by: pit <pit@pits-MacBook-Pro.local>
2023-10-19 09:19:46 +03:00
1fbf6ed4ba fix: add mode to checkout session 2023-10-19 12:27:01 +11:00
1d6f7f9e37 fix(bug): name field can be updated with spaces #555 (#558) 2023-10-19 12:12:56 +11:00
e3c3ec7825 fix: downgrade react-pdf 2023-10-18 23:51:16 +11:00
08d176c803 Merge pull request #575 from 18feb06/fix-error-invalid-url-on-main-page-on-self-host-#573
fix: error invalid url on main page on self host #573
2023-10-18 23:20:22 +11:00
b952ed9035 fix: support multi env 2023-10-18 23:19:29 +11:00
43062dda12 chore: upgrade to latest next.js version (#553)
* chore: upgrade next.js
* fix: canvas not found error
* chore: upgrade package for marketing
* feat: add isServer conditional
* fix: inverse isServer condition
* fix: normalize packages
* fix: upgrade ee package
* fix: depdency nightmares
* fix: failing seed script
2023-10-18 22:33:02 +11:00
1b53ff9c2d Merge branch 'documenso:feat/refresh' into fix-error-invalid-url-on-main-page-on-self-host-#573 2023-10-18 16:30:57 +05:00
acd3e6d613 fix: invalid url on main page in self host 2023-10-18 16:28:57 +05:00
e33b02df56 fix: truncate long file name in admin dashboard (#572)
Co-authored-by: pit <pit@192-168-0-136.rdsnet.ro>
2023-10-18 08:58:35 +03:00
2c6849ca76 fix: email requesting signature shows "completed document" in preview… (#514) 2023-10-18 15:32:39 +11:00
9434f9e2e4 fix: support mailto link fix (#571) 2023-10-17 17:27:02 +11:00
f6daef7333 Merge pull request #566 from documenso/feat/plan-limits
feat: plan limits
2023-10-17 14:30:18 +11:00
c3df8d4c2a fix: add redirects for v0.9 requests 2023-10-17 13:50:54 +11:00
4b09693862 feat: add safari clipboard copy support (#486) 2023-10-17 12:40:36 +11:00
8d2e50d1fe fix: user avatar on admin documents table (#570)
Co-authored-by: pit <pit@pits-MacBook-Pro.local>
2023-10-16 17:36:12 +03:00
bfc749f30b fix: fix for Accepting signatures or text fields with white space only #551 (#557) 2023-10-16 20:08:45 +11:00
e0d4255700 fix: enable dragging fields (#565) 2023-10-16 19:50:28 +11:00
6ba4ff1c17 fix: build errors 2023-10-16 17:38:41 +11:00
652af26754 fix: exports on next page 2023-10-15 20:32:36 +11:00
093488a67c feat: plan limits 2023-10-15 20:26:32 +11:00
75 changed files with 6861 additions and 4533 deletions

1
.gitignore vendored
View File

@ -31,6 +31,7 @@ yarn-error.log*
# turbo # turbo
.turbo .turbo
.turbo-cookie
# vercel # vercel
.vercel .vercel

View File

@ -2,8 +2,12 @@
const path = require('path'); const path = require('path');
const { withContentlayer } = require('next-contentlayer'); const { withContentlayer } = require('next-contentlayer');
require('dotenv').config({ const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
path: path.join(__dirname, '../../.env.local'),
ENV_FILES.forEach((file) => {
require('dotenv').config({
path: path.join(__dirname, `../../${file}`),
});
}); });
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
@ -22,6 +26,14 @@ const config = {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}', transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
}, },
}, },
webpack: (config, { isServer }) => {
// fixes: Module not found: Cant resolve ../build/Release/canvas.node
if (isServer) {
config.resolve.alias.canvas = false;
}
return config;
},
async headers() { async headers() {
return [ return [
{ {

View File

@ -21,7 +21,7 @@
"framer-motion": "^10.12.8", "framer-motion": "^10.12.8",
"lucide-react": "^0.279.0", "lucide-react": "^0.279.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "13.4.19", "next": "13.5.4",
"next-auth": "4.22.3", "next-auth": "4.22.3",
"next-contentlayer": "^0.3.4", "next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1", "next-plausible": "^3.10.1",
@ -34,7 +34,7 @@
"react-icons": "^4.11.0", "react-icons": "^4.11.0",
"recharts": "^2.7.2", "recharts": "^2.7.2",
"sharp": "0.32.5", "sharp": "0.32.5",
"typescript": "5.1.6", "typescript": "5.2.2",
"zod": "^3.21.4" "zod": "^3.21.4"
}, },
"devDependencies": { "devDependencies": {

View File

@ -0,0 +1,247 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { Field, Prisma, Recipient } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
type SinglePlayerModeStep = 'fields' | 'sign';
// !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during
// !: the upgrade of Next.js to v13.5.x.
export const SinglePlayerClient = () => {
const analytics = useAnalytics();
const router = useRouter();
const { toast } = useToast();
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
const [fields, setFields] = useState<Field[]>([]);
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
fields: {
title: 'Add document',
description: 'Upload a document and add fields.',
stepIndex: 1,
onBackStep: uploadedFile
? () => {
setUploadedFile(null);
setFields([]);
}
: undefined,
onNextStep: () => setStep('sign'),
},
sign: {
title: 'Sign',
description: 'Enter your details.',
stepIndex: 2,
onBackStep: () => setStep('fields'),
},
};
const currentDocumentFlow = documentFlow[step];
useEffect(() => {
analytics.startSessionRecording('marketing_session_recording_spm');
return () => {
analytics.stopSessionRecording();
};
}, [analytics]);
/**
* Insert the selected fields into the local state.
*/
const onFieldsSubmit = (data: TAddFieldsFormSchema) => {
if (!uploadedFile) {
return;
}
setFields(
data.fields.map((field, i) => ({
id: i,
documentId: -1,
recipientId: -1,
type: field.type,
page: field.pageNumber,
positionX: new Prisma.Decimal(field.pageX),
positionY: new Prisma.Decimal(field.pageY),
width: new Prisma.Decimal(field.pageWidth),
height: new Prisma.Decimal(field.pageHeight),
customText: '',
inserted: false,
})),
);
analytics.capture('Marketing: SPM - Fields added');
documentFlow.fields.onNextStep?.();
};
/**
* Upload, create, sign and send the document.
*/
const onSignSubmit = async (data: TAddSignatureFormSchema) => {
if (!uploadedFile) {
return;
}
try {
const putFileData = await putFile(uploadedFile.file);
const documentToken = await createSinglePlayerDocument({
documentData: {
type: putFileData.type,
data: putFileData.data,
},
documentName: uploadedFile.file.name,
signer: data,
fields: fields.map((field) => ({
page: field.page,
type: field.type,
positionX: field.positionX.toNumber(),
positionY: field.positionY.toNumber(),
width: field.width.toNumber(),
height: field.height.toNumber(),
})),
});
analytics.capture('Marketing: SPM - Document signed', {
signer: data.email,
});
router.push(`/singleplayer/${documentToken}/success`);
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
const placeholderRecipient: Recipient = {
id: -1,
documentId: -1,
email: '',
name: '',
token: '',
expired: null,
signedAt: null,
readStatus: 'OPENED',
signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT',
};
const onFileDrop = async (file: File) => {
try {
const arrayBuffer = await file.arrayBuffer();
const base64String = base64.encode(new Uint8Array(arrayBuffer));
setUploadedFile({
file,
fileBase64: `data:application/pdf;base64,${base64String}`,
});
analytics.capture('Marketing: SPM - Document uploaded');
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
return (
<div className="mt-6 sm:mt-12">
<div className="text-center">
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
View our{' '}
<Link
href={'/pricing'}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
community plan
</Link>{' '}
for exclusive features, including the ability to collaborate with multiple signers.
</p>
</div>
<div className="mt-12 grid w-full grid-cols-12 gap-8">
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
{uploadedFile ? (
<Card gradient>
<CardContent className="p-2">
<LazyPDFViewer document={uploadedFile.fileBase64} />
</CardContent>
</Card>
) : (
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
)}
</div>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
{/* Add fields to PDF page. */}
{step === 'fields' && (
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
<AddFieldsFormPartial
documentFlow={documentFlow.fields}
hideRecipients={true}
recipients={uploadedFile ? [placeholderRecipient] : []}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields}
onSubmit={onFieldsSubmit}
/>
</fieldset>
)}
{/* Enter user details and signature. */}
{step === 'sign' && (
<AddSignatureFormPartial
documentFlow={documentFlow.sign}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields}
onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/>
)}
</DocumentFlowFormContainer>
</div>
</div>
</div>
);
};

View File

@ -1,244 +1,10 @@
'use client'; import { SinglePlayerClient } from './client';
import { useEffect, useState } from 'react'; export const revalidate = 0;
import Link from 'next/link'; // !: This entire file is a hack to get around failed prerendering of
import { useRouter } from 'next/navigation'; // !: the Single Player Mode page. This regression was introduced during
// !: the upgrade of Next.js to v13.5.x.
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; export default function SingleplayerPage() {
import { base64 } from '@documenso/lib/universal/base64'; return <SinglePlayerClient />;
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { Field, Prisma, Recipient } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
type SinglePlayerModeStep = 'fields' | 'sign';
export default function SinglePlayerModePage() {
const analytics = useAnalytics();
const router = useRouter();
const { toast } = useToast();
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
const [fields, setFields] = useState<Field[]>([]);
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
fields: {
title: 'Add document',
description: 'Upload a document and add fields.',
stepIndex: 1,
onBackStep: uploadedFile
? () => {
setUploadedFile(null);
setFields([]);
}
: undefined,
onNextStep: () => setStep('sign'),
},
sign: {
title: 'Sign',
description: 'Enter your details.',
stepIndex: 2,
onBackStep: () => setStep('fields'),
},
};
const currentDocumentFlow = documentFlow[step];
useEffect(() => {
analytics.startSessionRecording('marketing_session_recording_spm');
return () => {
analytics.stopSessionRecording();
};
}, [analytics]);
/**
* Insert the selected fields into the local state.
*/
const onFieldsSubmit = (data: TAddFieldsFormSchema) => {
if (!uploadedFile) {
return;
}
setFields(
data.fields.map((field, i) => ({
id: i,
documentId: -1,
recipientId: -1,
type: field.type,
page: field.pageNumber,
positionX: new Prisma.Decimal(field.pageX),
positionY: new Prisma.Decimal(field.pageY),
width: new Prisma.Decimal(field.pageWidth),
height: new Prisma.Decimal(field.pageHeight),
customText: '',
inserted: false,
})),
);
analytics.capture('Marketing: SPM - Fields added');
documentFlow.fields.onNextStep?.();
};
/**
* Upload, create, sign and send the document.
*/
const onSignSubmit = async (data: TAddSignatureFormSchema) => {
if (!uploadedFile) {
return;
}
try {
const putFileData = await putFile(uploadedFile.file);
const documentToken = await createSinglePlayerDocument({
documentData: {
type: putFileData.type,
data: putFileData.data,
},
documentName: uploadedFile.file.name,
signer: data,
fields: fields.map((field) => ({
page: field.page,
type: field.type,
positionX: field.positionX.toNumber(),
positionY: field.positionY.toNumber(),
width: field.width.toNumber(),
height: field.height.toNumber(),
})),
});
analytics.capture('Marketing: SPM - Document signed', {
signer: data.email,
});
router.push(`/singleplayer/${documentToken}/success`);
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
const placeholderRecipient: Recipient = {
id: -1,
documentId: -1,
email: '',
name: '',
token: '',
expired: null,
signedAt: null,
readStatus: 'OPENED',
signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT',
};
const onFileDrop = async (file: File) => {
try {
const arrayBuffer = await file.arrayBuffer();
const base64String = base64.encode(new Uint8Array(arrayBuffer));
setUploadedFile({
file,
fileBase64: `data:application/pdf;base64,${base64String}`,
});
analytics.capture('Marketing: SPM - Document uploaded');
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
return (
<div className="mt-6 sm:mt-12">
<div className="text-center">
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
View our{' '}
<Link
href={'/pricing'}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
community plan
</Link>{' '}
for exclusive features, including the ability to collaborate with multiple signers.
</p>
</div>
<div className="mt-12 grid w-full grid-cols-12 gap-8">
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
{uploadedFile ? (
<Card gradient>
<CardContent className="p-2">
<LazyPDFViewer document={uploadedFile.fileBase64} />
</CardContent>
</Card>
) : (
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
)}
</div>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
{/* Add fields to PDF page. */}
{step === 'fields' && (
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
<AddFieldsFormPartial
documentFlow={documentFlow.fields}
hideRecipients={true}
recipients={uploadedFile ? [placeholderRecipient] : []}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields}
onSubmit={onFieldsSubmit}
/>
</fieldset>
)}
{/* Enter user details and signature. */}
{step === 'sign' && (
<AddSignatureFormPartial
documentFlow={documentFlow.sign}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields}
onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/>
)}
</DocumentFlowFormContainer>
</div>
</div>
</div>
);
} }

View File

@ -30,7 +30,7 @@ import { claimPlan } from '~/api/claim-plan/fetcher';
import { FormErrorMessage } from '../form/form-error-message'; import { FormErrorMessage } from '../form/form-error-message';
export const ZClaimPlanDialogFormSchema = z.object({ export const ZClaimPlanDialogFormSchema = z.object({
name: z.string().min(3), name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
email: z.string().email(), email: z.string().email(),
}); });

View File

@ -28,7 +28,7 @@ const FOOTER_LINKS = [
{ href: '/open', text: 'Open' }, { href: '/open', text: 'Open' },
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' }, { href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' }, { href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
{ href: 'mailto:support@documenso.com', text: 'Support' }, { href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
{ href: '/privacy', text: 'Privacy' }, { href: '/privacy', text: 'Privacy' },
]; ];

View File

@ -39,6 +39,7 @@ export const MENU_NAVIGATION_LINKS = [
{ {
href: 'mailto:support@documenso.com', href: 'mailto:support@documenso.com',
text: 'Support', text: 'Support',
target: '_blank',
}, },
{ {
href: '/privacy', href: '/privacy',
@ -78,7 +79,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
staggerChildren: 0.03, staggerChildren: 0.03,
}} }}
> >
{MENU_NAVIGATION_LINKS.map(({ href, text }) => ( {MENU_NAVIGATION_LINKS.map(({ href, text, target }) => (
<motion.div <motion.div
key={href} key={href}
variants={{ variants={{
@ -100,6 +101,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
className="text-foreground hover:text-foreground/80 text-2xl font-semibold" className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
href={href} href={href}
onClick={() => handleMenuItemClick()} onClick={() => handleMenuItemClick()}
target={target}
> >
{text} {text}
</Link> </Link>

View File

@ -31,7 +31,7 @@ import { FormErrorMessage } from '../form/form-error-message';
const ZWidgetFormSchema = z const ZWidgetFormSchema = z
.object({ .object({
email: z.string().email({ message: 'Please enter a valid email address.' }), email: z.string().email({ message: 'Please enter a valid email address.' }),
name: z.string().min(3, { message: 'Please enter a valid name.' }), name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
}) })
.and( .and(
z.union([ z.union([
@ -41,7 +41,7 @@ const ZWidgetFormSchema = z
}), }),
z.object({ z.object({
signatureDataUrl: z.null().or(z.string().max(0)), signatureDataUrl: z.null().or(z.string().max(0)),
signatureText: z.string().min(1), signatureText: z.string().trim().min(1),
}), }),
]), ]),
); );

View File

@ -41,7 +41,7 @@ export default async function handler(
}, },
}); });
if (user && user.Subscription.length > 0) { if (user && user.Subscription) {
return res.status(200).json({ return res.status(200).json({
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`, redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
}); });

View File

@ -2,8 +2,12 @@
const path = require('path'); const path = require('path');
const { version } = require('./package.json'); const { version } = require('./package.json');
require('dotenv').config({ const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
path: path.join(__dirname, '../../.env.local'),
ENV_FILES.forEach((file) => {
require('dotenv').config({
path: path.join(__dirname, `../../${file}`),
});
}); });
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
@ -29,6 +33,14 @@ const config = {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}', transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
}, },
}, },
webpack: (config, { isServer }) => {
// fixes: Module not found: Cant resolve ../build/Release/canvas.node
if (isServer) {
config.resolve.alias.canvas = false;
}
return config;
},
async rewrites() { async rewrites() {
return [ return [
{ {
@ -37,6 +49,32 @@ const config = {
}, },
]; ];
}, },
async redirects() {
return [
{
permanent: true,
source: '/documents/:id/sign',
destination: '/sign/:token',
has: [
{
type: 'query',
key: 'token',
},
],
},
{
permanent: true,
source: '/documents/:id/signed',
destination: '/sign/:token',
has: [
{
type: 'query',
key: 'token',
},
],
},
];
},
}; };
module.exports = config; module.exports = config;

View File

@ -25,7 +25,7 @@
"lucide-react": "^0.279.0", "lucide-react": "^0.279.0",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "13.4.19", "next": "13.5.4",
"next-auth": "4.22.3", "next-auth": "4.22.3",
"next-plausible": "^3.10.1", "next-plausible": "^3.10.1",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
@ -40,7 +40,7 @@
"react-rnd": "^10.4.1", "react-rnd": "^10.4.1",
"sharp": "0.32.5", "sharp": "0.32.5",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"typescript": "5.1.6", "typescript": "5.2.2",
"zod": "^3.21.4" "zod": "^3.21.4"
}, },
"devDependencies": { "devDependencies": {

View File

@ -8,6 +8,7 @@ import { Loader } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { FindResultSet } from '@documenso/lib/types/find-result-set'; import { FindResultSet } from '@documenso/lib/types/find-result-set';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import { Document, User } from '@documenso/prisma/client'; import { Document, User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table';
@ -51,7 +52,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
header: 'Title', header: 'Title',
accessorKey: 'title', accessorKey: 'title',
cell: ({ row }) => { cell: ({ row }) => {
return <div>{row.original.title}</div>; return (
<div className="block max-w-[5rem] truncate font-medium md:max-w-[10rem]">
{row.original.title}
</div>
);
}, },
}, },
{ {
@ -62,7 +67,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
<Link href={`/admin/users/${row.original.User.id}`}> <Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white"> <Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-gray-400"> <AvatarFallback className="text-gray-400">
<span className="text-xs">{row.original.User.name}</span> <span className="text-sm">
{recipientInitials(row.original.User.name ?? '')}
</span>
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</Link> </Link>

View File

@ -4,8 +4,10 @@ import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox'; import { Combobox } from '@documenso/ui/primitives/combobox';
import { import {
@ -19,7 +21,9 @@ import {
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { TUserFormSchema, ZUserFormSchema } from '~/providers/admin-user-profile-update.types'; const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
export default function UserPage({ params }: { params: { id: number } }) { export default function UserPage({ params }: { params: { id: number } }) {
const { toast } = useToast(); const { toast } = useToast();

View File

@ -14,14 +14,14 @@ import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
interface User { type UserData = {
id: number; id: number;
name: string | null; name: string | null;
email: string; email: string;
roles: Role[]; roles: Role[];
Subscription: SubscriptionLite[]; Subscription?: SubscriptionLite | null;
Document: DocumentLite[]; Document: DocumentLite[];
} };
type SubscriptionLite = Pick< type SubscriptionLite = Pick<
Subscription, Subscription,
@ -31,7 +31,7 @@ type SubscriptionLite = Pick<
type DocumentLite = Pick<Document, 'id'>; type DocumentLite = Pick<Document, 'id'>;
type UsersDataTableProps = { type UsersDataTableProps = {
users: User[]; users: UserData[];
totalPages: number; totalPages: number;
perPage: number; perPage: number;
page: number; page: number;
@ -100,19 +100,7 @@ export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTa
{ {
header: 'Subscription', header: 'Subscription',
accessorKey: 'subscription', accessorKey: 'subscription',
cell: ({ row }) => { cell: ({ row }) => row.original.Subscription?.status ?? 'NONE',
if (row.original.Subscription && row.original.Subscription.length > 0) {
return (
<>
{row.original.Subscription.map((subscription: SubscriptionLite, i: number) => {
return <span key={i}>{subscription.status}</span>;
})}
</>
);
} else {
return <span>NONE</span>;
}
},
}, },
{ {
header: 'Documents', header: 'Documents',

View File

@ -2,12 +2,15 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; 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';
@ -22,6 +25,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { quota, remaining } = useLimits();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation(); const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
@ -52,11 +57,19 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast({ if (error instanceof TRPCClientError) {
title: 'Error', toast({
description: 'An error occurred while uploading your document.', title: 'Error',
variant: 'destructive', description: error.message,
}); variant: 'destructive',
});
} else {
toast({
title: 'Error',
description: 'An error occurred while uploading your document.',
variant: 'destructive',
});
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -64,13 +77,46 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
return ( return (
<div className={cn('relative', className)}> <div className={cn('relative', className)}>
<DocumentDropzone className="min-h-[40vh]" onDrop={onFileDrop} /> <DocumentDropzone
className="min-h-[40vh]"
disabled={remaining.documents === 0}
onDrop={onFileDrop}
/>
<div className="absolute -bottom-6 right-0">
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
<p className="text-muted-foreground/60 text-xs">
{remaining.documents} of {quota.documents} documents remaining this month.
</p>
)}
</div>
{isLoading && ( {isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center"> <div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-12 w-12 animate-spin" /> <Loader className="text-muted-foreground h-12 w-12 animate-spin" />
</div> </div>
)} )}
{remaining.documents === 0 && (
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
<div className="text-center">
<h2 className="text-muted-foreground/80 text-xl font-semibold">
You have reached your document limit.
</h2>
<p className="text-muted-foreground/60 mt-2 text-sm">
You can upload up to {quota.documents} documents per month on your current plan.
</p>
<Link
className="text-primary hover:text-primary/80 mt-6 block font-medium"
href="/settings/billing"
>
Upgrade your account to upload more documents.
</Link>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
@ -28,11 +29,13 @@ export default async function AuthenticatedDashboardLayout({
return ( return (
<NextAuthProvider session={session}> <NextAuthProvider session={session}>
<Header user={user} /> <LimitsProvider>
<Header user={user} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main> <main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
<RefreshOnFocus /> <RefreshOnFocus />
</LimitsProvider>
</NextAuthProvider> </NextAuthProvider>
); );
} }

View File

@ -3,9 +3,10 @@ import { redirect } from 'next/navigation';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; import { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { LocaleDate } from '~/components/formatter/locale-date'; import { LocaleDate } from '~/components/formatter/locale-date';
@ -30,12 +31,10 @@ export default async function BillingSettingsPage() {
let subscriptionProduct: Stripe.Product | null = null; let subscriptionProduct: Stripe.Product | null = null;
if (subscription?.planId) { if (subscription?.priceId) {
const foundSubscriptionProduct = (await stripe.products.list()).data.find( subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
(item) => item.default_price === subscription.planId, () => null,
); );
subscriptionProduct = foundSubscriptionProduct ?? null;
} }
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE'; const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';

View File

@ -0,0 +1,43 @@
import fs from 'fs/promises';
import { loadFileIntoPinecone } from '@documenso/lib/server-only/pinecone';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentDataType } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Chat } from './chat';
type ChatPDFProps = {
id: string;
type: DocumentDataType;
data: string;
initialData: string;
};
export async function ChatPDF({ documentData }: { documentData: ChatPDFProps }) {
const docData = await getFile(documentData);
const fileName = `${documentData.id}}.pdf`;
try {
await fs.access(fileName, fs.constants.F_OK);
} catch (err) {
await fs.writeFile(fileName, docData);
}
await loadFileIntoPinecone(fileName);
return (
<Card className="my-8" gradient={true} degrees={200}>
<CardContent className="mt-8 flex flex-col">
<h2 className="text-foreground text-2xl font-semibold">Chat with the PDF</h2>
<p className="text-muted-foreground mt-2 text-sm">Ask any questions regarding the PDF</p>
<hr className="border-border mb-4 mt-4" />
<Chat />
<hr className="border-border mb-4 mt-4" />
<p className="text-muted-foreground text-sm italic">
Disclaimer: Never trust AI 100%. Always double check the documents yourself. Documenso is
not liable for any issue arising from you relying 100% on the AI.
</p>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,56 @@
'use client';
import { useChat } from 'ai/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
type Props = {};
export function Chat({}: Props) {
const { input, handleInputChange, handleSubmit, messages } = useChat({
api: '/api/chat',
});
// continue https://youtu.be/bZFedu-0emE?si=2JGSJfSQ38aXSlp2&t=10941
return (
<div>
<div className="flex flex-col gap-8">
<ul>
{messages.map((message, index) => (
<li
className={cn(
'flex',
message.role === 'user'
? 'mb-6 ml-10 mt-6 flex justify-end'
: 'mr-10 justify-start',
)}
key={index}
>
<span
className={
message.role === 'user'
? 'bg-background text-foreground group relative rounded-lg border-2 p-4 backdrop-blur-[2px]'
: 'bg-primary text-primary-foreground rounded-lg p-4 backdrop-blur-[2px]'
}
>
{message.content}
</span>
</li>
))}
</ul>
</div>
<form className="mb-2 mt-8 flex" onSubmit={handleSubmit}>
<Input
value={input}
className="mr-6 w-1/2"
onChange={handleInputChange}
placeholder="Ask away..."
/>
<Button type="submit">Send</Button>
</form>
</div>
);
}

View File

@ -87,7 +87,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
Please review the document before signing. Please review the document before signing.
</p> </p>
<hr className="border-border mb-8 mt-4" /> <hr className="border-border mb-8 mt-4 h-8 w-full" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"> <div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4"> <div className="flex flex-1 flex-col gap-y-4">
@ -99,7 +99,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
id="full-name" id="full-name"
className="bg-background mt-2" className="bg-background mt-2"
value={fullName} value={fullName}
onChange={(e) => setFullName(e.target.value)} onChange={(e) => setFullName(e.target.value.trimStart())}
/> />
</div> </div>

View File

@ -125,7 +125,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
type="text" type="text"
className="mt-2" className="mt-2"
value={localFullName} value={localFullName}
onChange={(e) => setLocalFullName(e.target.value)} onChange={(e) => setLocalFullName(e.target.value.trimStart())}
/> />
</div> </div>

View File

@ -14,6 +14,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { ChatPDF } from './chat-pdf';
import { DateField } from './date-field'; import { DateField } from './date-field';
import { EmailField } from './email-field'; import { EmailField } from './email-field';
import { SigningForm } from './form'; import { SigningForm } from './form';
@ -106,6 +107,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
.otherwise(() => null), .otherwise(() => null),
)} )}
</ElementVisible> </ElementVisible>
<ChatPDF documentData={documentData} />
</div> </div>
</SigningProvider> </SigningProvider>
); );

View File

@ -20,7 +20,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { FormErrorMessage } from '../form/form-error-message'; import { FormErrorMessage } from '../form/form-error-message';
export const ZProfileFormSchema = z.object({ export const ZProfileFormSchema = z.object({
name: z.string().min(1), name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
signature: z.string().min(1, 'Signature Pad cannot be empty'), signature: z.string().min(1, 'Signature Pad cannot be empty'),
}); });

View File

@ -19,7 +19,7 @@ import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZSignUpFormSchema = z.object({ export const ZSignUpFormSchema = z.object({
name: z.string().min(1), name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().email().min(1), email: z.string().email().min(1),
password: z.string().min(6).max(72), password: z.string().min(6).max(72),
signature: z.string().min(1, { message: 'We need your signature to sign documents' }), signature: z.string().min(1, { message: 'We need your signature to sign documents' }),

View File

@ -0,0 +1,54 @@
import { Message, OpenAIStream, StreamingTextResponse } from 'ai';
import { Configuration, OpenAIApi } from 'openai-edge';
import { getContext } from '@documenso/lib/server-only/context';
export const runtime = 'edge';
const config = new Configuration({
apiKey: process.env.OPENAI_API_KEY!,
});
const openai = new OpenAIApi(config);
export default async function handler(request: Request) {
// console.log(request.method);
// request.json().then((data) => console.log(data));
// return Response.json({ message: 'world' });
try {
const data = await request.json();
const lastMessage = data.messages[data.messages.length - 1];
const context = await getContext(lastMessage.content);
console.log('context', context);
const prompt = {
role: 'system',
content: `AI assistant is a brand new, powerful, human-like artificial intelligence.
The traits of AI include expert knowledge, helpfulness, cleverness, and articulateness.
AI is a well-behaved and well-mannered individual.
AI is always friendly, kind, and inspiring, and he is eager to provide vivid and thoughtful responses to the user.
AI has the sum of all knowledge in their brain, and is able to accurately answer nearly any question about any topic in conversation.
AI assistant is a big fan of Pinecone and Vercel.
START CONTEXT BLOCK
${context}
END OF CONTEXT BLOCK
AI assistant will take into account any CONTEXT BLOCK that is provided in a conversation.
If the context does not provide the answer to question, the AI assistant will say, "I'm sorry, but I don't know the answer to that question".
AI assistant will not apologize for previous responses, but instead will indicated new information was gained.
AI assistant will not invent anything that is not drawn directly from the context.
`,
};
const response = await openai.createChatCompletion({
model: 'gpt-3.5-turbo',
messages: [prompt, ...data.messages.filter((message: Message) => message.role === 'user')],
stream: true,
});
const stream = OpenAIStream(response);
return new StreamingTextResponse(stream);
} catch (error) {
console.error('There was an error getting embeddings: ', error);
throw new Error('There was an error getting embeddings');
}
}

View File

@ -41,7 +41,7 @@ export default async function handler(
}, },
}); });
if (user && user.Subscription.length > 0) { if (user && user.Subscription) {
return res.status(200).json({ return res.status(200).json({
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`, redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
}); });

View File

@ -0,0 +1,3 @@
import { limitsHandler } from '@documenso/ee/server-only/limits/handler';
export default limitsHandler;

View File

@ -1,237 +1,7 @@
import { NextApiRequest, NextApiResponse } from 'next'; import { stripeWebhookHandler } from '@documenso/ee/server-only/stripe/webhook/handler';
import { randomBytes } from 'crypto';
import { readFileSync } from 'fs';
import { buffer } from 'micro';
import { match } from 'ts-pattern';
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
import { redis } from '@documenso/lib/server-only/redis';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import {
DocumentDataType,
DocumentStatus,
FieldType,
ReadStatus,
SendStatus,
SigningStatus,
SubscriptionStatus,
} from '@documenso/prisma/client';
const log = (...args: unknown[]) => console.log('[stripe]', ...args);
export const config = { export const config = {
api: { bodyParser: false }, api: { bodyParser: false },
}; };
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default stripeWebhookHandler;
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
// return res.status(500).json({
// success: false,
// message: 'Subscriptions are not enabled',
// });
// }
const sig =
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
if (!sig) {
return res.status(400).json({
success: false,
message: 'No signature found in request',
});
}
log('constructing body...');
const body = await buffer(req);
log('constructed body');
const event = stripe.webhooks.constructEvent(
body,
sig,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, turbo/no-undeclared-env-vars
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET!,
);
log('event-type:', event.type);
if (event.type === 'customer.subscription.updated') {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
await handleCustomerSubscriptionUpdated(subscription);
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
if (event.type === 'checkout.session.completed') {
// This is required since we don't want to create a guard for every event type
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const session = event.data.object as Stripe.Checkout.Session;
if (session.metadata?.source === 'landing') {
const user = await prisma.user.findFirst({
where: {
id: Number(session.client_reference_id),
},
});
if (!user) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
const signatureText = session.metadata?.signatureText || user.name;
let signatureDataUrl = '';
if (session.metadata?.signatureDataUrl) {
const result = await redis.get<string>(`signature:${session.metadata.signatureDataUrl}`);
if (result) {
signatureDataUrl = result;
}
}
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({
data: {
title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED,
userId: user.id,
documentDataId,
},
include: {
documentData: true,
},
});
const { documentData } = document;
if (!documentData) {
throw new Error(`Document ${document.id} has no document data`);
}
const recipient = await prisma.recipient.create({
data: {
name: user.name ?? '',
email: user.email,
token: randomBytes(16).toString('hex'),
signedAt: now,
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.SIGNED,
documentId: document.id,
},
});
const field = await prisma.field.create({
data: {
documentId: document.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 0,
positionX: 77,
positionY: 638,
inserted: false,
customText: '',
},
});
if (signatureDataUrl) {
documentData.data = await insertImageInPDF(
documentData.data,
signatureDataUrl,
field.positionX.toNumber(),
field.positionY.toNumber(),
field.page,
);
} else {
documentData.data = await insertTextInPDF(
documentData.data,
signatureText ?? '',
field.positionX.toNumber(),
field.positionY.toNumber(),
field.page,
);
}
await Promise.all([
prisma.signature.create({
data: {
fieldId: field.id,
recipientId: recipient.id,
signatureImageAsBase64: signatureDataUrl || undefined,
typedSignature: signatureDataUrl ? '' : signatureText,
},
}),
prisma.document.update({
where: {
id: document.id,
},
data: {
documentData: {
update: {
data: documentData.data,
},
},
},
}),
]);
}
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
log('Unhandled webhook event', event.type);
return res.status(400).json({
success: false,
message: 'Unhandled webhook event',
});
}
const handleCustomerSubscriptionUpdated = async (subscription: Stripe.Subscription) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const { plan } = subscription as unknown as Stripe.SubscriptionItem;
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
await prisma.subscription.update({
where: {
customerId: customerId,
},
data: {
planId: plan.id,
status,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
periodEnd: new Date(subscription.current_period_end * 1000),
updatedAt: new Date(),
},
});
};

View File

@ -6,7 +6,3 @@ export default trpcNext.createNextApiHandler({
router: appRouter, router: appRouter,
createContext: async ({ req, res }) => createTrpcContext({ req, res }), createContext: async ({ req, res }) => createTrpcContext({ req, res }),
}); });
// export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
// res.json({ hello: 'world' });
// }

View File

@ -1,6 +0,0 @@
import { z } from 'zod';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
export const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
export type TUserFormSchema = z.infer<typeof ZUserFormSchema>;

9344
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,9 +15,12 @@
"dx:up": "docker compose -f docker/compose-services.yml up -d", "dx:up": "docker compose -f docker/compose-services.yml up -d",
"dx:down": "docker compose -f docker/compose-services.yml down", "dx:down": "docker compose -f docker/compose-services.yml down",
"ci": "turbo run build test:e2e", "ci": "turbo run build test:e2e",
"prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma",
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma", "prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma", "prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
"with:env": "dotenv -e .env -e .env.local --" "prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma",
"with:env": "dotenv -e .env -e .env.local --",
"reset:hard": "npm run clean && npm i && npm run prisma:generate"
}, },
"engines": { "engines": {
"npm": ">=8.6.0", "npm": ">=8.6.0",
@ -43,6 +46,13 @@
"packages/*" "packages/*"
], ],
"dependencies": { "dependencies": {
"@pinecone-database/pinecone": "^1.1.1",
"@types/md5": "^2.3.4",
"ai": "^2.2.16",
"langchain": "^0.0.169",
"md5": "^2.3.0",
"openai-edge": "^1.2.2",
"pdf-parse": "^1.1.1",
"recharts": "^2.7.2" "recharts": "^2.7.2"
} }
} }

View File

@ -14,6 +14,13 @@
}, },
"dependencies": { "dependencies": {
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/prisma": "*" "@documenso/prisma": "*",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "13.5.4",
"next-auth": "4.22.3",
"react": "18.2.0",
"ts-pattern": "^5.0.5",
"zod": "^3.21.4"
} }
} }

View File

@ -0,0 +1,31 @@
import { APP_BASE_URL } from '@documenso/lib/constants/app';
import { FREE_PLAN_LIMITS } from './constants';
import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema';
export type GetLimitsOptions = {
headers?: Record<string, string>;
};
export const getLimits = async ({ headers }: GetLimitsOptions = {}) => {
const requestHeaders = headers ?? {};
const url = new URL(`${APP_BASE_URL}/api/limits`);
return fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then(async (res) => res.json())
.then((res) => ZLimitsResponseSchema.parse(res))
.catch(() => {
return {
quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS,
} satisfies TLimitsResponseSchema;
});
};

View File

@ -0,0 +1,11 @@
import { TLimitsSchema } from './schema';
export const FREE_PLAN_LIMITS: TLimitsSchema = {
documents: 5,
recipients: 10,
};
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
documents: Infinity,
recipients: Infinity,
};

View File

@ -0,0 +1,6 @@
export const ERROR_CODES: Record<string, string> = {
UNAUTHORIZED: 'You must be logged in to access this resource',
USER_FETCH_FAILED: 'An error occurred while fetching your user account',
SUBSCRIPTION_FETCH_FAILED: 'An error occurred while fetching your subscription',
UNKNOWN: 'An unknown error occurred',
};

View File

@ -0,0 +1,54 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { getToken } from 'next-auth/jwt';
import { match } from 'ts-pattern';
import { withStaleWhileRevalidate } from '@documenso/lib/server-only/http/with-swr';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { SELFHOSTED_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
import { getServerLimits } from './server';
export const limitsHandler = async (
req: NextApiRequest,
res: NextApiResponse<TLimitsResponseSchema | TLimitsErrorResponseSchema>,
) => {
try {
const token = await getToken({ req });
const isBillingEnabled = await getFlag('app_billing');
if (!isBillingEnabled) {
return withStaleWhileRevalidate<typeof res>(res).status(200).json({
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
});
}
if (!token?.email) {
throw new Error(ERROR_CODES.UNAUTHORIZED);
}
const limits = await getServerLimits({ email: token.email });
return withStaleWhileRevalidate<typeof res>(res).status(200).json(limits);
} catch (err) {
console.error('error', err);
if (err instanceof Error) {
const status = match(err.message)
.with(ERROR_CODES.UNAUTHORIZED, () => 401)
.otherwise(() => 500);
return res.status(status).json({
error: ERROR_CODES[err.message] ?? ERROR_CODES.UNKNOWN,
});
}
res.status(500).json({
error: ERROR_CODES.UNKNOWN,
});
}
};

View File

@ -0,0 +1,53 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { getLimits } from '../client';
import { FREE_PLAN_LIMITS } from '../constants';
import { TLimitsResponseSchema } from '../schema';
export type LimitsContextValue = TLimitsResponseSchema;
const LimitsContext = createContext<LimitsContextValue | null>(null);
export const useLimits = () => {
const limits = useContext(LimitsContext);
if (!limits) {
throw new Error('useLimits must be used within a LimitsProvider');
}
return limits;
};
export type LimitsProviderProps = {
initialValue?: LimitsContextValue;
children?: React.ReactNode;
};
export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => {
const defaultValue: TLimitsResponseSchema = {
quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS,
};
const [limits, setLimits] = useState(() => initialValue ?? defaultValue);
useEffect(() => {
void getLimits().then((limits) => setLimits(limits));
}, []);
useEffect(() => {
const onFocus = () => {
void getLimits().then((limits) => setLimits(limits));
};
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
};
}, []);
return <LimitsContext.Provider value={limits}>{children}</LimitsContext.Provider>;
};

View File

@ -0,0 +1,18 @@
'use server';
import { headers } from 'next/headers';
import { getLimits } from '../client';
import { LimitsProvider as ClientLimitsProvider } from './client';
export type LimitsProviderProps = {
children?: React.ReactNode;
};
export const LimitsProvider = async ({ children }: LimitsProviderProps) => {
const requestHeaders = Object.fromEntries(headers().entries());
const limits = await getLimits({ headers: requestHeaders });
return <ClientLimitsProvider initialValue={limits}>{children}</ClientLimitsProvider>;
};

View File

@ -0,0 +1,28 @@
import { z } from 'zod';
// Not proud of the below but it's a way to deal with Infinity when returning JSON.
export const ZLimitsSchema = z.object({
documents: z
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
.optional()
.default(0),
recipients: z
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
.optional()
.default(0),
});
export type TLimitsSchema = z.infer<typeof ZLimitsSchema>;
export const ZLimitsResponseSchema = z.object({
quota: ZLimitsSchema,
remaining: ZLimitsSchema,
});
export type TLimitsResponseSchema = z.infer<typeof ZLimitsResponseSchema>;
export const ZLimitsErrorResponseSchema = z.object({
error: z.string(),
});
export type TLimitsErrorResponseSchema = z.infer<typeof ZLimitsErrorResponseSchema>;

View File

@ -0,0 +1,78 @@
import { DateTime } from 'luxon';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import { ZLimitsSchema } from './schema';
export type GetServerLimitsOptions = {
email?: string;
};
export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
const isBillingEnabled = await getFlag('app_billing');
if (!isBillingEnabled) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
};
}
if (!email) {
throw new Error(ERROR_CODES.UNAUTHORIZED);
}
const user = await prisma.user.findFirst({
where: {
email,
},
include: {
Subscription: true,
},
});
if (!user) {
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
}
let quota = structuredClone(FREE_PLAN_LIMITS);
let remaining = structuredClone(FREE_PLAN_LIMITS);
if (user.Subscription?.priceId) {
const { product } = await stripe.prices
.retrieve(user.Subscription.priceId, {
expand: ['product'],
})
.catch((err) => {
console.error(err);
throw err;
});
if (typeof product === 'string') {
throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED);
}
quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {});
remaining = structuredClone(quota);
}
const documents = await prisma.document.count({
where: {
userId: user.id,
createdAt: {
gte: DateTime.utc().startOf('month').toJSDate(),
},
},
});
remaining.documents = Math.max(remaining.documents - documents, 0);
return {
quota,
remaining,
};
};

View File

@ -17,6 +17,7 @@ export const getCheckoutSession = async ({
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
customer: customerId, customer: customerId,
mode: 'subscription',
line_items: [ line_items: [
{ {
price: priceId, price: priceId,

View File

@ -0,0 +1,17 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetProductByPriceIdOptions = {
priceId: string;
};
export const getProductByPriceId = async ({ priceId }: GetProductByPriceIdOptions) => {
const { product } = await stripe.prices.retrieve(priceId, {
expand: ['product'],
});
if (typeof product === 'string' || 'deleted' in product) {
throw new Error('Product not found');
}
return product;
};

View File

@ -0,0 +1,224 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { buffer } from 'micro';
import { match } from 'ts-pattern';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma';
import { onSubscriptionDeleted } from './on-subscription-deleted';
import { onSubscriptionUpdated } from './on-subscription-updated';
type StripeWebhookResponse = {
success: boolean;
message: string;
};
export const stripeWebhookHandler = async (
req: NextApiRequest,
res: NextApiResponse<StripeWebhookResponse>,
) => {
try {
const isBillingEnabled = await getFlag('app_billing');
if (!isBillingEnabled) {
return res.status(500).json({
success: false,
message: 'Billing is disabled',
});
}
const signature =
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
if (!signature) {
return res.status(400).json({
success: false,
message: 'No signature found in request',
});
}
const body = await buffer(req);
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET,
);
await match(event.type)
.with('checkout.session.completed', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const session = event.data.object as Stripe.Checkout.Session;
const userId = Number(session.client_reference_id);
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription?.id;
if (!subscriptionId || Number.isNaN(userId)) {
return res.status(500).json({
success: false,
message: 'Invalid session',
});
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await onSubscriptionUpdated({ userId, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.with('customer.subscription.updated', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id;
const result = await prisma.subscription.findFirst({
select: {
userId: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.with('invoice.payment_succeeded', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const invoice = event.data.object as Stripe.Invoice;
if (invoice.billing_reason !== 'subscription_cycle') {
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
const customerId =
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
return res.status(500).json({
success: false,
message: 'Invalid invoice',
});
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const result = await prisma.subscription.findFirst({
select: {
userId: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.with('invoice.payment_failed', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const invoice = event.data.object as Stripe.Invoice;
const customerId =
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
return res.status(500).json({
success: false,
message: 'Invalid invoice',
});
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const result = await prisma.subscription.findFirst({
select: {
userId: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
})
.with('customer.subscription.deleted', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
await onSubscriptionDeleted({ subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.otherwise(() => {
return res.status(200).json({
success: true,
message: 'Webhook received',
});
});
} catch (err) {
console.error(err);
res.status(500).json({
success: false,
message: 'Unknown error',
});
}
};

View File

@ -0,0 +1,21 @@
import { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionDeletedOptions = {
subscription: Stripe.Subscription;
};
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.INACTIVE,
},
});
};

View File

@ -0,0 +1,44 @@
import { match } from 'ts-pattern';
import { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionUpdatedOptions = {
userId: number;
subscription: Stripe.Subscription;
};
export const onSubscriptionUpdated = async ({
userId,
subscription,
}: OnSubscriptionUpdatedOptions) => {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
await prisma.subscription.upsert({
where: {
customerId,
},
create: {
customerId,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
userId,
},
update: {
customerId,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
},
});
};

View File

@ -1 +1 @@
export { render, renderAsync } from '@react-email/components'; export { render } from '@react-email/components';

View File

@ -32,7 +32,7 @@ export const DocumentInviteEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002', assetBaseUrl = 'http://localhost:3002',
customBody, customBody,
}: DocumentInviteEmailTemplateProps) => { }: DocumentInviteEmailTemplateProps) => {
const previewText = `Completed Document`; const previewText = `${inviterName} has invited you to sign ${documentName}`;
const getAssetUrl = (path: string) => { const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString(); return new URL(path, assetBaseUrl).toString();

View File

@ -7,8 +7,8 @@
"clean": "rimraf node_modules" "clean": "rimraf node_modules"
}, },
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "^5.59.2", "@typescript-eslint/eslint-plugin": "6.8.0",
"@typescript-eslint/parser": "^5.59.2", "@typescript-eslint/parser": "6.8.0",
"eslint": "^8.40.0", "eslint": "^8.40.0",
"eslint-config-next": "13.4.19", "eslint-config-next": "13.4.19",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
@ -16,6 +16,6 @@
"eslint-plugin-package-json": "^0.1.4", "eslint-plugin-package-json": "^0.1.4",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2", "eslint-plugin-react": "^7.32.2",
"typescript": "^5.1.6" "typescript": "5.2.2"
} }
} }

View File

@ -1,5 +1,3 @@
import { Role, User } from '@documenso/prisma/client'; import { Role, User } from '@documenso/prisma/client';
const isAdmin = (user: User) => user.roles.includes(Role.ADMIN); export const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
export { isAdmin };

View File

@ -28,12 +28,13 @@
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"nanoid": "^4.0.2", "nanoid": "^4.0.2",
"next": "13.4.19", "next": "13.5.4",
"next-auth": "4.22.3", "next-auth": "4.22.3",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"react": "18.2.0", "react": "18.2.0",
"stripe": "^12.7.0", "stripe": "^12.7.0",
"ts-pattern": "^5.0.5" "ts-pattern": "^5.0.5",
"zod": "^3.21.4"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",

View File

@ -9,9 +9,7 @@ export const getUsersWithSubscriptionsCount = async () => {
return await prisma.user.count({ return await prisma.user.count({
where: { where: {
Subscription: { Subscription: {
some: { status: SubscriptionStatus.ACTIVE,
status: SubscriptionStatus.ACTIVE,
},
}, },
}, },
}); });

View File

@ -0,0 +1,35 @@
import { Pinecone } from '@pinecone-database/pinecone';
import { getEmbeddings } from './embeddings';
export async function getMatchesFromEmbeddings(embeddings: number[]) {
const pc = new Pinecone({
apiKey: process.env.PINECONE_API_KEY!,
environment: process.env.PINECONE_ENV!,
});
const pineconeIndex = pc.index('documenso-chat-with-pdf-test');
try {
const queryResult = await pineconeIndex.query({
topK: 5,
vector: embeddings,
includeMetadata: true,
});
return queryResult.matches || [];
} catch (error) {
console.error('There was an error getting matches from embeddings: ', error);
throw new Error('There was an error getting matches from embeddings');
}
}
export async function getContext(query: string) {
const queryEmbeddings = await getEmbeddings(query);
const matches = await getMatchesFromEmbeddings(queryEmbeddings);
const qualifyingMatches = matches.filter((match) => match.score && match.score > 0.7);
const docs = qualifyingMatches.map((match) => match.metadata?.text);
return docs.join('\n').substring(0, 3000);
}

View File

@ -0,0 +1,23 @@
import { Configuration, OpenAIApi } from 'openai-edge';
const config = new Configuration({
apiKey: process.env.OPENAI_API_KEY!,
});
const openai = new OpenAIApi(config);
export async function getEmbeddings(text: string) {
try {
const response = await openai.createEmbedding({
model: 'text-embedding-ada-002',
input: text.replace(/\n/g, ' '),
});
const result = await response.json();
return result.data[0].embedding;
} catch (error) {
console.error('There was an error getting embeddings: ', error);
throw new Error('There was an error getting embeddings');
}
}

View File

@ -0,0 +1,9 @@
import { NextRequest } from 'next/server';
export const toNextRequest = (req: Request) => {
const headers = Object.fromEntries(req.headers.entries());
return new NextRequest(req, {
headers: headers,
});
};

View File

@ -0,0 +1,28 @@
import { NextApiResponse } from 'next';
import { NextResponse } from 'next/server';
type NarrowedResponse<T> = T extends NextResponse
? NextResponse
: T extends NextApiResponse<infer U>
? NextApiResponse<U>
: never;
export const withStaleWhileRevalidate = <T>(
res: NarrowedResponse<T>,
cacheInSeconds = 60,
staleCacheInSeconds = 300,
) => {
if ('headers' in res) {
res.headers.set(
'Cache-Control',
`public, s-maxage=${cacheInSeconds}, stale-while-revalidate=${staleCacheInSeconds}`,
);
} else {
res.setHeader(
'Cache-Control',
`public, s-maxage=${cacheInSeconds}, stale-while-revalidate=${staleCacheInSeconds}`,
);
}
return res;
};

View File

@ -0,0 +1,113 @@
import { Pinecone } from '@pinecone-database/pinecone';
import { PDFLoader } from 'langchain/document_loaders/fs/pdf';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
import md5 from 'md5';
import { getEmbeddings } from './embeddings';
let pc: Pinecone | null = null;
// export type PDFPage = {
// pageContent: string;
// metadata: {
// source: string;
// pdf: {
// version: string;
// info: {
// pdfformatversion: string;
// isacroformpresent: boolean;
// isxfapresent: boolean;
// creator: string;
// producer: string;
// ceationdate: string;
// moddate: string;
// };
// metadata: null;
// totalPages: number;
// };
// loc: {
// pageNumber: number;
// };
// };
// };
export type PDFPage = unknown;
export const getPineconeClient = () => {
if (!pc) {
pc = new Pinecone({
apiKey: process.env.PINECONE_API_KEY!,
environment: process.env.PINECONE_ENV!,
});
}
return pc;
};
export async function loadFileIntoPinecone(file: string) {
if (!file) {
throw new Error('No file provided');
}
const loader = new PDFLoader(file);
const pages: PDFPage[] = await loader.load();
const documents = await Promise.all(pages.map(prepareDocument));
const vectors = await Promise.all(documents.flat().map(embedDocuments));
const client = getPineconeClient();
const pineconeIndex = client.index('documenso-chat-with-pdf-test');
try {
await pineconeIndex.upsert(vectors);
} catch (error) {
console.error('There was an error upserting vectors: ', error);
}
}
async function embedDocuments(doc) {
try {
const embeddings = await getEmbeddings(doc.pageContent);
const hash = md5(doc.pageContent);
return {
id: hash,
values: embeddings,
metadata: {
text: doc.metadata.text,
pageNumber: doc.metadata.pageNumber,
},
};
} catch (error) {
console.error('There was an error embedding documents: ', error);
throw new Error('There was an error embedding documents');
}
}
export const truncateStringByBytes = (str: string, numBytes: number) => {
const encoder = new TextEncoder();
return new TextDecoder('utf-8').decode(encoder.encode(str).slice(0, numBytes));
};
async function prepareDocument(page: PDFPage) {
let { pageContent, metadata } = page;
pageContent = pageContent.replace(/\n/g, '');
const splitter = new RecursiveCharacterTextSplitter();
const docs = await splitter.splitDocuments([
{
pageContent,
metadata: {
pageNumber: metadata.loc.pageNumber,
text: truncateStringByBytes(pageContent, 36000),
},
},
]);
return docs;
}
function convertToAscii(input: string) {
return input.replace(/[^\x00-\x7F]/g, '');
}

View File

@ -7,7 +7,7 @@ export type GetSubscriptionByUserIdOptions = {
}; };
export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => { export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => {
return prisma.subscription.findFirst({ return await prisma.subscription.findFirst({
where: { where: {
userId, userId,
}, },

View File

@ -3,8 +3,6 @@ import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client'; import { DocumentDataType } from '@documenso/prisma/client';
import { getPresignGetUrl } from './server-actions';
export type GetFileOptions = { export type GetFileOptions = {
type: DocumentDataType; type: DocumentDataType;
data: string; data: string;
@ -33,6 +31,8 @@ const getFileFromBytes64 = (data: string) => {
}; };
const getFileFromS3 = async (key: string) => { const getFileFromS3 = async (key: string) => {
const { getPresignGetUrl } = await import('./server-actions');
const { url } = await getPresignGetUrl(key); const { url } = await getPresignGetUrl(key);
const response = await fetch(url, { const response = await fetch(url, {

View File

@ -4,7 +4,6 @@ import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client'; import { DocumentDataType } from '@documenso/prisma/client';
import { createDocumentData } from '../../server-only/document-data/create-document-data'; import { createDocumentData } from '../../server-only/document-data/create-document-data';
import { getPresignPostUrl } from './server-actions';
type File = { type File = {
name: string; name: string;
@ -34,6 +33,8 @@ const putFileInDatabase = async (file: File) => {
}; };
const putFileInS3 = async (file: File) => { const putFileInS3 = async (file: File) => {
const { getPresignPostUrl } = await import('./server-actions');
const { url, key } = await getPresignPostUrl(file.name, file.type); const { url, key } = await getPresignPostUrl(file.name, file.type);
const body = await file.arrayBuffer(); const body = await file.arrayBuffer();

View File

@ -6,7 +6,6 @@ import {
PutObjectCommand, PutObjectCommand,
S3Client, S3Client,
} from '@aws-sdk/client-s3'; } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import slugify from '@sindresorhus/slugify'; import slugify from '@sindresorhus/slugify';
import path from 'node:path'; import path from 'node:path';
@ -17,6 +16,8 @@ import { alphaid } from '../id';
export const getPresignPostUrl = async (fileName: string, contentType: string) => { export const getPresignPostUrl = async (fileName: string, contentType: string) => {
const client = getS3Client(); const client = getS3Client();
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
const { user } = await getServerComponentSession(); const { user } = await getServerComponentSession();
// Get the basename and extension for the file // Get the basename and extension for the file
@ -44,6 +45,8 @@ export const getPresignPostUrl = async (fileName: string, contentType: string) =
export const getAbsolutePresignPostUrl = async (key: string) => { export const getAbsolutePresignPostUrl = async (key: string) => {
const client = getS3Client(); const client = getS3Client();
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
const putObjectCommand = new PutObjectCommand({ const putObjectCommand = new PutObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET, Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key, Key: key,
@ -59,6 +62,8 @@ export const getAbsolutePresignPostUrl = async (key: string) => {
export const getPresignGetUrl = async (key: string) => { export const getPresignGetUrl = async (key: string) => {
const client = getS3Client(); const client = getS3Client();
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
const getObjectCommand = new GetObjectCommand({ const getObjectCommand = new GetObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET, Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key, Key: key,

View File

@ -3,8 +3,6 @@ import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client'; import { DocumentDataType } from '@documenso/prisma/client';
import { getAbsolutePresignPostUrl } from './server-actions';
export type UpdateFileOptions = { export type UpdateFileOptions = {
type: DocumentDataType; type: DocumentDataType;
oldData: string; oldData: string;
@ -40,6 +38,8 @@ const updateFileWithBytes64 = (data: string) => {
}; };
const updateFileWithS3 = async (key: string, data: string) => { const updateFileWithS3 = async (key: string, data: string) => {
const { getAbsolutePresignPostUrl } = await import('./server-actions');
const { url } = await getAbsolutePresignPostUrl(key); const { url } = await getAbsolutePresignPostUrl(key);
const response = await fetch(url, { const response = await fetch(url, {

View File

@ -14,16 +14,16 @@
"prisma:seed": "prisma db seed" "prisma:seed": "prisma db seed"
}, },
"prisma": { "prisma": {
"seed": "ts-node --transpileOnly --skipProject ./seed-database.ts" "seed": "ts-node --transpileOnly --project ./tsconfig.seed.json ./seed-database.ts"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "5.3.1", "@prisma/client": "5.4.2",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"dotenv-cli": "^7.3.0", "dotenv-cli": "^7.3.0",
"prisma": "5.3.1" "prisma": "5.4.2"
}, },
"devDependencies": { "devDependencies": {
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.1.6" "typescript": "5.2.2"
} }
} }

View File

@ -31,7 +31,7 @@ model User {
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
Document Document[] Document Document[]
Subscription Subscription[] Subscription Subscription?
PasswordResetToken PasswordResetToken[] PasswordResetToken PasswordResetToken[]
} }

View File

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"module": "NodeNext"
}
}

View File

@ -1,5 +1,6 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document'; import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
@ -63,13 +64,25 @@ export const documentRouter = router({
try { try {
const { title, documentDataId } = input; const { title, documentDataId } = input;
const { remaining } = await getServerLimits({ email: ctx.user.email });
if (remaining.documents <= 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'You have reached your document limit for this month. Please upgrade your plan.',
});
}
return await createDocument({ return await createDocument({
userId: ctx.user.id, userId: ctx.user.id,
title, title,
documentDataId, documentDataId,
}); });
} catch (err) { } catch (err) {
console.error(err); if (err instanceof TRPCError) {
throw err;
}
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',

View File

@ -3,7 +3,7 @@ import { z } from 'zod';
export const ZSignFieldWithTokenMutationSchema = z.object({ export const ZSignFieldWithTokenMutationSchema = z.object({
token: z.string(), token: z.string(),
fieldId: z.number(), fieldId: z.number(),
value: z.string(), value: z.string().trim(),
isBase64: z.boolean().optional(), isBase64: z.boolean().optional(),
}); });

View File

@ -22,7 +22,7 @@
"@types/react": "18.2.18", "@types/react": "18.2.18",
"@types/react-dom": "18.2.7", "@types/react-dom": "18.2.7",
"react": "18.2.0", "react": "18.2.0",
"typescript": "^5.1.6" "typescript": "5.2.2"
}, },
"dependencies": { "dependencies": {
"@documenso/lib": "*", "@documenso/lib": "*",
@ -60,11 +60,11 @@
"framer-motion": "^10.12.8", "framer-motion": "^10.12.8",
"lucide-react": "^0.279.0", "lucide-react": "^0.279.0",
"luxon": "^3.4.2", "luxon": "^3.4.2",
"next": "13.4.19", "next": "13.5.4",
"pdfjs-dist": "3.6.172", "pdfjs-dist": "3.6.172",
"react-day-picker": "^8.7.1", "react-day-picker": "^8.7.1",
"react-hook-form": "^7.45.4", "react-hook-form": "^7.45.4",
"react-pdf": "^7.3.3", "react-pdf": "7.3.3",
"react-rnd": "^10.4.1", "react-rnd": "^10.4.1",
"tailwind-merge": "^1.12.0", "tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5" "tailwindcss-animate": "^1.0.5"

View File

@ -11,12 +11,8 @@ const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger; const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = ({ const AlertDialogPortal = ({ children, ...props }: AlertDialogPrimitive.AlertDialogPortalProps) => (
className, <AlertDialogPrimitive.Portal {...props}>
children,
...props
}: AlertDialogPrimitive.AlertDialogPortalProps) => (
<AlertDialogPrimitive.Portal className={cn(className)} {...props}>
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center"> <div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
{children} {children}
</div> </div>

View File

@ -12,12 +12,11 @@ const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger; const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = ({ const DialogPortal = ({
className,
children, children,
position = 'start', position = 'start',
...props ...props
}: DialogPrimitive.DialogPortalProps & { position?: 'start' | 'end' | 'center' }) => ( }: DialogPrimitive.DialogPortalProps & { position?: 'start' | 'end' | 'center' }) => (
<DialogPrimitive.Portal className={cn(className)} {...props}> <DialogPrimitive.Portal {...props}>
<div <div
className={cn('fixed inset-0 z-50 flex justify-center sm:items-center', { className={cn('fixed inset-0 z-50 flex justify-center sm:items-center', {
'items-start': position === 'start', 'items-start': position === 'start',

View File

@ -74,16 +74,23 @@ const DocumentDropzoneCardCenterVariants: Variants = {
export type DocumentDropzoneProps = { export type DocumentDropzoneProps = {
className?: string; className?: string;
disabled?: boolean;
onDrop?: (_file: File) => void | Promise<void>; onDrop?: (_file: File) => void | Promise<void>;
[key: string]: unknown; [key: string]: unknown;
}; };
export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzoneProps) => { export const DocumentDropzone = ({
className,
onDrop,
disabled,
...props
}: DocumentDropzoneProps) => {
const { getRootProps, getInputProps } = useDropzone({ const { getRootProps, getInputProps } = useDropzone({
accept: { accept: {
'application/pdf': ['.pdf'], 'application/pdf': ['.pdf'],
}, },
multiple: false, multiple: false,
disabled,
onDrop: ([acceptedFile]) => { onDrop: ([acceptedFile]) => {
if (acceptedFile && onDrop) { if (acceptedFile && onDrop) {
void onDrop(acceptedFile); void onDrop(acceptedFile);
@ -102,11 +109,12 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
<Card <Card
role="button" role="button"
className={cn( className={cn(
'focus-visible:ring-ring ring-offset-background flex flex-1 cursor-pointer flex-col items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2', 'focus-visible:ring-ring ring-offset-background flex flex-1 cursor-pointer flex-col items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 aria-disabled:pointer-events-none aria-disabled:opacity-60',
className, className,
)} )}
gradient={true} gradient={true}
degrees={120} degrees={120}
aria-disabled={disabled}
{...getRootProps()} {...getRootProps()}
{...props} {...props}
> >

View File

@ -246,12 +246,12 @@ export const AddFieldsFormPartial = ({
useEffect(() => { useEffect(() => {
if (selectedField) { if (selectedField) {
window.addEventListener('mousemove', onMouseMove); window.addEventListener('mousemove', onMouseMove);
window.addEventListener('click', onMouseClick); window.addEventListener('mouseup', onMouseClick);
} }
return () => { return () => {
window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('click', onMouseClick); window.removeEventListener('mouseup', onMouseClick);
}; };
}, [onMouseClick, onMouseMove, selectedField]); }, [onMouseClick, onMouseMove, selectedField]);
@ -417,7 +417,7 @@ export const AddFieldsFormPartial = ({
type="button" type="button"
className="group h-full w-full" className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT} disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={(e) => e.stopPropagation()} onClick={() => setSelectedField(FieldType.SIGNATURE)}
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)} onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined} data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
> >
@ -441,7 +441,7 @@ export const AddFieldsFormPartial = ({
type="button" type="button"
className="group h-full w-full" className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT} disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={(e) => e.stopPropagation()} onClick={() => setSelectedField(FieldType.EMAIL)}
onMouseDown={() => setSelectedField(FieldType.EMAIL)} onMouseDown={() => setSelectedField(FieldType.EMAIL)}
data-selected={selectedField === FieldType.EMAIL ? true : undefined} data-selected={selectedField === FieldType.EMAIL ? true : undefined}
> >
@ -464,7 +464,7 @@ export const AddFieldsFormPartial = ({
type="button" type="button"
className="group h-full w-full" className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT} disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={(e) => e.stopPropagation()} onClick={() => setSelectedField(FieldType.NAME)}
onMouseDown={() => setSelectedField(FieldType.NAME)} onMouseDown={() => setSelectedField(FieldType.NAME)}
data-selected={selectedField === FieldType.NAME ? true : undefined} data-selected={selectedField === FieldType.NAME ? true : undefined}
> >
@ -487,7 +487,7 @@ export const AddFieldsFormPartial = ({
type="button" type="button"
className="group h-full w-full" className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT} disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={(e) => e.stopPropagation()} onClick={() => setSelectedField(FieldType.DATE)}
onMouseDown={() => setSelectedField(FieldType.DATE)} onMouseDown={() => setSelectedField(FieldType.DATE)}
data-selected={selectedField === FieldType.DATE ? true : undefined} data-selected={selectedField === FieldType.DATE ? true : undefined}
> >

View File

@ -28,8 +28,8 @@ interface SheetPortalProps
extends SheetPrimitive.DialogPortalProps, extends SheetPrimitive.DialogPortalProps,
VariantProps<typeof portalVariants> {} VariantProps<typeof portalVariants> {}
const SheetPortal = ({ position, className, children, ...props }: SheetPortalProps) => ( const SheetPortal = ({ position, children, ...props }: SheetPortalProps) => (
<SheetPrimitive.Portal className={cn(className)} {...props}> <SheetPrimitive.Portal {...props}>
<div className={portalVariants({ position })}>{children}</div> <div className={portalVariants({ position })}>{children}</div>
</SheetPrimitive.Portal> </SheetPrimitive.Portal>
); );

View File

@ -66,6 +66,7 @@
"NEXT_PRIVATE_SMTP_FROM_NAME", "NEXT_PRIVATE_SMTP_FROM_NAME",
"NEXT_PRIVATE_SMTP_FROM_ADDRESS", "NEXT_PRIVATE_SMTP_FROM_ADDRESS",
"NEXT_PRIVATE_STRIPE_API_KEY", "NEXT_PRIVATE_STRIPE_API_KEY",
"NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET",
"VERCEL", "VERCEL",
"VERCEL_ENV", "VERCEL_ENV",
"VERCEL_URL", "VERCEL_URL",
@ -78,4 +79,4 @@
"E2E_TEST_AUTHENTICATE_USER_EMAIL", "E2E_TEST_AUTHENTICATE_USER_EMAIL",
"E2E_TEST_AUTHENTICATE_USER_PASSWORD" "E2E_TEST_AUTHENTICATE_USER_PASSWORD"
] ]
} }