Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: Support a TRPC client in App Router Server Components #34

Merged
merged 2 commits into from
Oct 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/web/app/_trpc/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { AppRouter } from "@calcom/trpc/server/routers/_app";

import { createTRPCReact } from "@trpc/react-query";

export const trpc = createTRPCReact<AppRouter>({});
3 changes: 3 additions & 0 deletions apps/web/app/_trpc/serverClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { appRouter } from "@calcom/trpc/server/routers/_app";

export const serverClient = appRouter.createCaller({});
124 changes: 124 additions & 0 deletions apps/web/app/_trpc/trpc-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { trpc } from "app/_trpc/client";
import { useState } from "react";
import superjson from "superjson";

import { httpBatchLink } from "@calcom/trpc/client/links/httpBatchLink";
import { httpLink } from "@calcom/trpc/client/links/httpLink";
import { loggerLink } from "@calcom/trpc/client/links/loggerLink";
import { splitLink } from "@calcom/trpc/client/links/splitLink";

const ENDPOINTS = [
"admin",
"apiKeys",
"appRoutingForms",
"apps",
"auth",
"availability",
"appBasecamp3",
"bookings",
"deploymentSetup",
"eventTypes",
"features",
"insights",
"payments",
"public",
"saml",
"slots",
"teams",
"organizations",
"users",
"viewer",
"webhook",
"workflows",
"appsRouter",
"googleWorkspace",
] as const;
export type Endpoint = (typeof ENDPOINTS)[number];

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolveEndpoint = (links: any) => {
// TODO: Update our trpc routes so they are more clear.
// This function parses paths like the following and maps them
// to the correct API endpoints.
// - viewer.me - 2 segment paths like this are for logged in requests
// - viewer.public.i18n - 3 segments paths can be public or authed
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (ctx: any) => {
const parts = ctx.op.path.split(".");
let endpoint;
let path = "";
if (parts.length == 2) {
endpoint = parts[0] as keyof typeof links;
path = parts[1];
} else {
endpoint = parts[1] as keyof typeof links;
path = parts.splice(2, parts.length - 2).join(".");
}
return links[endpoint]({ ...ctx, op: { ...ctx.op, path } });
};
};

export const TrpcProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: { queries: { staleTime: 5000 } },
})
);
const url =
typeof window !== "undefined"
? "/api/trpc"
: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}/api/trpc`
: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/trpc`;

const [trpcClient] = useState(() =>
trpc.createClient({
links: [
// adds pretty logs to your console in development and logs errors in production
loggerLink({
enabled: (opts) =>
!!process.env.NEXT_PUBLIC_DEBUG || (opts.direction === "down" && opts.result instanceof Error),
}),
splitLink({
// check for context property `skipBatch`
condition: (op) => !!op.context.skipBatch,
// when condition is true, use normal request
true: (runtime) => {
const links = Object.fromEntries(
ENDPOINTS.map((endpoint) => [
endpoint,
httpLink({
url: `${url}/${endpoint}`,
})(runtime),
])
);
return resolveEndpoint(links);
},
// when condition is false, use batch request
false: (runtime) => {
const links = Object.fromEntries(
ENDPOINTS.map((endpoint) => [
endpoint,
httpBatchLink({
url: `${url}/${endpoint}`,
})(runtime),
])
);
return resolveEndpoint(links);
},
}),
],
transformer: superjson,
})
);

return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
};
3 changes: 1 addition & 2 deletions apps/web/components/PageWrapperAppDir.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import type { ReactNode } from "react";

import "@calcom/embed-core/src/embed-iframe";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { trpc } from "@calcom/trpc/react";

import type { AppProps } from "@lib/app-providers-app-dir";
import AppProviders from "@lib/app-providers-app-dir";
Expand Down Expand Up @@ -85,4 +84,4 @@ function PageWrapper(props: PageWrapperProps) {
);
}

