diff --git a/apps/remix/app/_todo/app/(unauthenticated)/team/decline/[token]/page.tsx b/apps/remix/app/_todo/app/(unauthenticated)/team/decline/[token]/page.tsx new file mode 100644 index 000000000..06c7dadc9 --- /dev/null +++ b/apps/remix/app/_todo/app/(unauthenticated)/team/decline/[token]/page.tsx @@ -0,0 +1,142 @@ +import Link from 'next/link'; + +import { Trans } from '@lingui/macro'; +import { DateTime } from 'luxon'; + +import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; +import { declineTeamInvitation } from '@documenso/lib/server-only/team/decline-team-invitation'; +import { getTeamById } from '@documenso/lib/server-only/team/get-team'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberInviteStatus } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; + +type DeclineInvitationPageProps = { + params: { + token: string; + }; +}; + +export default async function DeclineInvitationPage({ + params: { token }, +}: DeclineInvitationPageProps) { + await setupI18nSSR(); + + const session = await getServerComponentSession(); + + const teamMemberInvite = await prisma.teamMemberInvite.findUnique({ + where: { + token, + }, + }); + + if (!teamMemberInvite) { + return ( +
+
+

+ Invalid token +

+ +

+ This token is invalid or has expired. No action is needed. +

+ + +
+
+ ); + } + + const team = await getTeamById({ teamId: teamMemberInvite.teamId }); + + const user = await prisma.user.findFirst({ + where: { + email: { + equals: teamMemberInvite.email, + mode: 'insensitive', + }, + }, + }); + + if (user) { + await declineTeamInvitation({ userId: user.id, teamId: team.id }); + } + + if (!user && teamMemberInvite.status !== TeamMemberInviteStatus.DECLINED) { + await prisma.teamMemberInvite.update({ + where: { + id: teamMemberInvite.id, + }, + data: { + status: TeamMemberInviteStatus.DECLINED, + }, + }); + } + + const email = encryptSecondaryData({ + data: teamMemberInvite.email, + expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), + }); + + if (!user) { + return ( +
+

+ Team invitation +

+ +

+ + You have been invited by {team.name} to join their team. + +

+ +

+ To decline this invitation you must create an account. +

+ + +
+ ); + } + + const isSessionUserTheInvitedUser = user?.id === session.user?.id; + + return ( +
+

+ Invitation declined +

+ +

+ + You have declined the invitation from {team.name} to join their team. + +

+ + {isSessionUserTheInvitedUser ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/remix/app/_todo/app/(unauthenticated)/team/invite/[token]/page.tsx b/apps/remix/app/_todo/app/(unauthenticated)/team/invite/[token]/page.tsx new file mode 100644 index 000000000..3441dbed7 --- /dev/null +++ b/apps/remix/app/_todo/app/(unauthenticated)/team/invite/[token]/page.tsx @@ -0,0 +1,147 @@ +import Link from 'next/link'; + +import { Trans } from '@lingui/macro'; +import { DateTime } from 'luxon'; + +import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; +import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation'; +import { getTeamById } from '@documenso/lib/server-only/team/get-team'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberInviteStatus } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; + +type AcceptInvitationPageProps = { + params: { + token: string; + }; +}; + +export default async function AcceptInvitationPage({ + params: { token }, +}: AcceptInvitationPageProps) { + await setupI18nSSR(); + + const session = await getServerComponentSession(); + + const teamMemberInvite = await prisma.teamMemberInvite.findUnique({ + where: { + token, + }, + }); + + if (!teamMemberInvite) { + return ( +
+
+

+ Invalid token +

+ +

+ + This token is invalid or has expired. Please contact your team for a new invitation. + +

+ + +
+
+ ); + } + + const team = await getTeamById({ teamId: teamMemberInvite.teamId }); + + const user = await prisma.user.findFirst({ + where: { + email: { + equals: teamMemberInvite.email, + mode: 'insensitive', + }, + }, + }); + + // Directly convert the team member invite to a team member if they already have an account. + if (user) { + await acceptTeamInvitation({ userId: user.id, teamId: team.id }); + } + + // For users who do not exist yet, set the team invite status to accepted, which is checked during + // user creation to determine if we should add the user to the team at that time. + if (!user && teamMemberInvite.status !== TeamMemberInviteStatus.ACCEPTED) { + await prisma.teamMemberInvite.update({ + where: { + id: teamMemberInvite.id, + }, + data: { + status: TeamMemberInviteStatus.ACCEPTED, + }, + }); + } + + const email = encryptSecondaryData({ + data: teamMemberInvite.email, + expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), + }); + + if (!user) { + return ( +
+

+ Team invitation +

+ +

+ + You have been invited by {team.name} to join their team. + +

+ +

+ To accept this invitation you must create an account. +

