Compare commits
37 Commits
feat/admin
...
feat/plan-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ba4ff1c17 | ||
|
|
652af26754 | ||
|
|
093488a67c | ||
|
|
0d026f3476 | ||
|
|
3e89ec1afc | ||
|
|
df0d18fc81 | ||
|
|
dd25c355ff | ||
|
|
442b089d7f | ||
|
|
1c58b21383 | ||
|
|
ede9eb052d | ||
|
|
4d5275f915 | ||
|
|
55301a9d53 | ||
|
|
c0dd57a4d2 | ||
|
|
cc80773402 | ||
|
|
01e6367b72 | ||
|
|
8dfcfb99e0 | ||
|
|
aecc703317 | ||
|
|
2422c3e7be | ||
|
|
4e1994a0c8 | ||
|
|
a3dce67117 | ||
|
|
64dcd451e9 | ||
|
|
a85523ecfc | ||
|
|
85b32bb15b | ||
|
|
742ad86b10 | ||
|
|
39ff11a59d | ||
|
|
4f5976479a | ||
|
|
d10713b477 | ||
|
|
2efaabd2c3 | ||
|
|
7bc1e9dcc8 | ||
|
|
8848df701c | ||
|
|
2e800d0eed | ||
|
|
70ecc9a4a8 | ||
|
|
4d485940ea | ||
|
|
cbe118b74f | ||
|
|
de9116e9b2 | ||
|
|
027a588604 | ||
|
|
773566f193 |
@@ -15,6 +15,11 @@ 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"
|
||||||
|
|
||||||
|
# [[E2E Tests]]
|
||||||
|
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||||
|
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||||
|
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
|
||||||
|
|
||||||
# [[STORAGE]]
|
# [[STORAGE]]
|
||||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||||
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
||||||
@@ -68,6 +73,7 @@ NEXT_PRIVATE_STRIPE_API_KEY=
|
|||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
||||||
|
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID=
|
||||||
|
|
||||||
# [[FEATURES]]
|
# [[FEATURES]]
|
||||||
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
||||||
|
|||||||
51
.github/workflows/e2e-tests.yml
vendored
Normal file
51
.github/workflows/e2e-tests.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: Playwright Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [feat/refresh]
|
||||||
|
pull_request:
|
||||||
|
branches: [feat/refresh]
|
||||||
|
jobs:
|
||||||
|
e2e_tests:
|
||||||
|
timeout-minutes: 60
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Copy env
|
||||||
|
run: cp .env.example .env
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
- name: Generate Prisma Client
|
||||||
|
run: npm run prisma:generate -w @documenso/prisma
|
||||||
|
- name: Create the database
|
||||||
|
run: npm run prisma:migrate-dev
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: npm run ci
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 30
|
||||||
|
env:
|
||||||
|
NEXT_PRIVATE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
|
||||||
|
NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
1
apps/marketing/process-env.d.ts
vendored
1
apps/marketing/process-env.d.ts
vendored
@@ -7,6 +7,7 @@ declare namespace NodeJS {
|
|||||||
|
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
||||||
|
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
|
||||||
|
|
||||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||||
|
|||||||
@@ -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`,
|
||||||
});
|
});
|
||||||
|
|||||||
1
apps/web/process-env.d.ts
vendored
1
apps/web/process-env.d.ts
vendored
@@ -7,6 +7,7 @@ declare namespace NodeJS {
|
|||||||
|
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
||||||
|
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
|
||||||
|
|
||||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface User {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
Subscription: SubscriptionLite[];
|
Subscription?: SubscriptionLite | null;
|
||||||
Document: DocumentLite[];
|
Document: DocumentLite[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
if (error instanceof TRPCClientError) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'An error occurred while uploading your document.',
|
description: 'An error occurred while uploading your document.',
|
||||||
variant: 'destructive',
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
<LimitsProvider>
|
||||||
<Header user={user} />
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
133
apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx
Normal file
133
apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
|
import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { createCheckout } from './create-checkout.action';
|
||||||
|
|
||||||
|
type Interval = keyof PriceIntervals;
|
||||||
|
|
||||||
|
const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
|
||||||
|
|
||||||
|
const FRIENDLY_INTERVALS: Record<Interval, string> = {
|
||||||
|
day: 'Daily',
|
||||||
|
week: 'Weekly',
|
||||||
|
month: 'Monthly',
|
||||||
|
year: 'Yearly',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MotionCard = motion(Card);
|
||||||
|
|
||||||
|
export type BillingPlansProps = {
|
||||||
|
prices: PriceIntervals;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
|
const [interval, setInterval] = useState<Interval>('month');
|
||||||
|
const [isFetchingCheckoutSession, setIsFetchingCheckoutSession] = useState(false);
|
||||||
|
|
||||||
|
const onSubscribeClick = async (priceId: string) => {
|
||||||
|
try {
|
||||||
|
setIsFetchingCheckoutSession(true);
|
||||||
|
|
||||||
|
const url = await createCheckout({ priceId });
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('Unable to create session');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(url);
|
||||||
|
} catch (_err) {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'An error occurred while trying to create a checkout session.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsFetchingCheckoutSession(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Tabs value={interval} onValueChange={(value) => isInterval(value) && setInterval(value)}>
|
||||||
|
<TabsList>
|
||||||
|
{INTERVALS.map(
|
||||||
|
(interval) =>
|
||||||
|
prices[interval].length > 0 && (
|
||||||
|
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
|
||||||
|
{FRIENDLY_INTERVALS[interval]}
|
||||||
|
</TabsTrigger>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{prices[interval].map((price) => (
|
||||||
|
<MotionCard
|
||||||
|
key={price.id}
|
||||||
|
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
|
||||||
|
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0.3 } }}
|
||||||
|
>
|
||||||
|
<CardContent className="flex h-full flex-col p-6">
|
||||||
|
<CardTitle>{price.product.name}</CardTitle>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground mt-2 text-lg font-medium">
|
||||||
|
${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '}
|
||||||
|
<span className="text-xs">per {interval}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground mt-1.5 text-sm">
|
||||||
|
{price.product.description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{price.product.features && price.product.features.length > 0 && (
|
||||||
|
<div className="text-muted-foreground mt-4">
|
||||||
|
<div className="text-sm font-medium">Includes:</div>
|
||||||
|
|
||||||
|
<ul className="mt-1 divide-y text-sm">
|
||||||
|
{price.product.features.map((feature, index) => (
|
||||||
|
<li key={index} className="py-2">
|
||||||
|
{feature.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="mt-4"
|
||||||
|
loading={isFetchingCheckoutSession}
|
||||||
|
onClick={() => void onSubscribeClick(price.id)}
|
||||||
|
>
|
||||||
|
Subscribe
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</MotionCard>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { createBillingPortal } from './create-billing-portal.action';
|
||||||
|
|
||||||
|
export const BillingPortalButton = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
||||||
|
|
||||||
|
const handleFetchPortalUrl = async () => {
|
||||||
|
if (isFetchingPortalUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFetchingPortalUrl(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionUrl = await createBillingPortal();
|
||||||
|
|
||||||
|
if (!sessionUrl) {
|
||||||
|
throw new Error('NO_SESSION');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(sessionUrl, '_blank');
|
||||||
|
} catch (e) {
|
||||||
|
let description =
|
||||||
|
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.';
|
||||||
|
|
||||||
|
if (e.message === 'CUSTOMER_NOT_FOUND') {
|
||||||
|
description =
|
||||||
|
'You do not currently have a customer record, this should not happen. Please contact support for assistance.';
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFetchingPortalUrl(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
|
||||||
|
Manage Subscription
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getStripeCustomerByEmail,
|
||||||
|
getStripeCustomerById,
|
||||||
|
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||||
|
|
||||||
|
export const createBillingPortal = async () => {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
||||||
|
|
||||||
|
let stripeCustomer: Stripe.Customer | null = null;
|
||||||
|
|
||||||
|
// Find the Stripe customer for the current user subscription.
|
||||||
|
if (existingSubscription) {
|
||||||
|
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
|
||||||
|
|
||||||
|
if (!stripeCustomer) {
|
||||||
|
throw new Error('Missing Stripe customer for subscription');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to check if a Stripe customer already exists for the current user email.
|
||||||
|
if (!stripeCustomer) {
|
||||||
|
stripeCustomer = await getStripeCustomerByEmail(user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a Stripe customer if it does not exist for the current user.
|
||||||
|
if (!stripeCustomer) {
|
||||||
|
stripeCustomer = await stripe.customers.create({
|
||||||
|
name: user.name ?? undefined,
|
||||||
|
email: user.email,
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPortalSession({
|
||||||
|
customerId: stripeCustomer.id,
|
||||||
|
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||||
|
import {
|
||||||
|
getStripeCustomerByEmail,
|
||||||
|
getStripeCustomerById,
|
||||||
|
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||||
|
|
||||||
|
export type CreateCheckoutOptions = {
|
||||||
|
priceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
||||||
|
|
||||||
|
let stripeCustomer: Stripe.Customer | null = null;
|
||||||
|
|
||||||
|
// Find the Stripe customer for the current user subscription.
|
||||||
|
if (existingSubscription) {
|
||||||
|
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
|
||||||
|
|
||||||
|
if (!stripeCustomer) {
|
||||||
|
throw new Error('Missing Stripe customer for subscription');
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPortalSession({
|
||||||
|
customerId: stripeCustomer.id,
|
||||||
|
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to check if a Stripe customer already exists for the current user email.
|
||||||
|
if (!stripeCustomer) {
|
||||||
|
stripeCustomer = await getStripeCustomerByEmail(user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a Stripe customer if it does not exist for the current user.
|
||||||
|
if (!stripeCustomer) {
|
||||||
|
stripeCustomer = await stripe.customers.create({
|
||||||
|
name: user.name ?? undefined,
|
||||||
|
email: user.email,
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCheckoutSession({
|
||||||
|
customerId: stripeCustomer.id,
|
||||||
|
priceId,
|
||||||
|
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
import { match } from 'ts-pattern';
|
||||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
|
||||||
|
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 } 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 { SubscriptionStatus } from '@documenso/prisma/client';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
import { BillingPlans } from './billing-plans';
|
||||||
|
import { BillingPortalButton } from './billing-portal-button';
|
||||||
|
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
@@ -21,57 +24,73 @@ export default async function BillingSettingsPage() {
|
|||||||
redirect('/settings/profile');
|
redirect('/settings/profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => {
|
const [subscription, prices] = await Promise.all([
|
||||||
if (sub) {
|
getSubscriptionByUserId({ userId: user.id }),
|
||||||
return sub;
|
getPricesByInterval(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let subscriptionProduct: Stripe.Product | null = null;
|
||||||
|
|
||||||
|
if (subscription?.priceId) {
|
||||||
|
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we don't have a customer record, create one as well as an empty subscription.
|
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
|
||||||
return createCustomer({ user });
|
|
||||||
});
|
|
||||||
|
|
||||||
let billingPortalUrl = '';
|
|
||||||
|
|
||||||
if (subscription.customerId) {
|
|
||||||
billingPortalUrl = await getPortalSession({
|
|
||||||
customerId: subscription.customerId,
|
|
||||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Billing</h3>
|
<h3 className="text-lg font-medium">Billing</h3>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<div className="text-muted-foreground mt-2 text-sm">
|
||||||
Your subscription is{' '}
|
{isMissingOrInactiveOrFreePlan && (
|
||||||
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
|
<p>
|
||||||
{subscription?.periodEnd && (
|
You are currently on the <span className="font-semibold">Free Plan</span>.
|
||||||
<>
|
</p>
|
||||||
{' '}
|
)}
|
||||||
Your next payment is due on{' '}
|
|
||||||
<span className="font-semibold">
|
{!isMissingOrInactiveOrFreePlan &&
|
||||||
<LocaleDate date={subscription.periodEnd} />
|
match(subscription.status)
|
||||||
|
.with('ACTIVE', () => (
|
||||||
|
<p>
|
||||||
|
{subscriptionProduct ? (
|
||||||
|
<span>
|
||||||
|
You are currently subscribed to{' '}
|
||||||
|
<span className="font-semibold">{subscriptionProduct.name}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>You currently have an active plan</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subscription.periodEnd && (
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
which is set to{' '}
|
||||||
|
{subscription.cancelAtPeriodEnd ? (
|
||||||
|
<span>
|
||||||
|
end on{' '}
|
||||||
|
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
automatically renew on{' '}
|
||||||
|
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
.
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
))
|
||||||
|
.with('PAST_DUE', () => (
|
||||||
|
<p>Your current plan is past due. Please update your payment information.</p>
|
||||||
|
))
|
||||||
|
.otherwise(() => null)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
{billingPortalUrl && (
|
{isMissingOrInactiveOrFreePlan ? <BillingPlans prices={prices} /> : <BillingPortalButton />}
|
||||||
<Button asChild>
|
|
||||||
<Link href={billingPortalUrl}>Manage Subscription</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!billingPortalUrl && (
|
|
||||||
<p className="text-muted-foreground max-w-[60ch] text-base">
|
|
||||||
You do not currently have a customer record, this should not happen. Please contact
|
|
||||||
support for assistance.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`,
|
||||||
});
|
});
|
||||||
|
|||||||
3
apps/web/src/pages/api/limits/index.ts
Normal file
3
apps/web/src/pages/api/limits/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { limitsHandler } from '@documenso/ee/server-only/limits/handler';
|
||||||
|
|
||||||
|
export default limitsHandler;
|
||||||
@@ -1,197 +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 { 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,
|
|
||||||
} 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 === '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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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' });
|
|
||||||
// }
|
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
367
package-lock.json
generated
367
package-lock.json
generated
@@ -1848,6 +1848,10 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@documenso/app-tests": {
|
||||||
|
"resolved": "packages/app-tests",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@documenso/ee": {
|
"node_modules/@documenso/ee": {
|
||||||
"resolved": "packages/ee",
|
"resolved": "packages/ee",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -2461,6 +2465,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hapi/hoek": {
|
||||||
|
"version": "9.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||||
|
"integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="
|
||||||
|
},
|
||||||
|
"node_modules/@hapi/topo": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@hapi/hoek": "^9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.0.tgz",
|
||||||
@@ -3797,6 +3814,21 @@
|
|||||||
"url": "https://opencollective.com/unts"
|
"url": "https://opencollective.com/unts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.38.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz",
|
||||||
|
"integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.38.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.3.1.tgz",
|
||||||
@@ -5446,6 +5478,24 @@
|
|||||||
"url": "https://ko-fi.com/killymxi"
|
"url": "https://ko-fi.com/killymxi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@sideway/address": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@hapi/hoek": "^9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sideway/formula": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg=="
|
||||||
|
},
|
||||||
|
"node_modules/@sideway/pinpoint": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
|
||||||
|
},
|
||||||
"node_modules/@sindresorhus/slugify": {
|
"node_modules/@sindresorhus/slugify": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz",
|
||||||
@@ -7630,6 +7680,14 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/check-more-types": {
|
||||||
|
"version": "2.24.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz",
|
||||||
|
"integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.5.3",
|
"version": "3.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||||
@@ -9132,6 +9190,11 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/duplexer": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
||||||
|
},
|
||||||
"node_modules/duplexer2": {
|
"node_modules/duplexer2": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||||
@@ -10336,6 +10399,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/event-stream": {
|
||||||
|
"version": "3.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz",
|
||||||
|
"integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==",
|
||||||
|
"dependencies": {
|
||||||
|
"duplexer": "~0.1.1",
|
||||||
|
"from": "~0",
|
||||||
|
"map-stream": "~0.1.0",
|
||||||
|
"pause-stream": "0.0.11",
|
||||||
|
"split": "0.3",
|
||||||
|
"stream-combiner": "~0.0.4",
|
||||||
|
"through": "~2.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eventemitter3": {
|
"node_modules/eventemitter3": {
|
||||||
"version": "4.0.7",
|
"version": "4.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
@@ -10735,6 +10812,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/from": {
|
||||||
|
"version": "0.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
|
||||||
|
"integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g=="
|
||||||
|
},
|
||||||
"node_modules/fs-constants": {
|
"node_modules/fs-constants": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
@@ -12173,6 +12255,18 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
|
||||||
"integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="
|
"integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/joi": {
|
||||||
|
"version": "17.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/joi/-/joi-17.10.2.tgz",
|
||||||
|
"integrity": "sha512-hcVhjBxRNW/is3nNLdGLIjkgXetkeGc2wyhydhz8KumG23Aerk4HPjU5zaPAMRqXQFc0xNqXTC7+zQjxr0GlKA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@hapi/hoek": "^9.0.0",
|
||||||
|
"@hapi/topo": "^5.0.0",
|
||||||
|
"@sideway/address": "^4.1.3",
|
||||||
|
"@sideway/formula": "^3.0.1",
|
||||||
|
"@sideway/pinpoint": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jose": {
|
"node_modules/jose": {
|
||||||
"version": "4.14.4",
|
"version": "4.14.4",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz",
|
||||||
@@ -12407,6 +12501,14 @@
|
|||||||
"language-subtag-registry": "~0.3.2"
|
"language-subtag-registry": "~0.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lazy-ass": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==",
|
||||||
|
"engines": {
|
||||||
|
"node": "> 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/leac": {
|
"node_modules/leac": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||||
@@ -12954,6 +13056,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/map-stream": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g=="
|
||||||
|
},
|
||||||
"node_modules/markdown-extensions": {
|
"node_modules/markdown-extensions": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-1.1.1.tgz",
|
||||||
@@ -15007,6 +15114,14 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pause-stream": {
|
||||||
|
"version": "0.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
|
||||||
|
"integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==",
|
||||||
|
"dependencies": {
|
||||||
|
"through": "~2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pdf-lib": {
|
"node_modules/pdf-lib": {
|
||||||
"version": "1.17.1",
|
"version": "1.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||||
@@ -15105,6 +15220,36 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.38.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz",
|
||||||
|
"integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.38.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.38.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz",
|
||||||
|
"integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.4.27",
|
"version": "8.4.27",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz",
|
||||||
@@ -15655,6 +15800,20 @@
|
|||||||
"resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
|
||||||
"integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q=="
|
"integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/ps-tree": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==",
|
||||||
|
"dependencies": {
|
||||||
|
"event-stream": "=3.3.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"ps-tree": "bin/ps-tree.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pump": {
|
"node_modules/pump": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
|
||||||
@@ -17298,6 +17457,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rusha/-/rusha-0.8.14.tgz",
|
"resolved": "https://registry.npmjs.org/rusha/-/rusha-0.8.14.tgz",
|
||||||
"integrity": "sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA=="
|
"integrity": "sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||||
|
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sade": {
|
"node_modules/sade": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||||
@@ -17673,6 +17840,17 @@
|
|||||||
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz",
|
||||||
"integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w=="
|
"integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w=="
|
||||||
},
|
},
|
||||||
|
"node_modules/split": {
|
||||||
|
"version": "0.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz",
|
||||||
|
"integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==",
|
||||||
|
"dependencies": {
|
||||||
|
"through": "2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/split2": {
|
"node_modules/split2": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz",
|
||||||
@@ -17704,6 +17882,121 @@
|
|||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/start-server-and-test": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-8PFo4DLLLCDMuS51/BEEtE1m9CAXw1LNVtZSS1PzkYQh6Qf9JUwM4huYeSoUumaaoAyuwYBwCa9OsrcpMqcOdQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"arg": "^5.0.2",
|
||||||
|
"bluebird": "3.7.2",
|
||||||
|
"check-more-types": "2.24.0",
|
||||||
|
"debug": "4.3.4",
|
||||||
|
"execa": "5.1.1",
|
||||||
|
"lazy-ass": "1.6.0",
|
||||||
|
"ps-tree": "1.2.0",
|
||||||
|
"wait-on": "7.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"server-test": "src/bin/start.js",
|
||||||
|
"start-server-and-test": "src/bin/start.js",
|
||||||
|
"start-test": "src/bin/start.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/start-server-and-test/node_modules/arg": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="
|
||||||
|
},
|
||||||
|
"node_modules/start-server-and-test/node_modules/bluebird": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
|
||||||
|
},
|
||||||
|
"node_modules/start-server-and-test/node_modules/execa": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.3",
|
||||||
|
"get-stream": "^6.0.0",
|
||||||
|
"human-signals": "^2.1.0",
|
||||||
|
"is-stream": "^2.0.0",
|
||||||
|
"merge-stream": "^2.0.0",
|
||||||
|
"npm-run-path": "^4.0.1",
|
||||||
|
"onetime": "^5.1.2",
|
||||||
|
"signal-exit": "^3.0.3",
|
||||||
|
"strip-final-newline": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/start-server-and-test/node_modules/human-signals": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/start-server-and-test/node_modules/is-stream": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/start-server-and-test/node_modules/mimic-fn": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/start-server-and-test/node_modules/npm-run-path": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/start-server-and-test/node_modules/onetime": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
|
||||||
|
"dependencies": {
|
||||||
|
"mimic-fn": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/start-server-and-test/node_modules/strip-final-newline": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
|
||||||
@@ -17712,6 +18005,14 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stream-combiner": {
|
||||||
|
"version": "0.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz",
|
||||||
|
"integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==",
|
||||||
|
"dependencies": {
|
||||||
|
"duplexer": "~0.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stream-shift": {
|
"node_modules/stream-shift": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz",
|
||||||
@@ -18257,8 +18558,7 @@
|
|||||||
"node_modules/through": {
|
"node_modules/through": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/through2": {
|
"node_modules/through2": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
@@ -19122,6 +19422,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "5.25.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz",
|
||||||
|
"integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/unified": {
|
"node_modules/unified": {
|
||||||
"version": "10.1.2",
|
"version": "10.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz",
|
||||||
@@ -19519,6 +19825,24 @@
|
|||||||
"d3-timer": "^3.0.1"
|
"d3-timer": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wait-on": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog==",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^0.27.2",
|
||||||
|
"joi": "^17.7.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"minimist": "^1.2.7",
|
||||||
|
"rxjs": "^7.8.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"wait-on": "bin/wait-on"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/watchpack": {
|
"node_modules/watchpack": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
|
||||||
@@ -19788,15 +20112,49 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/app-tests": {
|
||||||
|
"name": "@documenso/app-tests",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "to-update",
|
||||||
|
"dependencies": {
|
||||||
|
"start-server-and-test": "^2.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@documenso/web": "*",
|
||||||
|
"@playwright/test": "^1.18.1",
|
||||||
|
"@types/node": "^20.8.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/app-tests/node_modules/@types/node": {
|
||||||
|
"version": "20.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.4.tgz",
|
||||||
|
"integrity": "sha512-ZVPnqU58giiCjSxjVUESDtdPk4QR5WQhhINbc9UBrKLU68MX5BF6kbQzTrkwbolyr0X8ChBpXfavr5mZFKZQ5A==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~5.25.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/ee": {
|
"packages/ee": {
|
||||||
"name": "@documenso/ee",
|
"name": "@documenso/ee",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "COMMERCIAL",
|
"license": "COMMERCIAL",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
"@documenso/prisma": "*"
|
"@documenso/prisma": "*",
|
||||||
|
"luxon": "^3.4.0",
|
||||||
|
"micro": "^10.0.1",
|
||||||
|
"next": "13.4.19",
|
||||||
|
"next-auth": "4.22.3",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"ts-pattern": "^5.0.5",
|
||||||
|
"zod": "^3.21.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/ee/node_modules/ts-pattern": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-tL0w8U/pgaacOmkb9fRlYzWEUDCfVjjv9dD4wHTgZ61MjhuMt46VNWTG747NqW6vRzoWIKABVhFSOJ82FvXrfA=="
|
||||||
|
},
|
||||||
"packages/email": {
|
"packages/email": {
|
||||||
"name": "@documenso/email",
|
"name": "@documenso/email",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -19856,7 +20214,8 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev",
|
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev",
|
||||||
"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",
|
||||||
"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 --"
|
"with:env": "dotenv -e .env -e .env.local --"
|
||||||
|
|||||||
4
packages/app-tests/.gitignore
vendored
Normal file
4
packages/app-tests/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/playwright/.cache/
|
||||||
55
packages/app-tests/e2e/test-auth-flow.spec.ts
Normal file
55
packages/app-tests/e2e/test-auth-flow.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { type Page, expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
|
||||||
|
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
/*
|
||||||
|
Using them sequentially so the 2nd test
|
||||||
|
uses the details from the 1st (registration) test
|
||||||
|
*/
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
const username = process.env.E2E_TEST_AUTHENTICATE_USERNAME;
|
||||||
|
const email = process.env.E2E_TEST_AUTHENTICATE_USER_EMAIL;
|
||||||
|
const password = process.env.E2E_TEST_AUTHENTICATE_USER_PASSWORD;
|
||||||
|
|
||||||
|
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
|
||||||
|
await page.goto('/signup');
|
||||||
|
await page.getByLabel('Name').fill(username);
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||||
|
|
||||||
|
const canvas = page.locator('canvas');
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
|
||||||
|
if (box) {
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL('/documents');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user can login with user and password', async ({ page }: { page: Page }) => {
|
||||||
|
await page.goto('/signin');
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
await expect(page).toHaveURL('/documents');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async () => {
|
||||||
|
try {
|
||||||
|
await deleteUser({ email });
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Error deleting user: ${e}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
21
packages/app-tests/package.json
Normal file
21
packages/app-tests/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@documenso/app-tests",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "to-update",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test:dev": "playwright test",
|
||||||
|
"test:e2e": "start-server-and-test \"(cd ../../apps/web && npm run start)\" http://localhost:3000 \"playwright test\""
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.18.1",
|
||||||
|
"@types/node": "^20.8.2",
|
||||||
|
"@documenso/web": "*"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"start-server-and-test": "^2.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
packages/app-tests/playwright.config.ts
Normal file
77
packages/app-tests/playwright.config.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// require('dotenv').config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
// {
|
||||||
|
// name: 'firefox',
|
||||||
|
// use: { ...devices['Desktop Firefox'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
// {
|
||||||
|
// name: 'webkit',
|
||||||
|
// use: { ...devices['Desktop Safari'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: { ...devices['Pixel 5'] },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: { ...devices['iPhone 12'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
// webServer: {
|
||||||
|
// command: 'npm run start',
|
||||||
|
// url: 'http://127.0.0.1:3000',
|
||||||
|
// reuseExistingServer: !process.env.CI,
|
||||||
|
// },
|
||||||
|
});
|
||||||
@@ -14,6 +14,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
"@documenso/prisma": "*"
|
"@documenso/prisma": "*",
|
||||||
|
"luxon": "^3.4.0",
|
||||||
|
"micro": "^10.0.1",
|
||||||
|
"next": "13.4.19",
|
||||||
|
"next-auth": "4.22.3",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"ts-pattern": "^5.0.5",
|
||||||
|
"zod": "^3.21.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
packages/ee/server-only/limits/client.ts
Normal file
31
packages/ee/server-only/limits/client.ts
Normal 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;
|
||||||
|
});
|
||||||
|
};
|
||||||
11
packages/ee/server-only/limits/constants.ts
Normal file
11
packages/ee/server-only/limits/constants.ts
Normal 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,
|
||||||
|
};
|
||||||
6
packages/ee/server-only/limits/errors.ts
Normal file
6
packages/ee/server-only/limits/errors.ts
Normal 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',
|
||||||
|
};
|
||||||
54
packages/ee/server-only/limits/handler.ts
Normal file
54
packages/ee/server-only/limits/handler.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
53
packages/ee/server-only/limits/provider/client.tsx
Normal file
53
packages/ee/server-only/limits/provider/client.tsx
Normal 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>;
|
||||||
|
};
|
||||||
18
packages/ee/server-only/limits/provider/server.tsx
Normal file
18
packages/ee/server-only/limits/provider/server.tsx
Normal 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>;
|
||||||
|
};
|
||||||
28
packages/ee/server-only/limits/schema.ts
Normal file
28
packages/ee/server-only/limits/schema.ts
Normal 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>;
|
||||||
78
packages/ee/server-only/limits/server.ts
Normal file
78
packages/ee/server-only/limits/server.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
31
packages/ee/server-only/stripe/get-checkout-session.ts
Normal file
31
packages/ee/server-only/stripe/get-checkout-session.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
|
||||||
|
export type GetCheckoutSessionOptions = {
|
||||||
|
customerId: string;
|
||||||
|
priceId: string;
|
||||||
|
returnUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCheckoutSession = async ({
|
||||||
|
customerId,
|
||||||
|
priceId,
|
||||||
|
returnUrl,
|
||||||
|
}: GetCheckoutSessionOptions) => {
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: priceId,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
success_url: `${returnUrl}?success=true`,
|
||||||
|
cancel_url: `${returnUrl}?canceled=true`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return session.url;
|
||||||
|
};
|
||||||
19
packages/ee/server-only/stripe/get-customer.ts
Normal file
19
packages/ee/server-only/stripe/get-customer.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
|
||||||
|
export const getStripeCustomerByEmail = async (email: string) => {
|
||||||
|
const foundStripeCustomers = await stripe.customers.list({
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
|
||||||
|
return foundStripeCustomers.data[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStripeCustomerById = async (stripeCustomerId: string) => {
|
||||||
|
try {
|
||||||
|
const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId);
|
||||||
|
|
||||||
|
return !stripeCustomer.deleted ? stripeCustomer : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
49
packages/ee/server-only/stripe/get-prices-by-interval.ts
Normal file
49
packages/ee/server-only/stripe/get-prices-by-interval.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
|
||||||
|
// Utility type to handle usage of the `expand` option.
|
||||||
|
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
|
||||||
|
|
||||||
|
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
|
||||||
|
|
||||||
|
export const getPricesByInterval = async () => {
|
||||||
|
let { data: prices } = await stripe.prices.search({
|
||||||
|
query: `active:'true' type:'recurring'`,
|
||||||
|
expand: ['data.product'],
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
prices = prices.filter((price) => {
|
||||||
|
// We use `expand` to get the product, but it's not typed as part of the Price type.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const product = price.product as Stripe.Product;
|
||||||
|
|
||||||
|
// Filter out prices for products that are not active.
|
||||||
|
return product.active;
|
||||||
|
});
|
||||||
|
|
||||||
|
const intervals: PriceIntervals = {
|
||||||
|
day: [],
|
||||||
|
week: [],
|
||||||
|
month: [],
|
||||||
|
year: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add each price to the correct interval.
|
||||||
|
for (const price of prices) {
|
||||||
|
if (price.recurring?.interval) {
|
||||||
|
// We use `expand` to get the product, but it's not typed as part of the Price type.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
intervals[price.recurring.interval].push(price as PriceWithProduct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order all prices by unit_amount.
|
||||||
|
intervals.day.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||||
|
intervals.week.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||||
|
intervals.month.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||||
|
intervals.year.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||||
|
|
||||||
|
return intervals;
|
||||||
|
};
|
||||||
17
packages/ee/server-only/stripe/get-product-by-price-id.ts
Normal file
17
packages/ee/server-only/stripe/get-product-by-price-id.ts
Normal 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;
|
||||||
|
};
|
||||||
224
packages/ee/server-only/stripe/webhook/handler.ts
Normal file
224
packages/ee/server-only/stripe/webhook/handler.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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 };
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ 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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
9
packages/lib/server-only/http/to-next-request.ts
Normal file
9
packages/lib/server-only/http/to-next-request.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
28
packages/lib/server-only/http/with-swr.ts
Normal file
28
packages/lib/server-only/http/with-swr.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="./stripe.d.ts" />
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
export const stripe = new Stripe(process.env.NEXT_PRIVATE_STRIPE_API_KEY ?? '', {
|
export const stripe = new Stripe(process.env.NEXT_PRIVATE_STRIPE_API_KEY ?? '', {
|
||||||
|
|||||||
7
packages/lib/server-only/stripe/stripe.d.ts
vendored
Normal file
7
packages/lib/server-only/stripe/stripe.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
declare module 'stripe' {
|
||||||
|
namespace Stripe {
|
||||||
|
interface Product {
|
||||||
|
features?: Array<{ name: string }>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
25
packages/lib/server-only/user/delete-user.ts
Normal file
25
packages/lib/server-only/user/delete-user.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type DeleteUserOptions = {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteUser = async ({ email }: DeleteUserOptions) => {
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
contains: email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(`User with email ${email} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.user.delete({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
3
packages/lib/universal/stripe/to-human-price.ts
Normal file
3
packages/lib/universal/stripe/to-human-price.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const toHumanPrice = (price: number) => {
|
||||||
|
return Number(price / 100).toFixed(2);
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[userId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Made the column `customerId` on table `Subscription` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
DELETE FROM "Subscription"
|
||||||
|
WHERE "customerId" IS NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Subscription" ADD COLUMN "cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ALTER COLUMN "customerId" SET NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId");
|
||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,11 +55,12 @@ model Subscription {
|
|||||||
status SubscriptionStatus @default(INACTIVE)
|
status SubscriptionStatus @default(INACTIVE)
|
||||||
planId String?
|
planId String?
|
||||||
priceId String?
|
priceId String?
|
||||||
customerId String?
|
customerId String
|
||||||
periodEnd DateTime?
|
periodEnd DateTime?
|
||||||
userId Int
|
userId Int @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
cancelAtPeriodEnd Boolean @default(false)
|
||||||
|
|
||||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
1
packages/tsconfig/process-env.d.ts
vendored
1
packages/tsconfig/process-env.d.ts
vendored
@@ -10,6 +10,7 @@ declare namespace NodeJS {
|
|||||||
|
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
||||||
|
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
|
||||||
|
|
||||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
26
turbo.json
26
turbo.json
@@ -2,13 +2,8 @@
|
|||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^build"],
|
||||||
"^build"
|
"outputs": [".next/**", "!.next/cache/**"]
|
||||||
],
|
|
||||||
"outputs": [
|
|
||||||
".next/**",
|
|
||||||
"!.next/cache/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"lint": {},
|
"lint": {},
|
||||||
"clean": {
|
"clean": {
|
||||||
@@ -17,11 +12,15 @@
|
|||||||
"dev": {
|
"dev": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true
|
||||||
|
},
|
||||||
|
"dev:test": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"test:e2e": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalDependencies": [
|
"globalDependencies": ["**/.env.*local"],
|
||||||
"**/.env.*local"
|
|
||||||
],
|
|
||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
"APP_VERSION",
|
"APP_VERSION",
|
||||||
"NEXTAUTH_URL",
|
"NEXTAUTH_URL",
|
||||||
@@ -34,6 +33,7 @@
|
|||||||
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
|
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
|
||||||
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID",
|
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID",
|
||||||
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID",
|
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID",
|
||||||
|
"NEXT_PUBLIC_STRIPE_FREE_PLAN_ID",
|
||||||
"NEXT_PRIVATE_DATABASE_URL",
|
"NEXT_PRIVATE_DATABASE_URL",
|
||||||
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
|
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
|
||||||
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
||||||
@@ -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",
|
||||||
@@ -73,6 +74,9 @@
|
|||||||
"POSTGRES_URL",
|
"POSTGRES_URL",
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"POSTGRES_PRISMA_URL",
|
"POSTGRES_PRISMA_URL",
|
||||||
"POSTGRES_URL_NON_POOLING"
|
"POSTGRES_URL_NON_POOLING",
|
||||||
|
"E2E_TEST_AUTHENTICATE_USERNAME",
|
||||||
|
"E2E_TEST_AUTHENTICATE_USER_EMAIL",
|
||||||
|
"E2E_TEST_AUTHENTICATE_USER_PASSWORD"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user