export default trpc.withTRPC(PageWrapper);
export default PageWrapper;
43 changes: 23 additions & 20 deletions apps/web/lib/app-providers-app-dir.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { TrpcProvider } from "app/_trpc/trpc-provider";
import { dir } from "i18next";
import type { Session } from "next-auth";
import { SessionProvider, useSession } from "next-auth/react";
Expand Down Expand Up @@ -255,26 +256,28 @@ const AppProviders = (props: PageWrapperProps) => {
const isBookingPage = useIsBookingPage();

const RemainingProviders = (
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
<SessionProvider>
<CustomI18nextProvider i18n={props.i18n}>
<TooltipProvider>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<CalcomThemeProvider
themeBasis={props.themeBasis}
nonce={props.nonce}
isThemeSupported={props.isThemeSupported}
isBookingPage={props.isBookingPage || isBookingPage}>
<FeatureFlagsProvider>
<OrgBrandProvider>
<MetaProvider>{props.children}</MetaProvider>
</OrgBrandProvider>
</FeatureFlagsProvider>
</CalcomThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
</SessionProvider>
</EventCollectionProvider>
<TrpcProvider>
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
<SessionProvider>
<CustomI18nextProvider i18n={props.i18n}>
<TooltipProvider>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<CalcomThemeProvider
themeBasis={props.themeBasis}
nonce={props.nonce}
isThemeSupported={props.isThemeSupported}
isBookingPage={props.isBookingPage || isBookingPage}>
<FeatureFlagsProvider>
<OrgBrandProvider>
<MetaProvider>{props.children}</MetaProvider>
</OrgBrandProvider>
</FeatureFlagsProvider>
</CalcomThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
</SessionProvider>
</EventCollectionProvider>
</TrpcProvider>
);

if (isBookingPage) {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/pages/api/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
res.setHeader("Content-Type", "text/html");
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
res.write(
renderEmail("MonthlyDigestEmail", {
await renderEmail("MonthlyDigestEmail", {
language: t,
Created: 12,
Completed: 13,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ export default class ResponseEmail extends BaseEmail {
this.toAddresses = toAddresses;
}

protected getNodeMailerPayload(): Record<string, unknown> {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const toAddresses = this.toAddresses;
const subject = `${this.form.name} has a new response`;
return {
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject,
html: renderEmail("ResponseEmail", {
html: await renderEmail("ResponseEmail", {
form: this.form,
orderedResponses: this.orderedResponses,
subject,
Expand Down
2 changes: 1 addition & 1 deletion packages/emails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
```ts
import { renderEmail } from "@calcom/emails";

renderEmail("TeamInviteEmail", */{
await renderEmail("TeamInviteEmail", */{
language: t,
from: "[email protected]",
to: "[email protected]",
Expand Down
5 changes: 2 additions & 3 deletions packages/emails/src/renderEmail.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import * as ReactDOMServer from "react-dom/server";

import * as templates from "./templates";

function renderEmail<K extends keyof typeof templates>(
async function renderEmail<K extends keyof typeof templates>(
template: K,
props: React.ComponentProps<(typeof templates)[K]>
) {
const Component = templates[template];
const ReactDOMServer = (await import("react-dom/server")).default;
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { Trans, type TFunction } from "next-i18next";

import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
Expand Down
2 changes: 2 additions & 0 deletions packages/emails/src/templates/BrokenIntegrationEmail.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import type { TFunction } from "next-i18next";
import { Trans } from "react-i18next";

Expand Down
2 changes: 2 additions & 0 deletions packages/emails/src/templates/SlugReplacementEmail.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import type { TFunction } from "next-i18next";
import { Trans } from "next-i18next";

Expand Down
6 changes: 3 additions & 3 deletions packages/emails/templates/_base-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default class BaseEmail {
return dayjs(time).tz(this.getTimezone()).locale(this.getLocale()).format(format);
}

protected getNodeMailerPayload(): Record<string, unknown> {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {};
}
public async sendEmail() {
Expand All @@ -38,14 +38,14 @@ export default class BaseEmail {
if (process.env.INTEGRATION_TEST_MODE === "true") {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-expect-error
setTestEmail(this.getNodeMailerPayload());
setTestEmail(await this.getNodeMailerPayload());
console.log(
"Skipped Sending Email as process.env.NEXT_PUBLIC_UNIT_TESTS is set. Emails are available in globalThis.testEmails"
);
return new Promise((r) => r("Skipped sendEmail for Unit Tests"));
}

const payload = this.getNodeMailerPayload();
const payload = await this.getNodeMailerPayload();
const parseSubject = z.string().safeParse(payload?.subject);
const payloadWithUnEscapedSubject = {
headers: {
Expand Down
4 changes: 2 additions & 2 deletions packages/emails/templates/account-verify-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ export default class AccountVerifyEmail extends BaseEmail {
this.verifyAccountInput = passwordEvent;
}

protected getNodeMailerPayload(): Record<string, unknown> {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: `${this.verifyAccountInput.user.name} <${this.verifyAccountInput.user.email}>`,
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
subject: this.verifyAccountInput.language("verify_email_subject", {
appName: APP_NAME,
}),
html: renderEmail("VerifyAccountEmail", this.verifyAccountInput),
html: await renderEmail("VerifyAccountEmail", this.verifyAccountInput),
text: this.getTextBody(),
};
}
Expand Down
4 changes: 2 additions & 2 deletions packages/emails/templates/admin-organization-notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ export default class AdminOrganizationNotification extends BaseEmail {
this.input = input;
}

protected getNodeMailerPayload(): Record<string, unknown> {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
to: this.input.instanceAdmins.map((admin) => admin.email).join(","),
subject: `${this.input.t("admin_org_notification_email_subject")}`,
html: renderEmail("AdminOrganizationNotificationEmail", {
html: await renderEmail("AdminOrganizationNotificationEmail", {
orgSlug: this.input.orgSlug,
webappIPAddress: this.input.webappIPAddress,
language: this.input.t,
Expand Down
4 changes: 2 additions & 2 deletions packages/emails/templates/attendee-awaiting-payment-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";

export default class AttendeeAwaitingPaymentEmail extends AttendeeScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
Expand All @@ -11,7 +11,7 @@ export default class AttendeeAwaitingPaymentEmail extends AttendeeScheduledEmail
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: renderEmail("AttendeeAwaitingPaymentEmail", {
html: await renderEmail("AttendeeAwaitingPaymentEmail", {
calEvent: this.calEvent,
attendee: this.attendee,
}),
Expand Down
4 changes: 2 additions & 2 deletions packages/emails/templates/attendee-cancelled-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";

export default class AttendeeCancelledEmail extends AttendeeScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
Expand All @@ -11,7 +11,7 @@ export default class AttendeeCancelledEmail extends AttendeeScheduledEmail {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: renderEmail("AttendeeCancelledEmail", {
html: await renderEmail("AttendeeCancelledEmail", {
calEvent: this.calEvent,
attendee: this.attendee,
}),
Expand Down
4 changes: 2 additions & 2 deletions packages/emails/templates/attendee-cancelled-seat-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";

export default class AttendeeCancelledSeatEmail extends AttendeeScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
Expand All @@ -11,7 +11,7 @@ export default class AttendeeCancelledSeatEmail extends AttendeeScheduledEmail {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: renderEmail("AttendeeCancelledSeatEmail", {
html: await renderEmail("AttendeeCancelledSeatEmail", {
calEvent: this.calEvent,
attendee: this.attendee,
}),
Expand Down
Loading
Loading