+ + +
+ ); + } + + const isSessionUserTheInvitedUser = user.id === session.user?.id; + + return ( +
+

+ Invitation accepted! +

+ +

+ + You have accepted an invitation from {team.name} to join their team. + +

+ + {isSessionUserTheInvitedUser ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/remix/app/_todo/app/(unauthenticated)/team/verify/email/[token]/page.tsx b/apps/remix/app/_todo/app/(unauthenticated)/team/verify/email/[token]/page.tsx new file mode 100644 index 000000000..b53fb5f71 --- /dev/null +++ b/apps/remix/app/_todo/app/(unauthenticated)/team/verify/email/[token]/page.tsx @@ -0,0 +1,148 @@ +import Link from 'next/link'; + +import { Trans } from '@lingui/macro'; + +import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; +import { isTokenExpired } from '@documenso/lib/utils/token-verification'; +import { prisma } from '@documenso/prisma'; +import { Button } from '@documenso/ui/primitives/button'; + +type VerifyTeamEmailPageProps = { + params: { + token: string; + }; +}; + +export default async function VerifyTeamEmailPage({ params: { token } }: VerifyTeamEmailPageProps) { + await setupI18nSSR(); + + const teamEmailVerification = await prisma.teamEmailVerification.findUnique({ + where: { + token, + }, + include: { + team: true, + }, + }); + + if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) { + return ( +
+
+

+ Invalid link +

+ +

+ + This link is invalid or has expired. Please contact your team to resend a + verification. + +

+ + +
+
+ ); + } + + if (teamEmailVerification.completed) { + return ( +
+

+ Team email already verified! +

+ +

+ + You have already verified your email address for{' '} + {teamEmailVerification.team.name}. + +

+ + +
+ ); + } + + const { team } = teamEmailVerification; + + let isTeamEmailVerificationError = false; + + try { + await prisma.$transaction([ + prisma.teamEmailVerification.updateMany({ + where: { + teamId: team.id, + email: teamEmailVerification.email, + }, + data: { + completed: true, + }, + }), + prisma.teamEmailVerification.deleteMany({ + where: { + teamId: team.id, + expiresAt: { + lt: new Date(), + }, + }, + }), + prisma.teamEmail.create({ + data: { + teamId: team.id, + email: teamEmailVerification.email, + name: teamEmailVerification.name, + }, + }), + ]); + } catch (e) { + console.error(e); + isTeamEmailVerificationError = true; + } + + if (isTeamEmailVerificationError) { + return ( +
+

+ Team email verification +

+ +

+ + Something went wrong while attempting to verify your email address for{' '} + {team.name}. Please try again later. + +

+
+ ); + } + + return ( +
+

+ Team email verified! +

+ +

+ + You have verified your email address for {team.name}. + +

+ + +
+ ); +} diff --git a/apps/remix/app/_todo/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx b/apps/remix/app/_todo/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx new file mode 100644 index 000000000..8713aeecd --- /dev/null +++ b/apps/remix/app/_todo/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx @@ -0,0 +1,127 @@ +import Link from 'next/link'; + +import { Trans } from '@lingui/macro'; + +import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; +import { transferTeamOwnership } from '@documenso/lib/server-only/team/transfer-team-ownership'; +import { isTokenExpired } from '@documenso/lib/utils/token-verification'; +import { prisma } from '@documenso/prisma'; +import { Button } from '@documenso/ui/primitives/button'; + +type VerifyTeamTransferPage = { + params: { + token: string; + }; +}; + +export default async function VerifyTeamTransferPage({ + params: { token }, +}: VerifyTeamTransferPage) { + await setupI18nSSR(); + + const teamTransferVerification = await prisma.teamTransferVerification.findUnique({ + where: { + token, + }, + include: { + team: true, + }, + }); + + if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) { + return ( +
+
+

+ Invalid link +

+ +

+ + This link is invalid or has expired. Please contact your team to resend a transfer + request. + +

+ + +
+
+ ); + } + + if (teamTransferVerification.completed) { + return ( +
+

+ Team ownership transfer already completed! +

+ +

+ + You have already completed the ownership transfer for{' '} + {teamTransferVerification.team.name}. + +

+ + +
+ ); + } + + const { team } = teamTransferVerification; + + let isTransferError = false; + + try { + await transferTeamOwnership({ token }); + } catch (e) { + console.error(e); + isTransferError = true; + } + + if (isTransferError) { + return ( +
+

+ Team ownership transfer +

+ +

+ + Something went wrong while attempting to transfer the ownership of team{' '} + {team.name} to your. Please try again later or contact support. + +

+
+ ); + } + + return ( +
+

+ Team ownership transferred! +

+ +

+ + The ownership of team {team.name} has been successfully transferred to + you. + +

+ + +
+ ); +} diff --git a/apps/remix/app/_todo/app/(unauthenticated)/verify-email/[token]/client.tsx b/apps/remix/app/_todo/app/(unauthenticated)/verify-email/[token]/client.tsx new file mode 100644 index 000000000..d7c2a936a --- /dev/null +++ b/apps/remix/app/_todo/app/(unauthenticated)/verify-email/[token]/client.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useEffect } from 'react'; + +import Link from 'next/link'; + +import { Trans } from '@lingui/macro'; +import { CheckCircle2 } from 'lucide-react'; +import { signIn } from 'next-auth/react'; + +import { Button } from '@documenso/ui/primitives/button'; + +export type VerifyEmailPageClientProps = { + signInData?: string; +}; + +export const VerifyEmailPageClient = ({ signInData }: VerifyEmailPageClientProps) => { + useEffect(() => { + if (signInData) { + void signIn('manual', { + credential: signInData, + callbackUrl: '/documents', + }); + } + }, [signInData]); + + return ( +
+
+
+ +
+ +
+

+ Email Confirmed! +

+ +

+ + Your email has been successfully confirmed! You can now use all features of Documenso. + +

+ + {!signInData && ( + + )} +
+
+
+ ); +}; diff --git a/apps/remix/app/_todo/app/(unauthenticated)/verify-email/[token]/page.tsx b/apps/remix/app/_todo/app/(unauthenticated)/verify-email/[token]/page.tsx new file mode 100644 index 000000000..eb88538c4 --- /dev/null +++ b/apps/remix/app/_todo/app/(unauthenticated)/verify-email/[token]/page.tsx @@ -0,0 +1,130 @@ +import Link from 'next/link'; + +import { Trans } from '@lingui/macro'; +import { AlertTriangle, XCircle, XOctagon } from 'lucide-react'; +import { DateTime } from 'luxon'; +import { match } from 'ts-pattern'; + +import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; +import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; +import { + EMAIL_VERIFICATION_STATE, + verifyEmail, +} from '@documenso/lib/server-only/user/verify-email'; +import { prisma } from '@documenso/prisma'; +import { Button } from '@documenso/ui/primitives/button'; + +import { VerifyEmailPageClient } from './client'; + +export type PageProps = { + params: { + token: string; + }; +}; + +export default async function VerifyEmailPage({ params: { token } }: PageProps) { + await setupI18nSSR(); + + if (!token) { + return ( +
+
+
+ +
+ +

+ No token provided +

+

+ + It seems that there is no token provided. Please check your email and try again. + +

+
+
+ ); + } + + const verified = await verifyEmail({ token }); + + return await match(verified) + .with(EMAIL_VERIFICATION_STATE.NOT_FOUND, () => ( +
+
+
+ +
+ +
+

+ Something went wrong +

+ +

+ + We were unable to verify your email. If your email is not verified already, please + try again. + +

+ + +
+
+
+ )) + .with(EMAIL_VERIFICATION_STATE.EXPIRED, () => ( +
+
+
+ +
+ +
+

+ Your token has expired! +

+ +

+ + It seems that the provided token has expired. We've just sent you another token, + please check your email and try again. + +

+ + +
+
+
+ )) + .with(EMAIL_VERIFICATION_STATE.VERIFIED, async () => { + const { user } = await prisma.verificationToken.findFirstOrThrow({ + where: { + token, + }, + include: { + user: true, + }, + }); + + const data = encryptSecondaryData({ + data: JSON.stringify({ + userId: user.id, + email: user.email, + }), + expiresAt: DateTime.now().plus({ minutes: 5 }).toMillis(), + }); + + return ; + }) + .with(EMAIL_VERIFICATION_STATE.ALREADY_VERIFIED, () => ) + .exhaustive(); +} diff --git a/apps/remix/app/_todo/app/(unauthenticated)/verify-email/page.tsx b/apps/remix/app/_todo/app/(unauthenticated)/verify-email/page.tsx new file mode 100644 index 000000000..cd518a913 --- /dev/null +++ b/apps/remix/app/_todo/app/(unauthenticated)/verify-email/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; + +import { Trans } from '@lingui/macro'; +import { XCircle } from 'lucide-react'; + +import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; +import { Button } from '@documenso/ui/primitives/button'; + +export const metadata: Metadata = { + title: 'Verify Email', +}; + +export default async function EmailVerificationWithoutTokenPage() { + await setupI18nSSR(); + + return ( +
+
+
+ +
+ +
+

+ Uh oh! Looks like you're missing a token +

+ +

+ + It seems that there is no token provided, if you are trying to verify your email + please follow the link in your email. + +

+ + +
+
+
+ ); +} diff --git a/apps/remix/app/_todo/middleware.ts b/apps/remix/app/_todo/middleware.ts new file mode 100644 index 000000000..25de9debb --- /dev/null +++ b/apps/remix/app/_todo/middleware.ts @@ -0,0 +1,123 @@ +import { cookies } from 'next/headers'; +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; + +import { getToken } from 'next-auth/jwt'; + +import { TEAM_URL_ROOT_REGEX } from '@documenso/lib/constants/teams'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; + +async function middleware(req: NextRequest): Promise { + const preferredTeamUrl = cookies().get('preferred-team-url'); + + const referrer = req.headers.get('referer'); + const referrerUrl = referrer ? new URL(referrer) : null; + const referrerPathname = referrerUrl ? referrerUrl.pathname : null; + + // Whether to reset the preferred team url cookie if the user accesses a non team page from a team page. + const resetPreferredTeamUrl = + referrerPathname && + referrerPathname.startsWith('/t/') && + (!req.nextUrl.pathname.startsWith('/t/') || req.nextUrl.pathname === '/'); + + // Redirect root page to `/documents` or `/t/{preferredTeamUrl}/documents`. + if (req.nextUrl.pathname === '/') { + const redirectUrlPath = formatDocumentsPath( + resetPreferredTeamUrl ? undefined : preferredTeamUrl?.value, + ); + + const redirectUrl = new URL(redirectUrlPath, req.url); + const response = NextResponse.redirect(redirectUrl); + + return response; + } + + // Redirect `/t` to `/settings/teams`. + if (req.nextUrl.pathname === '/t') { + const redirectUrl = new URL('/settings/teams', req.url); + + return NextResponse.redirect(redirectUrl); + } + + // Redirect `/t/` to `/t//documents`. + if (TEAM_URL_ROOT_REGEX.test(req.nextUrl.pathname)) { + const redirectUrl = new URL(`${req.nextUrl.pathname}/documents`, req.url); + + const response = NextResponse.redirect(redirectUrl); + response.cookies.set('preferred-team-url', req.nextUrl.pathname.replace('/t/', '')); + + return response; + } + + // Set the preferred team url cookie if user accesses a team page. + if (req.nextUrl.pathname.startsWith('/t/')) { + const response = NextResponse.next(); + response.cookies.set('preferred-team-url', req.nextUrl.pathname.split('/')[2]); + + return response; + } + + if (req.nextUrl.pathname.startsWith('/signin')) { + const token = await getToken({ req }); + + if (token) { + const redirectUrl = new URL('/documents', req.url); + + return NextResponse.redirect(redirectUrl); + } + } + + // Clear preferred team url cookie if user accesses a non team page from a team page. + if (resetPreferredTeamUrl || req.nextUrl.pathname === '/documents') { + const response = NextResponse.next(); + response.cookies.set('preferred-team-url', ''); + + return response; + } + + if (req.nextUrl.pathname.startsWith('/embed')) { + const res = NextResponse.next(); + + const origin = req.headers.get('Origin') ?? '*'; + + // Allow third parties to iframe the document. + res.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.headers.set('Access-Control-Allow-Origin', origin); + res.headers.set('Content-Security-Policy', `frame-ancestors ${origin}`); + res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.headers.set('X-Content-Type-Options', 'nosniff'); + + return res; + } + + return NextResponse.next(); +} + +export default async function middlewareWrapper(req: NextRequest) { + const response = await middleware(req); + + // Can place anything that needs to be set on the response here. + + return response; +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - ingest (analytics) + * - site.webmanifest + */ + { + source: '/((?!api|_next/static|_next/image|ingest|favicon|site.webmanifest).*)', + missing: [ + { type: 'header', key: 'next-router-prefetch' }, + { type: 'header', key: 'purpose', value: 'prefetch' }, + ], + }, + ], +}; diff --git a/apps/remix/app/components/(dashboard)/layout/app-banner.tsx b/apps/remix/app/components/(dashboard)/layout/app-banner.tsx new file mode 100644 index 000000000..336aa3718 --- /dev/null +++ b/apps/remix/app/components/(dashboard)/layout/app-banner.tsx @@ -0,0 +1,28 @@ +import { type TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner'; + +export type AppBannerProps = { + banner: TSiteSettingsBannerSchema; +}; + +export const AppBanner = ({ banner }: AppBannerProps) => { + if (!banner.enabled) { + return null; + } + + return ( +
+
+
+ +
+
+
+ ); +}; + +// Banner +// Custom Text +// Custom Text with Custom Icon diff --git a/apps/remix/app/components/(dashboard)/layout/banner.tsx b/apps/remix/app/components/(dashboard)/layout/banner.tsx deleted file mode 100644 index 95a0de3dd..000000000 --- a/apps/remix/app/components/(dashboard)/layout/banner.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings'; -import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner'; - -export const Banner = async () => { - const banner = await getSiteSettings().then((settings) => - settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID), - ); - - return ( - <> - {banner && banner.enabled && ( -
-
-
- -
-
-
- )} - - ); -}; - -// Banner -// Custom Text -// Custom Text with Custom Icon diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx index 15233af69..9e328278c 100644 --- a/apps/remix/app/components/forms/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -13,6 +13,7 @@ import { z } from 'zod'; import communityCardsImage from '@documenso/assets/images/community-cards.png'; import { authClient } from '@documenso/auth/client'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; @@ -93,7 +94,7 @@ export const SignUpForm = ({ const { _ } = useLingui(); const { toast } = useToast(); - // const analytics = useAnalytics(); // Todo + const analytics = useAnalytics(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -134,11 +135,11 @@ export const SignUpForm = ({ duration: 5000, }); - // analytics.capture('App: User Sign Up', { - // email, - // timestamp: new Date().toISOString(), - // custom_campaign_params: { src: utmSrc }, - // }); + analytics.capture('App: User Sign Up', { + email, + timestamp: new Date().toISOString(), + custom_campaign_params: { src: utmSrc }, + }); } catch (err) { const error = AppError.parseError(err); diff --git a/apps/remix/app/components/general/teams/team-layout-billing-banner.tsx b/apps/remix/app/components/general/teams/team-layout-billing-banner.tsx new file mode 100644 index 000000000..becea0d22 --- /dev/null +++ b/apps/remix/app/components/general/teams/team-layout-billing-banner.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { AlertTriangle } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; +import type { TeamMemberRole } from '@documenso/prisma/client'; +import { type Subscription, SubscriptionStatus } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type TeamLayoutBillingBannerProps = { + subscription: Subscription; + teamId: number; + userRole: TeamMemberRole; +}; + +export const TeamLayoutBillingBanner = ({ + subscription, + teamId, + userRole, +}: TeamLayoutBillingBannerProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [isOpen, setIsOpen] = useState(false); + + const { mutateAsync: createBillingPortal, isPending } = + trpc.team.createBillingPortal.useMutation(); + + const handleCreatePortal = async () => { + try { + const sessionUrl = await createBillingPortal({ teamId }); + + window.open(sessionUrl, '_blank'); + + setIsOpen(false); + } catch (err) { + toast({ + title: _(msg`Something went wrong`), + description: _( + msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`, + ), + variant: 'destructive', + duration: 10000, + }); + } + }; + + if (subscription.status === SubscriptionStatus.ACTIVE) { + return null; + } + + return ( + <> +
+
+
+ + + {match(subscription.status) + .with(SubscriptionStatus.PAST_DUE, () => Payment overdue) + .with(SubscriptionStatus.INACTIVE, () => Teams restricted) + .exhaustive()} +
+ + +
+
+ + !isPending && setIsOpen(value)}> + + + Payment overdue + + + {match(subscription.status) + .with(SubscriptionStatus.PAST_DUE, () => ( + + + Your payment for teams is overdue. Please settle the payment to avoid any service + disruptions. + + + )) + .with(SubscriptionStatus.INACTIVE, () => ( + + + Due to an unpaid invoice, your team has been restricted. Please settle the payment + to restore full access to your team. + + + )) + .otherwise(() => null)} + + {canExecuteTeamAction('MANAGE_BILLING', userRole) && ( + + + + )} + + + + ); +}; diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index 546fe4f48..e4330366a 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -1,3 +1,5 @@ +import { Suspense } from 'react'; + import { Links, Meta, @@ -20,6 +22,7 @@ import { TooltipProvider } from '@documenso/ui/primitives/tooltip'; import type { Route } from './+types/root'; import stylesheet from './app.css?url'; import { GenericErrorLayout } from './components/general/generic-error-layout'; +import { PostHogPageview } from './providers/posthog'; import { langCookie } from './storage/lang-cookie.server'; import { themeSessionResolver } from './storage/theme-session.server'; @@ -41,6 +44,37 @@ export const links: Route.LinksFunction = () => [ { rel: 'stylesheet', href: stylesheet }, ]; +// Todo: Meta data. +// export function generateMetadata() { +// return { +// title: { +// template: '%s - Documenso', +// default: 'Documenso', +// }, +// description: +// 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.', +// keywords: +// 'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates', +// authors: { name: 'Documenso, Inc.' }, +// robots: 'index, follow', +// metadataBase: new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'), +// openGraph: { +// title: 'Documenso - The Open Source DocuSign Alternative', +// description: +// 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.', +// type: 'website', +// images: ['/opengraph-image.jpg'], +// }, +// twitter: { +// site: '@documenso', +// card: 'summary_large_image', +// images: ['/opengraph-image.jpg'], +// description: +// 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.', +// }, +// }; +// } + export async function loader({ request, context }: Route.LoaderArgs) { const { getTheme } = await themeSessionResolver(request); @@ -81,10 +115,15 @@ export function Layout({ children }: { children: React.ReactNode }) { + {/* */} + + + + {children} diff --git a/apps/remix/app/routes/_authenticated+/_layout.tsx b/apps/remix/app/routes/_authenticated+/_layout.tsx index 69984425b..22cef02ef 100644 --- a/apps/remix/app/routes/_authenticated+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/_layout.tsx @@ -1,15 +1,22 @@ import { Outlet, redirect } from 'react-router'; import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client'; +import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings'; +import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner'; +import { AppBanner } from '~/components/(dashboard)/layout/app-banner'; import { Header } from '~/components/(dashboard)/layout/header'; import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner'; import type { Route } from './+types/_layout'; -export const loader = ({ context }: Route.LoaderArgs) => { +export const loader = async ({ context }: Route.LoaderArgs) => { const { session } = context; + const banner = await getSiteSettings().then((settings) => + settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID), + ); + if (!session) { throw redirect('/signin'); } @@ -17,18 +24,18 @@ export const loader = ({ context }: Route.LoaderArgs) => { return { user: session.user, teams: session.teams, + banner, }; }; export default function Layout({ loaderData }: Route.ComponentProps) { - const { user, teams } = loaderData; + const { user, teams, banner } = loaderData; return ( {!user.emailVerified && } - {/* // Todo: Banner */} - {/* */} + {banner && }
diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx index 14b0c2eb7..5566213d2 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx @@ -1,7 +1,7 @@ import { Trans } from '@lingui/macro'; import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react'; import { Link, Outlet, redirect, useLocation } from 'react-router'; -import { getRequiredSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderSession } from 'server/utils/get-required-session-context'; import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; import { cn } from '@documenso/ui/lib/utils'; @@ -10,7 +10,7 @@ import { Button } from '@documenso/ui/primitives/button'; import type { Route } from './+types/_layout'; export function loader({ context }: Route.LoaderArgs) { - const { user } = getRequiredSessionContext(context); + const { user } = getRequiredLoaderSession(context); if (!user || !isAdmin(user)) { throw redirect('/documents'); diff --git a/apps/remix/app/routes/_authenticated+/documents+/$id._index.tsx b/apps/remix/app/routes/_authenticated+/documents+/$id._index.tsx index 9ab2abcc5..dc653b0c9 100644 --- a/apps/remix/app/routes/_authenticated+/documents+/$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/documents+/$id._index.tsx @@ -3,7 +3,7 @@ import { useLingui } from '@lingui/react'; import { DocumentStatus, TeamMemberRole } from '@prisma/client'; import { ChevronLeft, Clock9, Users2 } from 'lucide-react'; import { Link, redirect } from 'react-router'; -import { getRequiredSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderSession } from 'server/utils/get-required-session-context'; import { match } from 'ts-pattern'; import { useSession } from '@documenso/lib/client-only/providers/session'; @@ -34,7 +34,7 @@ import { DocumentPageViewRecipients } from '~/components/general/document/docume import type { Route } from './+types/$id._index'; export async function loader({ params, context }: Route.LoaderArgs) { - const { user, currentTeam: team } = getRequiredSessionContext(context); + const { user, currentTeam: team } = getRequiredLoaderSession(context); const { id } = params; diff --git a/apps/remix/app/routes/_authenticated+/documents+/$id.edit.tsx b/apps/remix/app/routes/_authenticated+/documents+/$id.edit.tsx index a500de06e..11af2eef7 100644 --- a/apps/remix/app/routes/_authenticated+/documents+/$id.edit.tsx +++ b/apps/remix/app/routes/_authenticated+/documents+/$id.edit.tsx @@ -2,7 +2,7 @@ import { Plural, Trans } from '@lingui/macro'; import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client'; import { ChevronLeft, Users2 } from 'lucide-react'; import { Link, redirect } from 'react-router'; -import { getRequiredSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderSession } from 'server/utils/get-required-session-context'; import { match } from 'ts-pattern'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; @@ -18,7 +18,7 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader'; import type { Route } from './+types/$id.edit'; export async function loader({ params, context }: Route.LoaderArgs) { - const { user, currentTeam: team } = getRequiredSessionContext(context); + const { user, currentTeam: team } = getRequiredLoaderSession(context); const { id } = params; diff --git a/apps/remix/app/routes/_authenticated+/documents+/$id.logs.tsx b/apps/remix/app/routes/_authenticated+/documents+/$id.logs.tsx index c36ea713c..f9c88a780 100644 --- a/apps/remix/app/routes/_authenticated+/documents+/$id.logs.tsx +++ b/apps/remix/app/routes/_authenticated+/documents+/$id.logs.tsx @@ -5,7 +5,7 @@ import type { Recipient } from '@prisma/client'; import { ChevronLeft } from 'lucide-react'; import { DateTime } from 'luxon'; import { Link, redirect } from 'react-router'; -import { getRequiredSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderSession } from 'server/utils/get-required-session-context'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; @@ -25,7 +25,7 @@ import type { Route } from './+types/$id.logs'; export async function loader({ params, context }: Route.LoaderArgs) { const { id } = params; - const { user, currentTeam: team } = getRequiredSessionContext(context); + const { user, currentTeam: team } = getRequiredLoaderSession(context); const documentId = Number(id); diff --git a/apps/remix/app/routes/_authenticated+/settings+/public-profile+/index.tsx b/apps/remix/app/routes/_authenticated+/settings+/public-profile+/index.tsx index b9443d6cb..9c33ff07c 100644 --- a/apps/remix/app/routes/_authenticated+/settings+/public-profile+/index.tsx +++ b/apps/remix/app/routes/_authenticated+/settings+/public-profile+/index.tsx @@ -4,7 +4,7 @@ import { Trans, msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import type { TemplateDirectLink } from '@prisma/client'; import { TemplateType } from '@prisma/client'; -import { getRequiredSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderSession } from 'server/utils/get-required-session-context'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile'; @@ -44,7 +44,7 @@ const teamProfileText = { }; export async function loader({ context }: Route.LoaderArgs) { - const { user } = getRequiredSessionContext(context); + const { user } = getRequiredLoaderSession(context); const { profile } = await getUserPublicProfile({ userId: user.id, diff --git a/apps/remix/app/routes/_authenticated+/settings+/tokens+/index.tsx b/apps/remix/app/routes/_authenticated+/settings+/tokens+/index.tsx index 1407c5286..c0b5133dc 100644 --- a/apps/remix/app/routes/_authenticated+/settings+/tokens+/index.tsx +++ b/apps/remix/app/routes/_authenticated+/settings+/tokens+/index.tsx @@ -1,7 +1,7 @@ import { Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { DateTime } from 'luxon'; -import { getRequiredSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderSession } from 'server/utils/get-required-session-context'; import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens'; import { Button } from '@documenso/ui/primitives/button'; @@ -12,7 +12,7 @@ import { ApiTokenForm } from '~/components/forms/token'; import type { Route } from './+types/index'; export async function loader({ context }: Route.LoaderArgs) { - const { user } = getRequiredSessionContext(context); + const { user } = getRequiredLoaderSession(context); // Todo: Use TRPC & use table instead const tokens = await getUserTokens({ userId: user.id }); diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx index 1eaf48878..16e2ca388 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx @@ -3,7 +3,7 @@ import { Trans, msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { ChevronLeft } from 'lucide-react'; import { Link, Outlet, isRouteErrorResponse, redirect, useNavigate } from 'react-router'; -import { getRequiredSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderSession } from 'server/utils/get-required-session-context'; import { match } from 'ts-pattern'; import { AppErrorCode } from '@documenso/lib/errors/app-error'; @@ -15,7 +15,7 @@ import { TeamProvider } from '~/providers/team'; import type { Route } from './+types/_layout'; export const loader = ({ context }: Route.LoaderArgs) => { - const { currentTeam } = getRequiredSessionContext(context); + const { currentTeam } = getRequiredLoaderSession(context); if (!currentTeam) { throw redirect('/documents'); diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_layout.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_layout.tsx index 786933067..a42c13d0e 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/_layout.tsx @@ -1,6 +1,6 @@ import { Trans } from '@lingui/macro'; import { Outlet } from 'react-router'; -import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderTeamSession } from 'server/utils/get-required-session-context'; import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; @@ -9,10 +9,9 @@ import { TeamSettingsMobileNav } from '~/components/general/teams/team-settings- import type { Route } from '../+types/_layout'; -export async function loader({ context }: Route.LoaderArgs) { - const { currentTeam: team } = getRequiredTeamSessionContext(context); +export function loader({ context }: Route.LoaderArgs) { + const { currentTeam: team } = getRequiredLoaderTeamSession(context); - // Todo: Test that 404 page shows up from error. if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) { throw new Response(null, { status: 401 }); // Unauthorized. } diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/billing.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/billing.tsx index f48e33b51..796e5d636 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/billing.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/billing.tsx @@ -1,7 +1,7 @@ import { Plural, Trans, msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { DateTime } from 'luxon'; -import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderTeamSession } from 'server/utils/get-required-session-context'; import type Stripe from 'stripe'; import { match } from 'ts-pattern'; @@ -16,7 +16,7 @@ import { TeamBillingPortalButton } from '~/components/general/teams/team-billing import type { Route } from './+types/billing'; export async function loader({ context }: Route.LoaderArgs) { - const { currentTeam: team } = getRequiredTeamSessionContext(context); + const { currentTeam: team } = getRequiredLoaderTeamSession(context); let teamSubscription: Stripe.Subscription | null = null; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/public-profile.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/public-profile.tsx index 3dfc1f0bc..14d1a2c80 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/public-profile.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/public-profile.tsx @@ -1,4 +1,4 @@ -import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderTeamSession } from 'server/utils/get-required-session-context'; import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile'; @@ -7,7 +7,7 @@ import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile import type { Route } from './+types/public-profile'; export async function loader({ context }: Route.LoaderArgs) { - const { user, currentTeam: team } = getRequiredTeamSessionContext(context); + const { user, currentTeam: team } = getRequiredLoaderTeamSession(context); const { profile } = await getTeamPublicProfile({ userId: user.id, diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/tokens.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/tokens.tsx index 1aed36c9e..1b6994a3f 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/tokens.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings+/tokens.tsx @@ -1,7 +1,7 @@ import { Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { DateTime } from 'luxon'; -import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderTeamSession } from 'server/utils/get-required-session-context'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens'; @@ -13,7 +13,7 @@ import { ApiTokenForm } from '~/components/forms/token'; import type { Route } from './+types/tokens'; export async function loader({ context }: Route.LoaderArgs) { - const { user, currentTeam: team } = getRequiredTeamSessionContext(context); + const { user, currentTeam: team } = getRequiredLoaderTeamSession(context); const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }).catch(() => null); diff --git a/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx b/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx index 06598ba4a..e663a4a2c 100644 --- a/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx @@ -2,7 +2,7 @@ import { Trans } from '@lingui/macro'; import { DocumentSigningOrder, SigningStatus } from '@prisma/client'; import { ChevronLeft, LucideEdit } from 'lucide-react'; import { Link, redirect } from 'react-router'; -import { getRequiredSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderSession } from 'server/utils/get-required-session-context'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; @@ -25,7 +25,7 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader'; import type { Route } from './+types/$id._index'; export async function loader({ params, context }: Route.LoaderArgs) { - const { user, currentTeam: team } = getRequiredSessionContext(context); + const { user, currentTeam: team } = getRequiredLoaderSession(context); const { id } = params; diff --git a/apps/remix/app/routes/_authenticated+/templates+/$id.edit.tsx b/apps/remix/app/routes/_authenticated+/templates+/$id.edit.tsx index f8f1a3d61..398add891 100644 --- a/apps/remix/app/routes/_authenticated+/templates+/$id.edit.tsx +++ b/apps/remix/app/routes/_authenticated+/templates+/$id.edit.tsx @@ -1,7 +1,7 @@ import { Trans } from '@lingui/macro'; import { ChevronLeft } from 'lucide-react'; import { Link, redirect } from 'react-router'; -import { getRequiredSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderSession } from 'server/utils/get-required-session-context'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; @@ -16,7 +16,7 @@ import { TemplateDirectLinkDialogWrapper } from '../../../components/dialogs/tem import type { Route } from './+types/$id.edit'; export async function loader({ context, params }: Route.LoaderArgs) { - const { user, currentTeam: team } = getRequiredSessionContext(context); + const { user, currentTeam: team } = getRequiredLoaderSession(context); const { id } = params; diff --git a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx index 80ed27c9d..828845e79 100644 --- a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx @@ -16,12 +16,6 @@ import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader'; import type { Route } from './+types/_index'; -export type TemplatesDirectPageProps = { - params: { - token: string; - }; -}; - export async function loader({ params, context }: Route.LoaderArgs) { const { token } = params; diff --git a/apps/remix/app/routes/_unauthenticated+/_layout.tsx b/apps/remix/app/routes/_unauthenticated+/_layout.tsx index 5d9121e17..d66c67b8c 100644 --- a/apps/remix/app/routes/_unauthenticated+/_layout.tsx +++ b/apps/remix/app/routes/_unauthenticated+/_layout.tsx @@ -2,12 +2,6 @@ import { Outlet } from 'react-router'; import backgroundPattern from '@documenso/assets/images/background-pattern.png'; -import type { Route } from './+types/_layout'; - -export const loader = async (args: Route.LoaderArgs) => { - // -}; - export default function Layout() { return (
diff --git a/apps/remix/app/routes/_unauthenticated+/signature-disclosure.tsx b/apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx similarity index 100% rename from apps/remix/app/routes/_unauthenticated+/signature-disclosure.tsx rename to apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx diff --git a/apps/remix/app/routes/_unauthenticated+/reset-password.$token.tsx b/apps/remix/app/routes/_unauthenticated+/reset-password.$token.tsx index 920c8b6ab..e837c6f34 100644 --- a/apps/remix/app/routes/_unauthenticated+/reset-password.$token.tsx +++ b/apps/remix/app/routes/_unauthenticated+/reset-password.$token.tsx @@ -17,7 +17,7 @@ export async function loader({ params }: Route.LoaderArgs) { const isValid = await getResetTokenValidity({ token }); if (!isValid) { - redirect('/reset-password'); + throw redirect('/reset-password'); } return { diff --git a/apps/remix/app/routes/_unauthenticated+/share.$slug.opengraph.tsx b/apps/remix/app/routes/_unauthenticated+/share.$slug.opengraph.tsx new file mode 100644 index 000000000..606d85fbc --- /dev/null +++ b/apps/remix/app/routes/_unauthenticated+/share.$slug.opengraph.tsx @@ -0,0 +1,152 @@ +import { ImageResponse } from 'next/og'; + +import { P, match } from 'ts-pattern'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; + +import type { ShareHandlerAPIResponse } from '../api+/share'; +import type { Route } from './+types/share.$slug.opengraph'; + +export const runtime = 'edge'; + +const CARD_OFFSET_TOP = 173; +const CARD_OFFSET_LEFT = 307; +const CARD_WIDTH = 590; +const CARD_HEIGHT = 337; + +const IMAGE_SIZE = { + width: 1200, + height: 630, +}; + +export const loader = async ({ params }: Route.LoaderArgs) => { + const { slug } = params; + + const baseUrl = NEXT_PUBLIC_WEBAPP_URL(); + + const [interSemiBold, interRegular, caveatRegular, shareFrameImage] = await Promise.all([ + fetch(new URL(`${baseUrl}/fonts/inter-semibold.ttf`, import.meta.url)).then(async (res) => + res.arrayBuffer(), + ), + fetch(new URL(`${baseUrl}/fonts/inter-regular.ttf`, import.meta.url)).then(async (res) => + res.arrayBuffer(), + ), + fetch(new URL(`${baseUrl}/fonts/caveat-regular.ttf`, import.meta.url)).then(async (res) => + res.arrayBuffer(), + ), + fetch(new URL(`${baseUrl}/static/og-share-frame2.png`, import.meta.url)).then(async (res) => + res.arrayBuffer(), + ), + ]); + + const recipientOrSender: ShareHandlerAPIResponse = await fetch( + new URL(`/api/share?slug=${slug}`, baseUrl), + ).then(async (res) => res.json()); + + if ('error' in recipientOrSender) { + return Response.json({ error: 'Not found' }, { status: 404 }); + } + + const isRecipient = 'Signature' in recipientOrSender; + + const signatureImage = match(recipientOrSender) + .with({ signatures: P.array(P._) }, (recipient) => { + return recipient.signatures?.[0]?.signatureImageAsBase64 || null; + }) + .otherwise((sender) => { + return sender.signature || null; + }); + + const signatureName = match(recipientOrSender) + .with({ signatures: P.array(P._) }, (recipient) => { + return recipient.name || recipient.email; + }) + .otherwise((sender) => { + return sender.name || sender.email; + }); + + return new ImageResponse( + ( +
+ {/* @ts-expect-error Lack of typing from ImageResponse */} + og-share-frame + + {signatureImage ? ( +
+ signature +
+ ) : ( +

+ {signatureName} +

+ )} + +
+

+ {isRecipient ? 'Document Signed!' : 'Document Sent!'} +

+
+
+ ), + { + ...IMAGE_SIZE, + fonts: [ + { + name: 'Caveat', + data: caveatRegular, + style: 'italic', + }, + { + name: 'Inter', + data: interRegular, + style: 'normal', + weight: 400, + }, + { + name: 'Inter', + data: interSemiBold, + style: 'normal', + weight: 600, + }, + ], + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + }, + }, + ); +}; diff --git a/apps/remix/app/routes/_unauthenticated+/share.$slug.tsx b/apps/remix/app/routes/_unauthenticated+/share.$slug.tsx new file mode 100644 index 000000000..8e86de76f --- /dev/null +++ b/apps/remix/app/routes/_unauthenticated+/share.$slug.tsx @@ -0,0 +1,39 @@ +import { redirect } from 'react-router'; + +import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app'; + +import type { Route } from './+types/share.$slug'; + +// Todo: Test meta. +export function meta({ params: { slug } }: Route.MetaArgs) { + return [ + { title: 'Documenso - Share' }, + { description: 'I just signed a document in style with Documenso!' }, + { + openGraph: { + title: 'Documenso - Join the open source signing revolution', + description: 'I just signed with Documenso!', + type: 'website', + images: [`/share/${slug}/opengraph`], + }, + }, + { + twitter: { + site: '@documenso', + card: 'summary_large_image', + images: [`/share/${slug}/opengraph`], + description: 'I just signed with Documenso!', + }, + }, + ]; +} + +export const loader = ({ request }: Route.LoaderArgs) => { + const userAgent = request.headers.get('User-Agent') ?? ''; + + if (/bot|facebookexternalhit|WhatsApp|google|bing|duckduckbot|MetaInspector/i.test(userAgent)) { + return null; + } + + return redirect(NEXT_PUBLIC_MARKETING_URL()); +}; diff --git a/apps/remix/app/routes/api+/branding.logo.team.$teamId.ts b/apps/remix/app/routes/api+/branding.logo.team.$teamId.ts new file mode 100644 index 000000000..d6bd0e738 --- /dev/null +++ b/apps/remix/app/routes/api+/branding.logo.team.$teamId.ts @@ -0,0 +1,73 @@ +import sharp from 'sharp'; + +import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { prisma } from '@documenso/prisma'; + +import type { Route } from './+types/branding.logo.team.$teamId'; + +export async function loader({ params }: Route.LoaderArgs) { + const teamId = Number(params.teamId); + + if (teamId === 0 || Number.isNaN(teamId)) { + return Response.json( + { + status: 'error', + message: 'Invalid team ID', + }, + { status: 400 }, + ); + } + + const settings = await prisma.teamGlobalSettings.findFirst({ + where: { + teamId, + }, + }); + + if (!settings || !settings.brandingEnabled) { + return Response.json( + { + status: 'error', + message: 'Not found', + }, + { status: 404 }, + ); + } + + if (!settings.brandingLogo) { + return Response.json( + { + status: 'error', + message: 'Not found', + }, + { status: 404 }, + ); + } + + const file = await getFile(JSON.parse(settings.brandingLogo)).catch(() => null); + + if (!file) { + return Response.json( + { + status: 'error', + message: 'Not found', + }, + { status: 404 }, + ); + } + + const img = await sharp(file) + .toFormat('png', { + quality: 80, + }) + .toBuffer(); + + return new Response(img, { + headers: { + 'Content-Type': 'image/png', + 'Content-Length': img.length.toString(), + // Stale while revalidate for 1 hours to 24 hours + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', + }, + }); +} diff --git a/apps/remix/app/routes/api+/health.ts b/apps/remix/app/routes/api+/health.ts new file mode 100644 index 000000000..9783157e9 --- /dev/null +++ b/apps/remix/app/routes/api+/health.ts @@ -0,0 +1,22 @@ +import { prisma } from '@documenso/prisma'; + +export async function loader() { + try { + await prisma.$queryRaw`SELECT 1`; + + return Response.json({ + status: 'ok', + message: 'All systems operational', + }); + } catch (err) { + console.error(err); + + return Response.json( + { + status: 'error', + message: err instanceof Error ? err.message : 'Unknown error', + }, + { status: 500 }, + ); + } +} diff --git a/apps/remix/app/routes/api+/share.ts b/apps/remix/app/routes/api+/share.ts new file mode 100644 index 000000000..fa768526e --- /dev/null +++ b/apps/remix/app/routes/api+/share.ts @@ -0,0 +1,27 @@ +import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/share/get-recipient-or-sender-by-share-link-slug'; + +import type { Route } from './+types/share'; + +export type ShareHandlerAPIResponse = + | Awaited> + | { error: string }; + +// Todo: Test +export async function loader({ request }: Route.LoaderArgs) { + try { + const url = new URL(request.url); + const slug = url.searchParams.get('slug'); + + if (typeof slug !== 'string') { + throw new Error('Invalid slug'); + } + + const data = await getRecipientOrSenderByShareLinkSlug({ + slug, + }); + + return Response.json(data); + } catch (error) { + return Response.json({ error: 'Not found' }, { status: 404 }); + } +} diff --git a/apps/remix/app/routes/api+/stripe.webhook.ts b/apps/remix/app/routes/api+/stripe.webhook.ts new file mode 100644 index 000000000..f3c78cd44 --- /dev/null +++ b/apps/remix/app/routes/api+/stripe.webhook.ts @@ -0,0 +1,11 @@ +import { stripeWebhookHandler } from '@documenso/ee/server-only/stripe/webhook/handler'; + +// Todo +// export const config = { +// api: { bodyParser: false }, +// }; +import type { Route } from './+types/webhook.trigger'; + +export async function loader({ request }: Route.LoaderArgs) { + return stripeWebhookHandler(request); +} diff --git a/apps/remix/app/routes/api+/webhook.trigger.ts b/apps/remix/app/routes/api+/webhook.trigger.ts new file mode 100644 index 000000000..ea95bd836 --- /dev/null +++ b/apps/remix/app/routes/api+/webhook.trigger.ts @@ -0,0 +1,17 @@ +import { handlerTriggerWebhooks } from '@documenso/lib/server-only/webhooks/trigger/handler'; + +import type { Route } from './+types/webhook.trigger'; + +// Todo +// export const config = { +// maxDuration: 300, +// api: { +// bodyParser: { +// sizeLimit: '50mb', +// }, +// }, +// }; + +export async function loader({ request }: Route.LoaderArgs) { + return handlerTriggerWebhooks(request); +} diff --git a/apps/remix/app/routes/embed+/direct.$url.tsx b/apps/remix/app/routes/embed+/direct.$url.tsx index f3cea512c..25863e779 100644 --- a/apps/remix/app/routes/embed+/direct.$url.tsx +++ b/apps/remix/app/routes/embed+/direct.$url.tsx @@ -1,5 +1,5 @@ import { data } from 'react-router'; -import { getRequiredSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderSession } from 'server/utils/get-required-session-context'; import { match } from 'ts-pattern'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; @@ -48,7 +48,7 @@ export async function loader({ params, context }: Route.LoaderArgs) { ); } - const { user } = getRequiredSessionContext(context); + const { user } = getRequiredLoaderSession(context); const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ documentAuth: template.authOptions, diff --git a/apps/remix/app/routes/embed+/sign.$url.tsx b/apps/remix/app/routes/embed+/sign.$url.tsx index c46507b90..aa2f412ef 100644 --- a/apps/remix/app/routes/embed+/sign.$url.tsx +++ b/apps/remix/app/routes/embed+/sign.$url.tsx @@ -1,5 +1,5 @@ import { data } from 'react-router'; -import { getRequiredSessionContext } from 'server/utils/get-required-session-context'; +import { getRequiredLoaderSession } from 'server/utils/get-required-session-context'; import { match } from 'ts-pattern'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; @@ -27,7 +27,7 @@ export async function loader({ params, context }: Route.LoaderArgs) { const token = params.url; - const { user } = getRequiredSessionContext(context); + const { user } = getRequiredLoaderSession(context); const [document, fields, recipient] = await Promise.all([ getDocumentAndSenderByToken({ diff --git a/apps/remix/example/cert.p12 b/apps/remix/example/cert.p12 new file mode 100644 index 000000000..532ee19ab Binary files /dev/null and b/apps/remix/example/cert.p12 differ diff --git a/apps/remix/package.json b/apps/remix/package.json index c086a99ff..b68c58181 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -30,6 +30,8 @@ "hono-react-router-adapter": "^0.6.2", "isbot": "^5.1.17", "jsonwebtoken": "^9.0.2", + "posthog-js": "^1.75.3", + "posthog-node": "^3.1.1", "react": "^18", "react-dom": "^18", "react-router": "^7.1.3", diff --git a/apps/remix/public/static/og-share-frame2.png b/apps/remix/public/static/og-share-frame2.png new file mode 100644 index 000000000..b9a65657d Binary files /dev/null and b/apps/remix/public/static/og-share-frame2.png differ diff --git a/apps/remix/server/index.ts b/apps/remix/server/index.ts index c759a4db2..0e3dab546 100644 --- a/apps/remix/server/index.ts +++ b/apps/remix/server/index.ts @@ -2,12 +2,15 @@ import { Hono } from 'hono'; import { PDFDocument } from 'pdf-lib'; +import { tsRestHonoApp } from '@documenso/api/hono'; import { auth } from '@documenso/auth/server'; +import { API_V2_BETA_URL } from '@documenso/lib/constants/app'; import { AppError } from '@documenso/lib/errors/app-error'; import { jobsClient } from '@documenso/lib/jobs/client'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { putFile } from '@documenso/lib/universal/upload/put-file'; import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions'; +import { openApiDocument } from '@documenso/trpc/server/open-api'; import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api'; import { reactRouterTrpcServer } from './trpc/hono-trpc-remix'; @@ -18,11 +21,14 @@ const app = new Hono(); app.route('/api/auth', auth); // API servers. Todo: Configure max durations, etc? +app.route('/api/v1', tsRestHonoApp); app.use('/api/jobs/*', jobsClient.getHonoApiHandler()); -app.use('/api/v1/*', reactRouterTrpcServer); // Todo: ts-rest -app.use('/api/v2/*', async (c) => openApiTrpcServerHandler(c)); app.use('/api/trpc/*', reactRouterTrpcServer); +// Unstable API server routes. Order matters for these two. +app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument)); +app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c)); + // Temp uploader. app .post('/api/file', async (c) => { diff --git a/apps/remix/server/trpc/hono-trpc-open-api.ts b/apps/remix/server/trpc/hono-trpc-open-api.ts index 1b12d7c13..16077df0f 100644 --- a/apps/remix/server/trpc/hono-trpc-open-api.ts +++ b/apps/remix/server/trpc/hono-trpc-open-api.ts @@ -1,6 +1,7 @@ import type { Context } from 'hono'; import { createOpenApiFetchHandler } from 'trpc-to-openapi'; +import { API_V2_BETA_URL } from '@documenso/lib/constants/app'; import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error'; import { appRouter } from '@documenso/trpc/server/router'; import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler'; @@ -9,7 +10,7 @@ import { createHonoTrpcContext } from './trpc-context'; export const openApiTrpcServerHandler = async (c: Context) => { return createOpenApiFetchHandler({ - endpoint: '/v2/api', + endpoint: API_V2_BETA_URL, router: appRouter, // Todo: Test this, since it's not using the createContext params. createContext: async () => createHonoTrpcContext({ c, requestSource: 'apiV2' }), diff --git a/apps/remix/server/trpc/hono-trpc-remix.ts b/apps/remix/server/trpc/hono-trpc-remix.ts index 24cae0458..658a30102 100644 --- a/apps/remix/server/trpc/hono-trpc-remix.ts +++ b/apps/remix/server/trpc/hono-trpc-remix.ts @@ -5,6 +5,7 @@ import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler' import { createHonoTrpcContext } from './trpc-context'; +// Todo // export const config = { // maxDuration: 120, // api: { diff --git a/apps/remix/server/utils/get-required-session-context.ts b/apps/remix/server/utils/get-required-session-context.ts index bf363d6ec..ad4345f8e 100644 --- a/apps/remix/server/utils/get-required-session-context.ts +++ b/apps/remix/server/utils/get-required-session-context.ts @@ -4,7 +4,7 @@ import { redirect } from 'react-router'; /** * Returns the session context or throws a redirect to signin if it is not present. */ -export const getRequiredSessionContext = (context: AppLoadContext) => { +export const getRequiredLoaderSession = (context: AppLoadContext) => { if (!context.session) { throw redirect('/signin'); // Todo: Maybe add a redirect cookie to come back? } @@ -15,7 +15,7 @@ export const getRequiredSessionContext = (context: AppLoadContext) => { /** * Returns the team session context or throws a redirect to signin if it is not present. */ -export const getRequiredTeamSessionContext = (context: AppLoadContext) => { +export const getRequiredLoaderTeamSession = (context: AppLoadContext) => { if (!context.session) { throw redirect('/signin'); // Todo: Maybe add a redirect cookie to come back? } diff --git a/package-lock.json b/package-lock.json index bf8759d6c..5db6ec70b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -126,6 +126,8 @@ "hono-react-router-adapter": "^0.6.2", "isbot": "^5.1.17", "jsonwebtoken": "^9.0.2", + "posthog-js": "^1.75.3", + "posthog-node": "^3.1.1", "react": "^18", "react-dom": "^18", "react-router": "^7.1.3", @@ -22490,6 +22492,12 @@ "set-function-name": "^2.0.1" } }, + "node_modules/itty-router": { + "version": "5.0.18", + "resolved": "https://registry.npmjs.org/itty-router/-/itty-router-5.0.18.tgz", + "integrity": "sha512-mK3ReOt4ARAGy0V0J7uHmArG2USN2x0zprZ+u+YgmeRjXTDbaowDy3kPcsmQY6tH+uHhDgpWit9Vqmv/4rTXwA==", + "license": "MIT" + }, "node_modules/jackspeak": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", @@ -37703,8 +37711,8 @@ "@documenso/lib": "*", "@documenso/prisma": "*", "@ts-rest/core": "^3.30.5", - "@ts-rest/next": "^3.30.5", "@ts-rest/open-api": "^3.33.0", + "@ts-rest/serverless": "^3.51.0", "@types/swagger-ui-react": "^4.18.3", "luxon": "^3.4.0", "superjson": "^1.13.1", @@ -37713,21 +37721,73 @@ "zod": "3.24.1" } }, - "packages/api/node_modules/@ts-rest/next": { - "version": "3.30.5", - "resolved": "https://registry.npmjs.org/@ts-rest/next/-/next-3.30.5.tgz", - "integrity": "sha512-NasfUN7SnwcjJNbxvvcemC4fOv4f4IF5I14wVqQODN0HWPokkrta6XLuv0eKQJYdB32AS7VINQhls8Sj1AIN0g==", + "packages/api/node_modules/@ts-rest/core": { + "version": "3.51.0", + "resolved": "https://registry.npmjs.org/@ts-rest/core/-/core-3.51.0.tgz", + "integrity": "sha512-v6lnWEcpZj1UgN9wb84XQ+EORP1QEtncFumoXMJjno5ZUV6vdjKze3MYcQN0C6vjBpIJPQEaI/gab2jr4/0KzQ==", + "license": "MIT", "peerDependencies": { - "@ts-rest/core": "3.30.5", - "next": "^12.0.0 || ^13.0.0", + "@types/node": "^18.18.7 || >=20.8.4", "zod": "^3.22.3" }, "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, "zod": { "optional": true } } }, + "packages/api/node_modules/@ts-rest/serverless": { + "version": "3.51.0", + "resolved": "https://registry.npmjs.org/@ts-rest/serverless/-/serverless-3.51.0.tgz", + "integrity": "sha512-BjwmLPgnYifdDjSpSvhZk+v1P+3CiM/jpxKNUgdw8RfgnDy/+aaOPmAcSkjhBCOIu6ASChuv/sNpiuWx3YyPUw==", + "license": "MIT", + "dependencies": { + "itty-router": "^5.0.9" + }, + "peerDependencies": { + "@azure/functions": "^4.0.0", + "@ts-rest/core": "~3.51.0", + "@types/aws-lambda": "^8.10.115", + "next": "^12.0.0 || ^13.0.0 || ^14.0.0", + "zod": "^3.22.3" + }, + "peerDependenciesMeta": { + "@azure/functions": { + "optional": true + }, + "@types/aws-lambda": { + "optional": true + }, + "next": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "packages/api/node_modules/@types/node": { + "version": "22.13.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.0.tgz", + "integrity": "sha512-ClIbNe36lawluuvq3+YYhnIN2CELi+6q8NpnM7PYp4hBn/TatfboPgVSm2rwKRfnV2M+Ty9GWDFI64KEe+kysA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "packages/api/node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "license": "MIT", + "optional": true, + "peer": true + }, "packages/app-tests": { "name": "@documenso/app-tests", "version": "0.0.0", diff --git a/packages/api/hono.ts b/packages/api/hono.ts new file mode 100644 index 000000000..bc4de971a --- /dev/null +++ b/packages/api/hono.ts @@ -0,0 +1,35 @@ +import { fetchRequestHandler } from '@ts-rest/serverless/fetch'; +import { Hono } from 'hono'; + +import { ApiContractV1 } from '@documenso/api/v1/contract'; +import { ApiContractV1Implementation } from '@documenso/api/v1/implementation'; +import { OpenAPIV1 } from '@documenso/api/v1/openapi'; +import { testCredentialsHandler } from '@documenso/lib/server-only/public-api/test-credentials'; +import { listDocumentsHandler } from '@documenso/lib/server-only/webhooks/zapier/list-documents'; +import { subscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/subscribe'; +import { unsubscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/unsubscribe'; + +// This is bad, ts-router will be created on each request. +// But don't really have a choice here. +export const tsRestHonoApp = new Hono(); + +tsRestHonoApp + .get('/openapi', (c) => c.redirect('https://openapi-v1.documenso.com')) + .get('/openapi.json', (c) => c.json(OpenAPIV1)) + .get('/me', async (c) => testCredentialsHandler(c.req.raw)); + +// Zapier. Todo: Check methods. Are these get/post/update requests? +// Todo: Is there really no validations? +tsRestHonoApp + .all('/zapier/list-documents', async (c) => listDocumentsHandler(c.req.raw)) + .all('/zapier/subscribe', async (c) => subscribeHandler(c.req.raw)) + .all('/zapier/unsubscribe', async (c) => unsubscribeHandler(c.req.raw)); + +tsRestHonoApp.mount('/', async (request) => { + return fetchRequestHandler({ + request, + contract: ApiContractV1, + router: ApiContractV1Implementation, + options: {}, + }); +}); diff --git a/packages/api/package.json b/packages/api/package.json index 0097d000b..cf5ca07bc 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -18,8 +18,8 @@ "@documenso/lib": "*", "@documenso/prisma": "*", "@ts-rest/core": "^3.30.5", - "@ts-rest/next": "^3.30.5", "@ts-rest/open-api": "^3.33.0", + "@ts-rest/serverless": "^3.51.0", "@types/swagger-ui-react": "^4.18.3", "luxon": "^3.4.0", "superjson": "^1.13.1", @@ -27,4 +27,4 @@ "ts-pattern": "^5.0.5", "zod": "3.24.1" } -} \ No newline at end of file +} diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 0f13150c7..ac2851946 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -1,4 +1,4 @@ -import { createNextRoute } from '@ts-rest/next'; +import { tsr } from '@ts-rest/serverless/fetch'; import { match } from 'ts-pattern'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; @@ -42,7 +42,6 @@ import { ZRadioFieldMeta, ZTextFieldMeta, } from '@documenso/lib/types/field-meta'; -import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { getFile } from '@documenso/lib/universal/upload/get-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { @@ -62,7 +61,7 @@ import { import { ApiContractV1 } from './contract'; import { authenticatedMiddleware } from './middleware/authenticated'; -export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { +export const ApiContractV1Implementation = tsr.router(ApiContractV1, { getDocuments: authenticatedMiddleware(async (args, user, team) => { const page = Number(args.query.page) || 1; const perPage = Number(args.query.perPage) || 10; @@ -849,7 +848,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { } }), - updateRecipient: authenticatedMiddleware(async (args, user, team) => { + updateRecipient: authenticatedMiddleware(async (args, user, team, { metadata }) => { const { id: documentId, recipientId } = args.params; const { name, email, role, authOptions, signingOrder } = args.body; @@ -887,7 +886,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { role, signingOrder, actionAuth: authOptions?.actionAuth, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata.requestMetadata, }).catch(() => null); if (!updatedRecipient) { @@ -909,7 +908,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { }; }), - deleteRecipient: authenticatedMiddleware(async (args, user, team) => { + deleteRecipient: authenticatedMiddleware(async (args, user, team, { metadata }) => { const { id: documentId, recipientId } = args.params; const document = await getDocumentById({ @@ -941,7 +940,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { recipientId: Number(recipientId), userId: user.id, teamId: team?.id, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata.requestMetadata, }).catch(() => null); if (!deletedRecipient) { @@ -963,7 +962,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { }; }), - createField: authenticatedMiddleware(async (args, user, team) => { + createField: authenticatedMiddleware(async (args, user, team, { metadata }) => { const { id: documentId } = args.params; const fields = Array.isArray(args.body) ? args.body : [args.body]; @@ -1100,7 +1099,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { fieldRecipientId: recipientId, fieldType: field.type, }, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata.requestMetadata, }), }); @@ -1134,7 +1133,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { } }), - updateField: authenticatedMiddleware(async (args, user, team) => { + updateField: authenticatedMiddleware(async (args, user, team, { metadata }) => { const { id: documentId, fieldId } = args.params; const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta } = args.body; @@ -1198,7 +1197,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { pageY, pageWidth, pageHeight, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata.requestMetadata, fieldMeta: fieldMeta ? ZFieldMetaSchema.parse(fieldMeta) : undefined, }); @@ -1225,7 +1224,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { }; }), - deleteField: authenticatedMiddleware(async (args, user, team) => { + deleteField: authenticatedMiddleware(async (args, user, team, { metadata }) => { const { id: documentId, fieldId } = args.params; const document = await getDocumentById({ @@ -1286,7 +1285,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { fieldId: Number(fieldId), userId: user.id, teamId: team?.id, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata.requestMetadata, }).catch(() => null); if (!deletedField) { diff --git a/packages/api/v1/middleware/authenticated.ts b/packages/api/v1/middleware/authenticated.ts index b2ad28d2f..a09e946df 100644 --- a/packages/api/v1/middleware/authenticated.ts +++ b/packages/api/v1/middleware/authenticated.ts @@ -1,14 +1,22 @@ -import type { NextApiRequest } from 'next'; +import type { TsRestRequest } from '@ts-rest/serverless'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; -import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { Team, User } from '@documenso/prisma/client'; +type B = { + // appRoute: any; + request: TsRestRequest; + responseHeaders: Headers; +}; + export const authenticatedMiddleware = < T extends { - req: NextApiRequest; + headers: { + authorization: string; + }; }, R extends { status: number; @@ -16,15 +24,15 @@ export const authenticatedMiddleware = < }, >( handler: ( - args: T, + args: T & { req: TsRestRequest }, user: User, team: Team | null | undefined, options: { metadata: ApiRequestMetadata }, ) => Promise, ) => { - return async (args: T) => { + return async (args: T, { request }: B) => { try { - const { authorization } = args.req.headers; + const { authorization } = args.headers; // Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx" const [token] = (authorization || '').split('Bearer ').filter((s) => s.length > 0); @@ -44,7 +52,7 @@ export const authenticatedMiddleware = < } const metadata: ApiRequestMetadata = { - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: extractRequestMetadata(request), // Todo: Test source: 'apiV1', auth: 'api', auditUser: { @@ -54,7 +62,15 @@ export const authenticatedMiddleware = < }, }; - return await handler(args, apiToken.user, apiToken.team, { metadata }); + return await handler( + { + ...args, + req: request, + }, + apiToken.user, + apiToken.team, + { metadata }, + ); } catch (err) { console.log({ err: err }); diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts index 440be6e34..3039b97ad 100644 --- a/packages/ee/server-only/stripe/webhook/handler.ts +++ b/packages/ee/server-only/stripe/webhook/handler.ts @@ -1,13 +1,9 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; - -import { buffer } from 'micro'; import { match } from 'ts-pattern'; import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; import type { Stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe'; import { createTeamFromPendingTeam } from '@documenso/lib/server-only/team/create-team'; -import { getFlag } from '@documenso/lib/universal/get-feature-flag'; import { env } from '@documenso/lib/utils/env'; import { prisma } from '@documenso/prisma'; @@ -19,37 +15,52 @@ type StripeWebhookResponse = { message: string; }; -export const stripeWebhookHandler = async ( - req: NextApiRequest, - res: NextApiResponse, -) => { +export const stripeWebhookHandler = async (req: Request) => { try { - const isBillingEnabled = await getFlag('app_billing'); + // Todo + // const isBillingEnabled = await getFlag('app_billing'); + const isBillingEnabled = true; + + const webhookSecret = env('NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET'); + + if (!webhookSecret) { + throw new Error('Missing Stripe webhook secret'); + } if (!isBillingEnabled) { - return res.status(500).json({ - success: false, - message: 'Billing is disabled', - }); + return Response.json( + { + success: false, + message: 'Billing is disabled', + } satisfies StripeWebhookResponse, + { status: 500 }, + ); } const signature = - typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : ''; + typeof req.headers.get('stripe-signature') === 'string' + ? req.headers.get('stripe-signature') + : ''; if (!signature) { - return res.status(400).json({ - success: false, - message: 'No signature found in request', - }); + return Response.json( + { + success: false, + message: 'No signature found in request', + } satisfies StripeWebhookResponse, + { status: 400 }, + ); } - const body = await buffer(req); + // Todo: I'm not sure about this. + const clonedReq = req.clone(); + const rawBody = await clonedReq.arrayBuffer(); + const body = Buffer.from(rawBody); - const event = stripe.webhooks.constructEvent( - body, - signature, - env('NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET'), // Todo: Test - ); + // It was this: + // const body = await buffer(req); + + const event = stripe.webhooks.constructEvent(body, signature, webhookSecret); await match(event.type) .with('checkout.session.completed', async () => { @@ -93,10 +104,10 @@ export const stripeWebhookHandler = async ( : session.subscription?.id; if (!subscriptionId) { - return res.status(500).json({ - success: false, - message: 'Invalid session', - }); + return Response.json( + { success: false, message: 'Invalid session' } satisfies StripeWebhookResponse, + { status: 500 }, + ); } const subscription = await stripe.subscriptions.retrieve(subscriptionId); @@ -105,26 +116,29 @@ export const stripeWebhookHandler = async ( if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) { await handleTeamSeatCheckout({ subscription }); - return res.status(200).json({ - success: true, - message: 'Webhook received', - }); + return Response.json( + { success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, + { status: 200 }, + ); } // Validate user ID. if (!userId || Number.isNaN(userId)) { - return res.status(500).json({ - success: false, - message: 'Invalid session or missing user ID', - }); + return Response.json( + { + success: false, + message: 'Invalid session or missing user ID', + } satisfies StripeWebhookResponse, + { status: 500 }, + ); } await onSubscriptionUpdated({ userId, subscription }); - return res.status(200).json({ - success: true, - message: 'Webhook received', - }); + return Response.json( + { success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, + { status: 200 }, + ); }) .with('customer.subscription.updated', async () => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions @@ -143,18 +157,21 @@ export const stripeWebhookHandler = async ( }); if (!team) { - return res.status(500).json({ - success: false, - message: 'No team associated with subscription found', - }); + return Response.json( + { + success: false, + message: 'No team associated with subscription found', + } satisfies StripeWebhookResponse, + { status: 500 }, + ); } await onSubscriptionUpdated({ teamId: team.id, subscription }); - return res.status(200).json({ - success: true, - message: 'Webhook received', - }); + return Response.json( + { success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, + { status: 200 }, + ); } const result = await prisma.user.findFirst({ @@ -167,28 +184,37 @@ export const stripeWebhookHandler = async ( }); if (!result?.id) { - return res.status(500).json({ - success: false, - message: 'User not found', - }); + return Response.json( + { + success: false, + message: 'User not found', + } satisfies StripeWebhookResponse, + { status: 500 }, + ); } await onSubscriptionUpdated({ userId: result.id, subscription }); - return res.status(200).json({ - success: true, - message: 'Webhook received', - }); + return Response.json( + { + success: true, + message: 'Webhook received', + } satisfies StripeWebhookResponse, + { status: 200 }, + ); }) .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', - }); + return Response.json( + { + success: true, + message: 'Webhook received', + } satisfies StripeWebhookResponse, + { status: 200 }, + ); } const customerId = @@ -200,19 +226,25 @@ export const stripeWebhookHandler = async ( : invoice.subscription?.id; if (!customerId || !subscriptionId) { - return res.status(500).json({ - success: false, - message: 'Invalid invoice', - }); + return Response.json( + { + success: false, + message: 'Invalid invoice', + } satisfies StripeWebhookResponse, + { status: 500 }, + ); } const subscription = await stripe.subscriptions.retrieve(subscriptionId); if (subscription.status === 'incomplete_expired') { - return res.status(200).json({ - success: true, - message: 'Webhook received', - }); + return Response.json( + { + success: true, + message: 'Webhook received', + } satisfies StripeWebhookResponse, + { status: 200 }, + ); } if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) { @@ -223,18 +255,24 @@ export const stripeWebhookHandler = async ( }); if (!team) { - return res.status(500).json({ - success: false, - message: 'No team associated with subscription found', - }); + return Response.json( + { + success: false, + message: 'No team associated with subscription found', + } satisfies StripeWebhookResponse, + { status: 500 }, + ); } await onSubscriptionUpdated({ teamId: team.id, subscription }); - return res.status(200).json({ - success: true, - message: 'Webhook received', - }); + return Response.json( + { + success: true, + message: 'Webhook received', + } satisfies StripeWebhookResponse, + { status: 200 }, + ); } const result = await prisma.user.findFirst({ @@ -247,18 +285,24 @@ export const stripeWebhookHandler = async ( }); if (!result?.id) { - return res.status(500).json({ - success: false, - message: 'User not found', - }); + return Response.json( + { + success: false, + message: 'User not found', + } satisfies StripeWebhookResponse, + { status: 500 }, + ); } await onSubscriptionUpdated({ userId: result.id, subscription }); - return res.status(200).json({ - success: true, - message: 'Webhook received', - }); + return Response.json( + { + success: true, + message: 'Webhook received', + } satisfies StripeWebhookResponse, + { status: 200 }, + ); }) .with('invoice.payment_failed', async () => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions @@ -273,19 +317,25 @@ export const stripeWebhookHandler = async ( : invoice.subscription?.id; if (!customerId || !subscriptionId) { - return res.status(500).json({ - success: false, - message: 'Invalid invoice', - }); + return Response.json( + { + success: false, + message: 'Invalid invoice', + } satisfies StripeWebhookResponse, + { status: 500 }, + ); } const subscription = await stripe.subscriptions.retrieve(subscriptionId); if (subscription.status === 'incomplete_expired') { - return res.status(200).json({ - success: true, - message: 'Webhook received', - }); + return Response.json( + { + success: true, + message: 'Webhook received', + } satisfies StripeWebhookResponse, + { status: 200 }, + ); } if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) { @@ -296,18 +346,24 @@ export const stripeWebhookHandler = async ( }); if (!team) { - return res.status(500).json({ - success: false, - message: 'No team associated with subscription found', - }); + return Response.json( + { + success: false, + message: 'No team associated with subscription found', + } satisfies StripeWebhookResponse, + { status: 500 }, + ); } await onSubscriptionUpdated({ teamId: team.id, subscription }); - return res.status(200).json({ - success: true, - message: 'Webhook received', - }); + return Response.json( + { + success: true, + message: 'Webhook received', + } satisfies StripeWebhookResponse, + { status: 200 }, + ); } const result = await prisma.user.findFirst({ @@ -320,18 +376,24 @@ export const stripeWebhookHandler = async ( }); if (!result?.id) { - return res.status(500).json({ - success: false, - message: 'User not found', - }); + return Response.json( + { + success: false, + message: 'User not found', + } satisfies StripeWebhookResponse, + { status: 500 }, + ); } await onSubscriptionUpdated({ userId: result.id, subscription }); - return res.status(200).json({ - success: true, - message: 'Webhook received', - }); + return Response.json( + { + success: true, + message: 'Webhook received', + } satisfies StripeWebhookResponse, + { status: 200 }, + ); }) .with('customer.subscription.deleted', async () => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions @@ -339,24 +401,33 @@ export const stripeWebhookHandler = async ( await onSubscriptionDeleted({ subscription }); - return res.status(200).json({ - success: true, - message: 'Webhook received', - }); + return Response.json( + { + success: true, + message: 'Webhook received', + } satisfies StripeWebhookResponse, + { status: 200 }, + ); }) .otherwise(() => { - return res.status(200).json({ - success: true, - message: 'Webhook received', - }); + return Response.json( + { + success: true, + message: 'Webhook received', + } satisfies StripeWebhookResponse, + { status: 200 }, + ); }); } catch (err) { console.error(err); - res.status(500).json({ - success: false, - message: 'Unknown error', - }); + return Response.json( + { + success: false, + message: 'Unknown error', + } satisfies StripeWebhookResponse, + { status: 500 }, + ); } }; diff --git a/packages/lib/client-only/hooks/use-analytics.ts b/packages/lib/client-only/hooks/use-analytics.ts index e5939dddf..e6516a075 100644 --- a/packages/lib/client-only/hooks/use-analytics.ts +++ b/packages/lib/client-only/hooks/use-analytics.ts @@ -1,10 +1,6 @@ import { posthog } from 'posthog-js'; -import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; -import { - FEATURE_FLAG_GLOBAL_SESSION_RECORDING, - extractPostHogConfig, -} from '@documenso/lib/constants/feature-flags'; +import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags'; export function useAnalytics() { // const featureFlags = useFeatureFlags(); diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts index 2077df7ef..d137aea33 100644 --- a/packages/lib/constants/app.ts +++ b/packages/lib/constants/app.ts @@ -5,8 +5,13 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000'; -export const NEXT_PUBLIC_MARKETING_URL = () => env('NEXT_PUBLIC_MARKETING_URL'); + +export const NEXT_PUBLIC_MARKETING_URL = () => + env('NEXT_PUBLIC_MARKETING_URL') ?? 'http://localhost:3001'; + export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = env('NEXT_PRIVATE_INTERNAL_WEBAPP_URL') ?? NEXT_PUBLIC_WEBAPP_URL(); export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true'; + +export const API_V2_BETA_URL = '/api/v2-beta'; diff --git a/packages/lib/server-only/public-api/test-credentials.ts b/packages/lib/server-only/public-api/test-credentials.ts index 2e20d79c4..76b5a84a1 100644 --- a/packages/lib/server-only/public-api/test-credentials.ts +++ b/packages/lib/server-only/public-api/test-credentials.ts @@ -1,19 +1,24 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; - import { validateApiToken } from '@documenso/lib/server-only/webhooks/zapier/validateApiToken'; -export const testCredentialsHandler = async (req: NextApiRequest, res: NextApiResponse) => { +export const testCredentialsHandler = async (req: Request) => { try { - const { authorization } = req.headers; + const authorization = req.headers.get('authorization'); + + if (!authorization) { + throw new Error('Missing authorization header'); + } const result = await validateApiToken({ authorization }); - return res.status(200).json({ + return Response.json({ name: result.team?.name ?? result.user.name, }); } catch (err) { - return res.status(500).json({ - message: 'Internal Server Error', - }); + return Response.json( + { + message: 'Internal Server Error', + }, + { status: 500 }, + ); } }; diff --git a/packages/lib/server-only/webhooks/trigger/handler.ts b/packages/lib/server-only/webhooks/trigger/handler.ts index 4e705efea..968f7ce5b 100644 --- a/packages/lib/server-only/webhooks/trigger/handler.ts +++ b/packages/lib/server-only/webhooks/trigger/handler.ts @@ -1,5 +1,3 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; - import { verify } from '../../crypto/verify'; import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger'; import { executeWebhook } from './execute-webhook'; @@ -15,29 +13,26 @@ export type HandlerTriggerWebhooksResponse = error: string; }; -export const handlerTriggerWebhooks = async ( - req: NextApiRequest, - res: NextApiResponse, -) => { - const signature = req.headers['x-webhook-signature']; +export const handlerTriggerWebhooks = async (req: Request) => { + const signature = req.headers.get('x-webhook-signature'); if (typeof signature !== 'string') { console.log('Missing signature'); - return res.status(400).json({ success: false, error: 'Missing signature' }); + return Response.json({ success: false, error: 'Missing signature' }, { status: 400 }); } const valid = verify(req.body, signature); if (!valid) { console.log('Invalid signature'); - return res.status(400).json({ success: false, error: 'Invalid signature' }); + return Response.json({ success: false, error: 'Invalid signature' }, { status: 400 }); } const result = ZTriggerWebhookBodySchema.safeParse(req.body); if (!result.success) { console.log('Invalid request body'); - return res.status(400).json({ success: false, error: 'Invalid request body' }); + return Response.json({ success: false, error: 'Invalid request body' }, { status: 400 }); } const { event, data, userId, teamId } = result.data; @@ -54,5 +49,8 @@ export const handlerTriggerWebhooks = async ( ), ); - return res.status(200).json({ success: true, message: 'Webhooks executed successfully' }); + return Response.json( + { success: true, message: 'Webhooks executed successfully' }, + { status: 200 }, + ); }; diff --git a/packages/lib/server-only/webhooks/zapier/list-documents.ts b/packages/lib/server-only/webhooks/zapier/list-documents.ts index 97f8608aa..f0c4380da 100644 --- a/packages/lib/server-only/webhooks/zapier/list-documents.ts +++ b/packages/lib/server-only/webhooks/zapier/list-documents.ts @@ -1,5 +1,3 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; - import type { Webhook } from '@prisma/client'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; @@ -9,9 +7,14 @@ import { getWebhooksByTeamId } from '../get-webhooks-by-team-id'; import { getWebhooksByUserId } from '../get-webhooks-by-user-id'; import { validateApiToken } from './validateApiToken'; -export const listDocumentsHandler = async (req: NextApiRequest, res: NextApiResponse) => { +export const listDocumentsHandler = async (req: Request) => { try { - const { authorization } = req.headers; + const authorization = req.headers.get('authorization'); + + if (!authorization) { + return new Response('Unauthorized', { status: 401 }); + } + const { user, userId, teamId } = await validateApiToken({ authorization }); let allWebhooks: Webhook[] = []; @@ -56,13 +59,16 @@ export const listDocumentsHandler = async (req: NextApiRequest, res: NextApiResp }, }; - return res.status(200).json([testWebhook]); + return Response.json([testWebhook]); } - return res.status(200).json([]); + return Response.json([]); } catch (err) { - return res.status(500).json({ - message: 'Internal Server Error', - }); + return Response.json( + { + message: 'Internal Server Error', + }, + { status: 500 }, + ); } }; diff --git a/packages/lib/server-only/webhooks/zapier/subscribe.ts b/packages/lib/server-only/webhooks/zapier/subscribe.ts index 90c68e063..6629b5f8c 100644 --- a/packages/lib/server-only/webhooks/zapier/subscribe.ts +++ b/packages/lib/server-only/webhooks/zapier/subscribe.ts @@ -1,14 +1,16 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; - import { prisma } from '@documenso/prisma'; import { validateApiToken } from './validateApiToken'; -export const subscribeHandler = async (req: NextApiRequest, res: NextApiResponse) => { +export const subscribeHandler = async (req: Request) => { try { - const { authorization } = req.headers; + const authorization = req.headers.get('authorization'); - const { webhookUrl, eventTrigger } = req.body; + if (!authorization) { + return new Response('Unauthorized', { status: 401 }); + } + + const { webhookUrl, eventTrigger } = await req.json(); const result = await validateApiToken({ authorization }); @@ -23,10 +25,13 @@ export const subscribeHandler = async (req: NextApiRequest, res: NextApiResponse }, }); - return res.status(200).json(createdWebhook); + return Response.json(createdWebhook); } catch (err) { - return res.status(500).json({ - message: 'Internal Server Error', - }); + return Response.json( + { + message: 'Internal Server Error', + }, + { status: 500 }, + ); } }; diff --git a/packages/lib/server-only/webhooks/zapier/unsubscribe.ts b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts index 07fa75e11..6e26a2c15 100644 --- a/packages/lib/server-only/webhooks/zapier/unsubscribe.ts +++ b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts @@ -1,14 +1,16 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; - import { prisma } from '@documenso/prisma'; import { validateApiToken } from './validateApiToken'; -export const unsubscribeHandler = async (req: NextApiRequest, res: NextApiResponse) => { +export const unsubscribeHandler = async (req: Request) => { try { - const { authorization } = req.headers; + const authorization = req.headers.get('authorization'); - const { webhookId } = req.body; + if (!authorization) { + return new Response('Unauthorized', { status: 401 }); + } + + const { webhookId } = await req.json(); const result = await validateApiToken({ authorization }); @@ -20,10 +22,13 @@ export const unsubscribeHandler = async (req: NextApiRequest, res: NextApiRespon }, }); - return res.status(200).json(deletedWebhook); + return Response.json(deletedWebhook); } catch (err) { - return res.status(500).json({ - message: 'Internal Server Error', - }); + return Response.json( + { + message: 'Internal Server Error', + }, + { status: 500 }, + ); } }; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 1e016590a..8221cff01 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -135,15 +135,6 @@ export const documentRouter = router({ * @private */ findDocumentsInternal: authenticatedProcedure - .meta({ - openapi: { - method: 'GET', - path: '/document', - summary: 'Find documents', - description: 'Find documents based on a search criteria', - tags: ['Document'], - }, - }) .input(ZFindDocumentsInternalRequestSchema) .output(ZFindDocumentsInternalResponseSchema) .query(async ({ input, ctx }) => {