diff --git a/.env.example b/.env.example index 45c26f6be..d188894de 100644 --- a/.env.example +++ b/.env.example @@ -29,15 +29,15 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password" # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 NEXT_PUBLIC_UPLOAD_TRANSPORT="database" # OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers. -NEXT_PRIVATE_UPLOAD_ENDPOINT= +NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002" # OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1. -NEXT_PRIVATE_UPLOAD_REGION= +NEXT_PRIVATE_UPLOAD_REGION="unknown" # REQUIRED: Defines the bucket to use for the S3 storage transport. -NEXT_PRIVATE_UPLOAD_BUCKET= +NEXT_PRIVATE_UPLOAD_BUCKET="documenso" # OPTIONAL: Defines the access key ID to use for the S3 storage transport. -NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID= +NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID="documenso" # OPTIONAL: Defines the secret access key to use for the S3 storage transport. -NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY= +NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password" # [[SMTP]] # OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels @@ -77,16 +77,14 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY= NEXT_PRIVATE_STRIPE_API_KEY= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= -NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID= -NEXT_PUBLIC_STRIPE_FREE_PLAN_ID= # [[FEATURES]] # OPTIONAL: Leave blank to disable PostHog and feature flags. NEXT_PUBLIC_POSTHOG_KEY="" -# OPTIONAL: Defines the host to use for PostHog. -NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com" # OPTIONAL: Leave blank to disable billing. NEXT_PUBLIC_FEATURE_BILLING_ENABLED= +# OPTIONAL: Leave blank to allow users to signup through /signup page. +NEXT_PUBLIC_DISABLE_SIGNUP= # This is only required for the marketing site # [[REDIS]] diff --git a/.github/pr-labeler.yml b/.github/labeler.yml similarity index 100% rename from .github/pr-labeler.yml rename to .github/labeler.yml diff --git a/.github/workflows/issue-assignee-check.yml b/.github/workflows/issue-assignee-check.yml index 1ce7a02be..dbd321509 100644 --- a/.github/workflows/issue-assignee-check.yml +++ b/.github/workflows/issue-assignee-check.yml @@ -12,6 +12,10 @@ jobs: if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User' runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 - name: Set up Node.js uses: actions/setup-node@v4 with: diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml index cc272fbfe..78f927e61 100644 --- a/.github/workflows/pr-review-reminder.yml +++ b/.github/workflows/pr-review-reminder.yml @@ -12,6 +12,10 @@ jobs: if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested') runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 - name: Set up Node.js uses: actions/setup-node@v4 with: diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml index ef0a87542..37b764652 100644 --- a/.github/workflows/semantic-pull-requests.yml +++ b/.github/workflows/semantic-pull-requests.yml @@ -1,4 +1,4 @@ -name: "Validate PR Name" +name: 'Validate PR Name' on: pull_request_target: @@ -9,13 +9,31 @@ on: - synchronize permissions: - pull-requests: read + pull-requests: write jobs: validate-pr: name: Validate PR title runs-on: ubuntu-latest steps: + - name: Check PR creator's previous activity + id: check_activity + run: | + CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login') + ACTIVITY=$(curl -s "https://api.github.com/search/commits?q=author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count') + if [ "$ACTIVITY" -eq 0 ]; then + echo "::set-output name=is_new::true" + else + echo "::set-output name=is_new::false" + fi + + - name: Count PRs created by user + id: count_prs + run: | + CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login') + PR_COUNT=$(curl -s "https://api.github.com/search/issues?q=type:pr+is:open+author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count') + echo "::set-output name=pr_count::$PR_COUNT" + - uses: amannn/action-semantic-pull-request@v5 id: lint_pr_title env: @@ -36,7 +54,7 @@ jobs: ${{ steps.lint_pr_title.outputs.error_message }} ``` - - if: ${{ steps.lint_pr_title.outputs.error_message == null }} + - if: ${{ steps.lint_pr_title.outputs.error_message == null && steps.check_activity.outputs.is_new == 'false' && steps.count_prs.outputs.pr_count < 2}} uses: marocchino/sticky-pull-request-comment@v2 with: header: pr-title-lint-error diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 82beed6e2..efd681a71 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -22,4 +22,4 @@ jobs: close-issue-message: 'This issue has been closed because of inactivity.' close-pr-message: 'This PR has been closed because of inactivity.' exempt-pr-labels: 'WIP,on-hold,needs review' - exempt-issue-labels: 'WIP,on-hold,needs review,roadmap' + exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned' diff --git a/README.md b/README.md index 89f44a926..24d932858 100644 --- a/README.md +++ b/README.md @@ -139,11 +139,13 @@ npm run d 1. **App** - http://localhost:3000 2. **Incoming Mail Access** - http://localhost:9000 - 3. **Database Connection Details** + - **Port**: 54320 - **Connection**: Use your favorite database client to connect using the provided port. +4. **S3 Storage Dashboard** - http://localhost:9001 + ## Developer Setup ### Manual Setup diff --git a/apps/marketing/process-env.d.ts b/apps/marketing/process-env.d.ts index 942007d17..207bacef5 100644 --- a/apps/marketing/process-env.d.ts +++ b/apps/marketing/process-env.d.ts @@ -6,8 +6,6 @@ declare namespace NodeJS { NEXT_PRIVATE_DATABASE_URL: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_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_WEBHOOK_SECRET: string; diff --git a/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx b/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx deleted file mode 100644 index 8f826b4de..000000000 --- a/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx +++ /dev/null @@ -1,160 +0,0 @@ -'use client'; - -import React, { useEffect, useState } from 'react'; - -import { useSearchParams } from 'next/navigation'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { Info } from 'lucide-react'; -import { usePlausible } from 'next-plausible'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; - -import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@documenso/ui/primitives/dialog'; -import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { claimPlan } from '~/api/claim-plan/fetcher'; - -import { FormErrorMessage } from '../form/form-error-message'; - -export const ZClaimPlanDialogFormSchema = z.object({ - name: z.string().trim().min(3, { message: 'Please enter a valid name.' }), - email: z.string().email(), -}); - -export type TClaimPlanDialogFormSchema = z.infer; - -export type ClaimPlanDialogProps = { - className?: string; - planId: string; - children: React.ReactNode; -}; - -export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => { - const params = useSearchParams(); - const analytics = useAnalytics(); - const event = usePlausible(); - - const { toast } = useToast(); - - const [open, setOpen] = useState(() => params?.get('cancelled') === 'true'); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - reset, - } = useForm({ - defaultValues: { - name: params?.get('name') ?? '', - email: params?.get('email') ?? '', - }, - resolver: zodResolver(ZClaimPlanDialogFormSchema), - }); - - const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => { - try { - const delay = new Promise((resolve) => { - setTimeout(resolve, 1000); - }); - - const [redirectUrl] = await Promise.all([ - claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }), - delay, - ]); - - event('claim-plan-pricing'); - analytics.capture('Marketing: Claim plan', { planId, email }); - - window.location.href = redirectUrl; - } catch (error) { - event('claim-plan-failed'); - analytics.capture('Marketing: Claim plan failure', { planId, email }); - - toast({ - title: 'Something went wrong', - description: error instanceof Error ? error.message : 'Please try again later.', - variant: 'destructive', - }); - } - }; - - useEffect(() => { - if (!isSubmitting && !open) { - reset(); - } - }, [open]); - - return ( - !isSubmitting && setOpen(value)}> - {children} - - - - Claim your plan - - - We're almost there! Please enter your email address and name to claim your plan. - - - -
-
- {params?.get('cancelled') === 'true' && ( -
-
-
- -
-
-

- You have cancelled the payment process. If you didn't mean to do this, please - try again. -

-
-
-
- )} - -
- - - - - -
- -
- - - - - -
- - -
-
-
-
- ); -}; diff --git a/apps/marketing/src/components/(marketing)/pricing-table.tsx b/apps/marketing/src/components/(marketing)/pricing-table.tsx index 712435e68..b65411064 100644 --- a/apps/marketing/src/components/(marketing)/pricing-table.tsx +++ b/apps/marketing/src/components/(marketing)/pricing-table.tsx @@ -1,9 +1,9 @@ 'use client'; -import { HTMLAttributes, useState } from 'react'; +import type { HTMLAttributes } from 'react'; +import { useState } from 'react'; import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; import { AnimatePresence, motion } from 'framer-motion'; import { usePlausible } from 'next-plausible'; @@ -16,14 +16,9 @@ export type PricingTableProps = HTMLAttributes; const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar'; export const PricingTable = ({ className, ...props }: PricingTableProps) => { - const params = useSearchParams(); const event = usePlausible(); - const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() => - params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID - ? 'YEARLY' - : 'MONTHLY', - ); + const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>('MONTHLY'); return (
diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx index 7fd4aaa49..c1ceadafe 100644 --- a/apps/marketing/src/components/(marketing)/widget.tsx +++ b/apps/marketing/src/components/(marketing)/widget.tsx @@ -26,6 +26,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { claimPlan } from '~/api/claim-plan/fetcher'; +import { STEP } from '../constants'; import { FormErrorMessage } from '../form/form-error-message'; const ZWidgetFormSchema = z @@ -48,13 +49,16 @@ const ZWidgetFormSchema = z export type TWidgetFormSchema = z.infer; +type StepKeys = keyof typeof STEP; +type StepValues = (typeof STEP)[StepKeys]; + export type WidgetProps = HTMLAttributes; export const Widget = ({ className, children, ...props }: WidgetProps) => { const { toast } = useToast(); const event = usePlausible(); - const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL'); + const [step, setStep] = useState(STEP.EMAIL); const [showSigningDialog, setShowSigningDialog] = useState(false); const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState(null); @@ -81,11 +85,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { const signatureText = watch('signatureText'); const stepsRemaining = useMemo(() => { - if (step === 'NAME') { + if (step === STEP.NAME) { return 2; } - if (step === 'SIGN') { + if (step === STEP.EMAIL) { return 1; } @@ -93,16 +97,16 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { }, [step]); const onNextStepClick = () => { - if (step === 'EMAIL') { - setStep('NAME'); + if (step === STEP.EMAIL) { + setStep(STEP.NAME); setTimeout(() => { document.querySelector('#name')?.focus(); }, 0); } - if (step === 'NAME') { - setStep('SIGN'); + if (step === STEP.NAME) { + setStep(STEP.SIGN); setTimeout(() => { document.querySelector('#signatureText')?.focus(); @@ -226,7 +230,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { type="button" className="bg-primary h-full w-14 rounded" disabled={!field.value || !!errors.email?.message} - onClick={() => step === 'EMAIL' && onNextStepClick()} + onClick={() => step === STEP.EMAIL && onNextStepClick()} > Next @@ -238,7 +242,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { - {(step === 'NAME' || step === 'SIGN') && ( + {(step === STEP.NAME || step === STEP.SIGN) && ( { +export const UsersDataTable = ({ + users, + totalPages, + perPage, + page, + individualPriceIds, +}: UsersDataTableProps) => { const [isPending, startTransition] = useTransition(); const updateSearchParams = useUpdateSearchParams(); const [searchString, setSearchString] = useState(''); @@ -100,7 +107,13 @@ export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTa { header: 'Subscription', accessorKey: 'subscription', - cell: ({ row }) => row.original.Subscription?.status ?? 'NONE', + cell: ({ row }) => { + const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) => + individualPriceIds.includes(sub.priceId), + ); + + return foundIndividualSubscription?.status ?? 'NONE'; + }, }, { header: 'Documents', diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx index 686ce7669..069378274 100644 --- a/apps/web/src/app/(dashboard)/admin/users/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -1,3 +1,5 @@ +import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type'; + import { UsersDataTable } from './data-table-users'; import { search } from './fetch-users.actions'; @@ -14,12 +16,23 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag const perPage = Number(searchParams.perPage) || 10; const searchString = searchParams.search || ''; - const { users, totalPages } = await search(searchString, page, perPage); + const [{ users, totalPages }, individualPrices] = await Promise.all([ + search(searchString, page, perPage), + getPricesByType('individual'), + ]); + + const individualPriceIds = individualPrices.map((price) => price.id); return (

Manage users

- +
); } diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx b/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx index ba4c0f818..a931f489b 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; -import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; +import type { 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'; diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts index ee5dbf175..885414515 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts +++ b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts @@ -1,46 +1,13 @@ 'use server'; -import { - getStripeCustomerByEmail, - getStripeCustomerById, -} from '@documenso/ee/server-only/stripe/get-customer'; +import { getStripeCustomerByUser } 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-component-session'; -import type { Stripe } from '@documenso/lib/server-only/stripe'; -import { stripe } from '@documenso/lib/server-only/stripe'; -import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; 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, - }, - }); - } + const { stripeCustomer } = await getStripeCustomerByUser(user); return getPortalSession({ customerId: stripeCustomer.id, diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts index 465d662a1..f8f20030c 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts +++ b/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts @@ -1,55 +1,36 @@ 'use server'; -import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer'; import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session'; -import { - getStripeCustomerByEmail, - getStripeCustomerById, -} from '@documenso/ee/server-only/stripe/get-customer'; +import { getStripeCustomerByUser } 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-component-session'; -import type { Stripe } from '@documenso/lib/server-only/stripe'; -import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; +import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id'; export type CreateCheckoutOptions = { priceId: string; }; export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => { - const { user } = await getRequiredServerComponentSession(); + const session = await getRequiredServerComponentSession(); - const existingSubscription = await getSubscriptionByUserId({ userId: user.id }); + const { user, stripeCustomer } = await getStripeCustomerByUser(session.user); - let stripeCustomer: Stripe.Customer | null = null; + const existingSubscriptions = await getSubscriptionsByUserId({ userId: user.id }); - // Find the Stripe customer for the current user subscription. - if (existingSubscription?.periodEnd && existingSubscription.periodEnd >= new Date()) { - stripeCustomer = await getStripeCustomerById(existingSubscription.customerId); - - if (!stripeCustomer) { - throw new Error('Missing Stripe customer for subscription'); - } + const foundSubscription = existingSubscriptions.find( + (subscription) => + subscription.priceId === priceId && + subscription.periodEnd && + subscription.periodEnd >= new Date(), + ); + if (foundSubscription) { 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) { - await createCustomer({ - user, - }); - - stripeCustomer = await getStripeCustomerByEmail(user.email); - } - return getCheckoutSession({ customerId: stripeCustomer.id, priceId, diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index 61dff3216..74e4bd685 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -2,12 +2,15 @@ import { redirect } from 'next/navigation'; import { match } from 'ts-pattern'; +import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; +import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type'; import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; -import type { Stripe } from '@documenso/lib/server-only/stripe'; -import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; +import { type Stripe } from '@documenso/lib/server-only/stripe'; +import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id'; +import { SubscriptionStatus } from '@documenso/prisma/client'; import { LocaleDate } from '~/components/formatter/locale-date'; @@ -15,7 +18,7 @@ import { BillingPlans } from './billing-plans'; import { BillingPortalButton } from './billing-portal-button'; export default async function BillingSettingsPage() { - const { user } = await getRequiredServerComponentSession(); + let { user } = await getRequiredServerComponentSession(); const isBillingEnabled = await getServerComponentFlag('app_billing'); @@ -24,20 +27,36 @@ export default async function BillingSettingsPage() { redirect('/settings/profile'); } - const [subscription, prices] = await Promise.all([ - getSubscriptionByUserId({ userId: user.id }), - getPricesByInterval(), + if (!user.customerId) { + user = await getStripeCustomerByUser(user).then((result) => result.user); + } + + const [subscriptions, prices, individualPrices] = await Promise.all([ + getSubscriptionsByUserId({ userId: user.id }), + getPricesByInterval({ type: 'individual' }), + getPricesByType('individual'), ]); + const individualPriceIds = individualPrices.map(({ id }) => id); + let subscriptionProduct: Stripe.Product | null = null; + const individualUserSubscriptions = subscriptions.filter(({ priceId }) => + individualPriceIds.includes(priceId), + ); + + const subscription = + individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ?? + individualUserSubscriptions[0]; + if (subscription?.priceId) { subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch( () => null, ); } - const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE'; + const isMissingOrInactiveOrFreePlan = + !subscription || subscription.status === SubscriptionStatus.INACTIVE; return (
diff --git a/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx b/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx index 8c7051caa..39bfba935 100644 --- a/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx @@ -1,3 +1,5 @@ +'use client'; + import React from 'react'; import Link from 'next/link'; diff --git a/apps/web/src/app/(unauthenticated)/signin/page.tsx b/apps/web/src/app/(unauthenticated)/signin/page.tsx index a4890d849..0b0333b65 100644 --- a/apps/web/src/app/(unauthenticated)/signin/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signin/page.tsx @@ -13,12 +13,14 @@ export default function SignInPage() { -

- Don't have an account?{' '} - - Sign up - -

+ {process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && ( +

+ Don't have an account?{' '} + + Sign up + +

+ )}

Create a new account

diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index ab0bba6af..2e352b45a 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -79,8 +79,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { return searchDocumentsData.map((document) => ({ label: document.title, path: `/documents/${document.id}`, - value: - document.title + ' ' + document.Recipient.map((recipient) => recipient.email).join(' '), + value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '), })); }, [searchDocumentsData]); diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index bea7f4aee..25f260575 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -1,10 +1,11 @@ 'use client'; -import { HTMLAttributes, useEffect, useState } from 'react'; +import type { HTMLAttributes } from 'react'; +import { useEffect, useState } from 'react'; import Link from 'next/link'; -import { User } from '@documenso/prisma/client'; +import type { User } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; import { Logo } from '~/components/branding/logo'; @@ -32,7 +33,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => { return (
5 && 'border-b-border', className, )} diff --git a/docker/compose-services.yml b/docker/compose-services.yml index 67c193dbc..85f06a8d7 100644 --- a/docker/compose-services.yml +++ b/docker/compose-services.yml @@ -17,3 +17,20 @@ services: - 9000:9000 - 2500:2500 - 1100:1100 + + minio: + image: minio/minio + container_name: minio + ports: + - 9002:9002 + - 9001:9001 + volumes: + - minio:/data + environment: + MINIO_ROOT_USER: documenso + MINIO_ROOT_PASSWORD: password + entrypoint: sh + command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"' + +volumes: + minio: diff --git a/docker/compose.yml b/docker/compose.yml index b427f419c..9d4f0e951 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -33,7 +33,6 @@ services: - SMTP_MAIL_USER=username - SMTP_MAIL_PASSWORD=password - MAIL_FROM=admin@example.com - - NEXT_PUBLIC_ALLOW_SIGNUP=true ports: - 3000:3000 volumes: diff --git a/lint-staged.config.cjs b/lint-staged.config.cjs index a975cb594..419fa099a 100644 --- a/lint-staged.config.cjs +++ b/lint-staged.config.cjs @@ -1,4 +1,6 @@ module.exports = { - '**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}': ['prettier --write'], - '**/*.yml': ['prettier --write'], + '**/*.{ts,tsx,cts,mts}': ['eslint --fix'], + '**/*.{js,jsx,cjs,mjs}': ['prettier --write'], + '**/*.{yml,mdx}': ['prettier --write'], + '**/*/package.json': ['npm run precommit'], }; diff --git a/package.json b/package.json index 2e708363f..30076100f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "prisma:seed": "npm run with:env -- npm run prisma:seed -w @documenso/prisma", "prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma", "with:env": "dotenv -e .env -e .env.local --", - "reset:hard": "npm run clean && npm i && npm run prisma:generate" + "reset:hard": "npm run clean && npm i && npm run prisma:generate", + "precommit": "npm install && git add package.json package-lock.json" }, "engines": { "npm": ">=8.6.0", @@ -42,7 +43,6 @@ "turbo": "^1.9.3" }, "name": "@documenso/root", - "packageManager": "npm@8.19.2", "workspaces": [ "apps/*", "packages/*" diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts index 548ad108a..f256c6356 100644 --- a/packages/ee/server-only/limits/server.ts +++ b/packages/ee/server-only/limits/server.ts @@ -1,10 +1,10 @@ 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 { SubscriptionStatus } from '@documenso/prisma/client'; +import { getPricesByType } from '../stripe/get-prices-by-type'; import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants'; import { ERROR_CODES } from './errors'; import { ZLimitsSchema } from './schema'; @@ -43,23 +43,29 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => { let quota = structuredClone(FREE_PLAN_LIMITS); let remaining = structuredClone(FREE_PLAN_LIMITS); - // Since we store details and allow for past due plans we need to check if the subscription is active. - if (user.Subscription?.status !== SubscriptionStatus.INACTIVE && user.Subscription?.priceId) { - const { product } = await stripe.prices - .retrieve(user.Subscription.priceId, { - expand: ['product'], - }) - .catch((err) => { - console.error(err); - throw err; - }); + const activeSubscriptions = user.Subscription.filter( + ({ status }) => status === SubscriptionStatus.ACTIVE, + ); - if (typeof product === 'string') { - throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED); + if (activeSubscriptions.length > 0) { + const individualPrices = await getPricesByType('individual'); + + for (const subscription of activeSubscriptions) { + const price = individualPrices.find((price) => price.id === subscription.priceId); + if (!price || typeof price.product === 'string' || price.product.deleted) { + continue; + } + + const currentQuota = ZLimitsSchema.parse( + 'metadata' in price.product ? price.product.metadata : {}, + ); + + // Use the subscription with the highest quota. + if (currentQuota.documents > quota.documents && currentQuota.recipients > quota.recipients) { + quota = currentQuota; + remaining = structuredClone(quota); + } } - - quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {}); - remaining = structuredClone(quota); } const documents = await prisma.document.count({ diff --git a/packages/ee/server-only/stripe/create-customer.ts b/packages/ee/server-only/stripe/create-customer.ts deleted file mode 100644 index 175298d0b..000000000 --- a/packages/ee/server-only/stripe/create-customer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { stripe } from '@documenso/lib/server-only/stripe'; -import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; -import { prisma } from '@documenso/prisma'; -import { User } from '@documenso/prisma/client'; - -export type CreateCustomerOptions = { - user: User; -}; - -export const createCustomer = async ({ user }: CreateCustomerOptions) => { - const existingSubscription = await getSubscriptionByUserId({ userId: user.id }); - - if (existingSubscription) { - throw new Error('User already has a subscription'); - } - - const customer = await stripe.customers.create({ - name: user.name ?? undefined, - email: user.email, - metadata: { - userId: user.id, - }, - }); - - return await prisma.subscription.create({ - data: { - userId: user.id, - customerId: customer.id, - }, - }); -}; diff --git a/packages/ee/server-only/stripe/get-customer.ts b/packages/ee/server-only/stripe/get-customer.ts index 11e782966..c85488e6f 100644 --- a/packages/ee/server-only/stripe/get-customer.ts +++ b/packages/ee/server-only/stripe/get-customer.ts @@ -1,4 +1,8 @@ import { stripe } from '@documenso/lib/server-only/stripe'; +import { prisma } from '@documenso/prisma'; +import type { User } from '@documenso/prisma/client'; + +import { onSubscriptionUpdated } from './webhook/on-subscription-updated'; export const getStripeCustomerByEmail = async (email: string) => { const foundStripeCustomers = await stripe.customers.list({ @@ -17,3 +21,74 @@ export const getStripeCustomerById = async (stripeCustomerId: string) => { return null; } }; + +/** + * Get a stripe customer by user. + * + * Will create a Stripe customer and update the relevant user if one does not exist. + */ +export const getStripeCustomerByUser = async (user: User) => { + if (user.customerId) { + const stripeCustomer = await getStripeCustomerById(user.customerId); + + if (!stripeCustomer) { + throw new Error('Missing Stripe customer'); + } + + return { + user, + stripeCustomer, + }; + } + + let stripeCustomer = await getStripeCustomerByEmail(user.email); + + const isSyncRequired = Boolean(stripeCustomer && !stripeCustomer.deleted); + + if (!stripeCustomer) { + stripeCustomer = await stripe.customers.create({ + name: user.name ?? undefined, + email: user.email, + metadata: { + userId: user.id, + }, + }); + } + + const updatedUser = await prisma.user.update({ + where: { + id: user.id, + }, + data: { + customerId: stripeCustomer.id, + }, + }); + + // Sync subscriptions if the customer already exists for back filling the DB + // and local development. + if (isSyncRequired) { + await syncStripeCustomerSubscriptions(user.id, stripeCustomer.id).catch((e) => { + console.error(e); + }); + } + + return { + user: updatedUser, + stripeCustomer, + }; +}; + +const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => { + const stripeSubscriptions = await stripe.subscriptions.list({ + customer: stripeCustomerId, + }); + + await Promise.all( + stripeSubscriptions.data.map(async (subscription) => + onSubscriptionUpdated({ + userId, + subscription, + }), + ), + ); +}; diff --git a/packages/ee/server-only/stripe/get-prices-by-interval.ts b/packages/ee/server-only/stripe/get-prices-by-interval.ts index f621425cc..a5578a813 100644 --- a/packages/ee/server-only/stripe/get-prices-by-interval.ts +++ b/packages/ee/server-only/stripe/get-prices-by-interval.ts @@ -1,4 +1,4 @@ -import Stripe from 'stripe'; +import type Stripe from 'stripe'; import { stripe } from '@documenso/lib/server-only/stripe'; @@ -7,7 +7,14 @@ type PriceWithProduct = Stripe.Price & { product: Stripe.Product }; export type PriceIntervals = Record; -export const getPricesByInterval = async () => { +export type GetPricesByIntervalOptions = { + /** + * Filter products by their meta 'type' attribute. + */ + type?: 'individual'; +}; + +export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions = {}) => { let { data: prices } = await stripe.prices.search({ query: `active:'true' type:'recurring'`, expand: ['data.product'], @@ -19,8 +26,10 @@ export const getPricesByInterval = async () => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const product = price.product as Stripe.Product; + const filter = !type || product.metadata?.type === type; + // Filter out prices for products that are not active. - return product.active; + return product.active && filter; }); const intervals: PriceIntervals = { diff --git a/packages/ee/server-only/stripe/get-prices-by-type.ts b/packages/ee/server-only/stripe/get-prices-by-type.ts new file mode 100644 index 000000000..22124562c --- /dev/null +++ b/packages/ee/server-only/stripe/get-prices-by-type.ts @@ -0,0 +1,11 @@ +import { stripe } from '@documenso/lib/server-only/stripe'; + +export const getPricesByType = async (type: 'individual') => { + const { data: prices } = await stripe.prices.search({ + query: `metadata['type']:'${type}' type:'recurring'`, + expand: ['data.product'], + limit: 100, + }); + + return prices; +}; diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts index 3058ed261..047de7962 100644 --- a/packages/ee/server-only/stripe/webhook/handler.ts +++ b/packages/ee/server-only/stripe/webhook/handler.ts @@ -75,23 +75,23 @@ export const stripeWebhookHandler = async ( // Finally, attempt to get the user ID from the subscription within the database. if (!userId && customerId) { - const result = await prisma.subscription.findFirst({ + const result = await prisma.user.findFirst({ select: { - userId: true, + id: true, }, where: { customerId, }, }); - if (!result?.userId) { + if (!result?.id) { return res.status(500).json({ success: false, message: 'User not found', }); } - userId = result.userId; + userId = result.id; } const subscriptionId = @@ -124,23 +124,23 @@ export const stripeWebhookHandler = async ( ? subscription.customer : subscription.customer.id; - const result = await prisma.subscription.findFirst({ + const result = await prisma.user.findFirst({ select: { - userId: true, + id: true, }, where: { customerId, }, }); - if (!result?.userId) { + if (!result?.id) { return res.status(500).json({ success: false, message: 'User not found', }); } - await onSubscriptionUpdated({ userId: result.userId, subscription }); + await onSubscriptionUpdated({ userId: result.id, subscription }); return res.status(200).json({ success: true, @@ -182,23 +182,23 @@ export const stripeWebhookHandler = async ( }); } - const result = await prisma.subscription.findFirst({ + const result = await prisma.user.findFirst({ select: { - userId: true, + id: true, }, where: { customerId, }, }); - if (!result?.userId) { + if (!result?.id) { return res.status(500).json({ success: false, message: 'User not found', }); } - await onSubscriptionUpdated({ userId: result.userId, subscription }); + await onSubscriptionUpdated({ userId: result.id, subscription }); return res.status(200).json({ success: true, @@ -233,23 +233,23 @@ export const stripeWebhookHandler = async ( }); } - const result = await prisma.subscription.findFirst({ + const result = await prisma.user.findFirst({ select: { - userId: true, + id: true, }, where: { customerId, }, }); - if (!result?.userId) { + if (!result?.id) { return res.status(500).json({ success: false, message: 'User not found', }); } - await onSubscriptionUpdated({ userId: result.userId, subscription }); + await onSubscriptionUpdated({ userId: result.id, subscription }); return res.status(200).json({ success: true, diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts b/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts index 27ff0cf4d..df1b36f55 100644 --- a/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts +++ b/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts @@ -1,4 +1,4 @@ -import { Stripe } from '@documenso/lib/server-only/stripe'; +import type { Stripe } from '@documenso/lib/server-only/stripe'; import { prisma } from '@documenso/prisma'; import { SubscriptionStatus } from '@documenso/prisma/client'; @@ -7,12 +7,9 @@ export type OnSubscriptionDeletedOptions = { }; export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => { - const customerId = - typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id; - await prisma.subscription.update({ where: { - customerId, + planId: subscription.id, }, data: { status: SubscriptionStatus.INACTIVE, diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts index dfa22d128..d7ce7b062 100644 --- a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts +++ b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts @@ -1,6 +1,6 @@ import { match } from 'ts-pattern'; -import { Stripe } from '@documenso/lib/server-only/stripe'; +import type { Stripe } from '@documenso/lib/server-only/stripe'; import { prisma } from '@documenso/prisma'; import { SubscriptionStatus } from '@documenso/prisma/client'; @@ -13,9 +13,6 @@ 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) @@ -23,22 +20,22 @@ export const onSubscriptionUpdated = async ({ await prisma.subscription.upsert({ where: { - customerId, + planId: subscription.id, }, create: { - customerId, status: status, planId: subscription.id, priceId: subscription.items.data[0].price.id, periodEnd: new Date(subscription.current_period_end * 1000), userId, + cancelAtPeriodEnd: subscription.cancel_at_period_end, }, update: { - customerId, status: status, planId: subscription.id, priceId: subscription.items.data[0].price.id, periodEnd: new Date(subscription.current_period_end * 1000), + cancelAtPeriodEnd: subscription.cancel_at_period_end, }, }); }; diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 6d59b0666..3b9492807 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -162,5 +162,17 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { return session; }, + + async signIn({ user }) { + // We do this to stop OAuth providers from creating an account + // when signups are disabled + if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { + const userData = await getUserByEmail({ email: user.email! }); + + return !!userData; + } + + return true; + }, }, }; diff --git a/packages/lib/server-only/admin/get-users-stats.ts b/packages/lib/server-only/admin/get-users-stats.ts index 13db21d83..09892171a 100644 --- a/packages/lib/server-only/admin/get-users-stats.ts +++ b/packages/lib/server-only/admin/get-users-stats.ts @@ -9,7 +9,9 @@ export const getUsersWithSubscriptionsCount = async () => { return await prisma.user.count({ where: { Subscription: { - status: SubscriptionStatus.ACTIVE, + some: { + status: SubscriptionStatus.ACTIVE, + }, }, }, }); diff --git a/packages/lib/server-only/subscription/get-subscription-by-user-id.ts b/packages/lib/server-only/subscription/get-subscription-by-user-id.ts deleted file mode 100644 index 772134f7c..000000000 --- a/packages/lib/server-only/subscription/get-subscription-by-user-id.ts +++ /dev/null @@ -1,15 +0,0 @@ -'use server'; - -import { prisma } from '@documenso/prisma'; - -export type GetSubscriptionByUserIdOptions = { - userId: number; -}; - -export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => { - return await prisma.subscription.findFirst({ - where: { - userId, - }, - }); -}; diff --git a/packages/lib/server-only/subscription/get-subscriptions-by-user-id.ts b/packages/lib/server-only/subscription/get-subscriptions-by-user-id.ts new file mode 100644 index 000000000..33f6255bd --- /dev/null +++ b/packages/lib/server-only/subscription/get-subscriptions-by-user-id.ts @@ -0,0 +1,15 @@ +'use server'; + +import { prisma } from '@documenso/prisma'; + +export type GetSubscriptionsByUserIdOptions = { + userId: number; +}; + +export const getSubscriptionsByUserId = async ({ userId }: GetSubscriptionsByUserIdOptions) => { + return await prisma.subscription.findMany({ + where: { + userId, + }, + }); +}; diff --git a/packages/lib/server-only/user/create-user.ts b/packages/lib/server-only/user/create-user.ts index 46c43b93b..f7db60c85 100644 --- a/packages/lib/server-only/user/create-user.ts +++ b/packages/lib/server-only/user/create-user.ts @@ -1,9 +1,11 @@ import { hash } from 'bcrypt'; +import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { prisma } from '@documenso/prisma'; import { IdentityProvider } from '@documenso/prisma/client'; import { SALT_ROUNDS } from '../../constants/auth'; +import { getFlag } from '../../universal/get-feature-flag'; export interface CreateUserOptions { name: string; @@ -13,6 +15,8 @@ export interface CreateUserOptions { } export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => { + const isBillingEnabled = await getFlag('app_billing'); + const hashedPassword = await hash(password, SALT_ROUNDS); const userExists = await prisma.user.findFirst({ @@ -25,7 +29,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse throw new Error('User already exists'); } - return await prisma.user.create({ + let user = await prisma.user.create({ data: { name, email: email.toLowerCase(), @@ -34,4 +38,15 @@ export const createUser = async ({ name, email, password, signature }: CreateUse identityProvider: IdentityProvider.DOCUMENSO, }, }); + + if (isBillingEnabled) { + try { + const stripeSession = await getStripeCustomerByUser(user); + user = stripeSession.user; + } catch (e) { + console.error(e); + } + } + + return user; }; diff --git a/packages/prisma/migrations/20231206073509_add_multple_subscriptions/migration.sql b/packages/prisma/migrations/20231206073509_add_multple_subscriptions/migration.sql new file mode 100644 index 000000000..931815bf0 --- /dev/null +++ b/packages/prisma/migrations/20231206073509_add_multple_subscriptions/migration.sql @@ -0,0 +1,31 @@ +/* + Warnings: + + - A unique constraint covering the columns `[planId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[customerId]` on the table `User` will be added. If there are existing duplicate values, this will fail. + - Made the column `planId` on table `Subscription` required. This step will fail if there are existing NULL values in that column. + - Made the column `priceId` on table `Subscription` required. This step will fail if there are existing NULL values in that column. + +*/ +-- Custom migration statement +DELETE FROM "Subscription" WHERE "planId" IS NULL OR "priceId" IS NULL; + +-- DropIndex +DROP INDEX "Subscription_customerId_key"; + +-- DropIndex +DROP INDEX "Subscription_userId_key"; + +-- AlterTable +ALTER TABLE "Subscription" ALTER COLUMN "planId" SET NOT NULL, +ALTER COLUMN "priceId" SET NOT NULL; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "customerId" TEXT; +ALTER TABLE "Subscription" DROP COLUMN "customerId"; + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_planId_key" ON "Subscription"("planId"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_customerId_key" ON "User"("customerId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 75c175adc..88b79517b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -21,6 +21,7 @@ enum Role { model User { id Int @id @default(autoincrement()) name String? + customerId String? @unique email String @unique emailVerified DateTime? password String? @@ -34,7 +35,7 @@ model User { accounts Account[] sessions Session[] Document Document[] - Subscription Subscription? + Subscription Subscription[] PasswordResetToken PasswordResetToken[] twoFactorSecret String? twoFactorEnabled Boolean @default(false) @@ -72,18 +73,16 @@ enum SubscriptionStatus { model Subscription { id Int @id @default(autoincrement()) status SubscriptionStatus @default(INACTIVE) - planId String? - priceId String? - customerId String + planId String @unique + priceId String periodEnd DateTime? - userId Int @unique + userId Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt cancelAtPeriodEnd Boolean @default(false) User User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([customerId]) @@index([userId]) } diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 59c51ade5..24dd272ee 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -11,6 +11,13 @@ import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema'; export const authRouter = router({ signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => { try { + if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Signups are disabled.', + }); + } + const { name, email, password, signature } = input; const user = await createUser({ name, email, password, signature }); diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 717f13ade..badc05931 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -10,8 +10,6 @@ declare namespace NodeJS { NEXT_PRIVATE_ENCRYPTION_KEY: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_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_WEBHOOK_SECRET: string; @@ -55,6 +53,8 @@ declare namespace NodeJS { NEXT_PRIVATE_SMTP_FROM_NAME?: string; NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string; + NEXT_PUBLIC_DISABLE_SIGNUP?: string; + /** * Vercel environment variables */ diff --git a/packages/ui/primitives/toast.tsx b/packages/ui/primitives/toast.tsx index 8b8355323..89616c132 100644 --- a/packages/ui/primitives/toast.tsx +++ b/packages/ui/primitives/toast.tsx @@ -15,7 +15,7 @@ const ToastViewport = React.forwardRef<