diff --git a/.changeset/sixty-ligers-fold.md b/.changeset/sixty-ligers-fold.md new file mode 100644 index 0000000..c21eda4 --- /dev/null +++ b/.changeset/sixty-ligers-fold.md @@ -0,0 +1,7 @@ +--- +"saleor-app-search": patch +--- + +Refactor: Introduced tRPC and re-implemented /api/configuration endpoint to the tRPC controller. + +_This does not affect the end-user functionality_ diff --git a/apps/search/package.json b/apps/search/package.json index 5cb42d5..8869e7a 100644 --- a/apps/search/package.json +++ b/apps/search/package.json @@ -20,6 +20,10 @@ "@saleor/react-hook-form-macaw": "workspace:*", "@sentry/nextjs": "7.55.2", "@tanstack/react-query": "4.29.19", + "@trpc/client": "10.34.0", + "@trpc/next": "10.34.0", + "@trpc/react-query": "10.34.0", + "@trpc/server": "10.34.0", "@types/debug": "^4.1.7", "@urql/exchange-auth": "^2.1.4", "algoliasearch": "4.19.1", diff --git a/apps/search/src/components/AlgoliaConfigurationForm.tsx b/apps/search/src/components/AlgoliaConfigurationForm.tsx index 87fb439..ce2f525 100644 --- a/apps/search/src/components/AlgoliaConfigurationForm.tsx +++ b/apps/search/src/components/AlgoliaConfigurationForm.tsx @@ -1,15 +1,15 @@ import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge"; -import { useForm } from "react-hook-form"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { fetchConfiguration } from "../lib/configuration"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useDashboardNotification } from "@saleor/apps-shared"; import { Box, Button, Divider, Text } from "@saleor/macaw-ui/next"; import { Input } from "@saleor/react-hook-form-macaw"; -import { useDashboardNotification } from "@saleor/apps-shared"; -import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; +import { useForm } from "react-hook-form"; import { AppConfigurationFields, AppConfigurationSchema } from "../domain/configuration"; +import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider"; +import { trpcClient } from "../modules/trpc/trpc-client"; export const AlgoliaConfigurationForm = () => { const { notifyError, notifySuccess } = useDashboardNotification(); @@ -24,50 +24,32 @@ export const AlgoliaConfigurationForm = () => { }); const reactQueryClient = useQueryClient(); - /** - * TODO Extract to hook - */ - const { isLoading: isQueryLoading } = useQuery({ - queryKey: ["configuration"], - onSuccess(data) { - setValue("secretKey", data?.secretKey || ""); - setValue("appId", data?.appId || ""); - setValue("indexNamePrefix", data?.indexNamePrefix || ""); - }, - queryFn: async () => fetchConfiguration(fetch), - }); - const { mutate, isLoading: isMutationLoading } = useMutation( - async (conf: AppConfigurationFields) => { - const resp = await fetch("/api/configuration", { - method: "POST", - body: JSON.stringify(conf), - }); + const { isLoading: isQueryLoading, refetch: refetchConfig } = + trpcClient.configuration.getConfig.useQuery(undefined, { + onSuccess(data) { + setValue("secretKey", data?.secretKey || ""); + setValue("appId", data?.appId || ""); + setValue("indexNamePrefix", data?.indexNamePrefix || ""); + }, + }); - if (resp.status >= 200 && resp.status < 300) { - const data = (await resp.json()) as { data?: AppConfigurationFields }; - - return data.data; - } - throw new Error(`Server responded with status code ${resp.status}`); - }, - { + const { mutate: setConfig, isLoading: isMutationLoading } = + trpcClient.configuration.setConfig.useMutation({ onSuccess: async () => { await Promise.all([ - reactQueryClient.refetchQueries({ - queryKey: ["configuration"], - }), + refetchConfig(), + // todo migrate to trpc reactQueryClient.refetchQueries({ queryKey: ["webhooks-status"], }), ]); notifySuccess("Configuration saved!"); }, - onError: async (data: Error) => { - notifyError("Could not save the configuration", data.message); + onError: async (error) => { + notifyError("Could not save the configuration", error.message); }, - } - ); + }); const onFormSubmit = handleSubmit(async (conf) => { const client = new AlgoliaSearchProvider({ @@ -80,7 +62,7 @@ export const AlgoliaConfigurationForm = () => { await client.ping(); setCredentialsValidationError(false); - mutate(conf); + setConfig(conf); } catch (e) { trigger(); setCredentialsValidationError(true); diff --git a/apps/search/src/components/ImportProductsToAlgolia.tsx b/apps/search/src/components/ImportProductsToAlgolia.tsx index 34b36cd..452e431 100644 --- a/apps/search/src/components/ImportProductsToAlgolia.tsx +++ b/apps/search/src/components/ImportProductsToAlgolia.tsx @@ -1,8 +1,8 @@ import { Box, Button, Text } from "@saleor/macaw-ui/next"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider"; -import { useConfiguration } from "../lib/configuration"; import { Products, useQueryAllProducts } from "./useQueryAllProducts"; +import { trpcClient } from "../modules/trpc/trpc-client"; const BATCH_SIZE = 100; @@ -14,21 +14,21 @@ export const ImportProductsToAlgolia = () => { const products = useQueryAllProducts(!started); - const algoliaConfiguration = useConfiguration(); + const { data: algoliaConfiguration } = trpcClient.configuration.getConfig.useQuery(); const searchProvider = useMemo(() => { - if (!algoliaConfiguration.data?.appId || !algoliaConfiguration.data.secretKey) { + if (!algoliaConfiguration?.appId || !algoliaConfiguration.secretKey) { return null; } return new AlgoliaSearchProvider({ - appId: algoliaConfiguration.data.appId, - apiKey: algoliaConfiguration.data.secretKey, - indexNamePrefix: algoliaConfiguration.data.indexNamePrefix, + appId: algoliaConfiguration.appId, + apiKey: algoliaConfiguration.secretKey, + indexNamePrefix: algoliaConfiguration.indexNamePrefix, }); }, [ - algoliaConfiguration?.data?.appId, - algoliaConfiguration?.data?.indexNamePrefix, - algoliaConfiguration?.data?.secretKey, + algoliaConfiguration?.appId, + algoliaConfiguration?.indexNamePrefix, + algoliaConfiguration?.secretKey, ]); const importProducts = useCallback(() => { diff --git a/apps/search/src/components/IndicesSettings.tsx b/apps/search/src/components/IndicesSettings.tsx index 68df0e1..037a738 100644 --- a/apps/search/src/components/IndicesSettings.tsx +++ b/apps/search/src/components/IndicesSettings.tsx @@ -1,14 +1,14 @@ import { Box, Button, Text } from "@saleor/macaw-ui/next"; import React from "react"; -import { useConfiguration } from "../lib/configuration"; import { TextLink } from "@saleor/apps-ui"; import { useIndicesSetupMutation } from "../lib/useIndicesSetup"; +import { trpcClient } from "../modules/trpc/trpc-client"; export const IndicesSettings = () => { - const algoliaConfiguration = useConfiguration(); + const { data: algoliaConfiguration } = trpcClient.configuration.getConfig.useQuery(); const updateWebhooksMutation = useIndicesSetupMutation(); - const isConfigured = algoliaConfiguration.data?.appId && algoliaConfiguration.data?.secretKey; + const isConfigured = algoliaConfiguration?.appId && algoliaConfiguration?.secretKey; return ( diff --git a/apps/search/src/lib/algolia/getAlgoliaConfiguration.ts b/apps/search/src/lib/algolia/getAlgoliaConfiguration.ts index 07be217..e7a8b9b 100644 --- a/apps/search/src/lib/algolia/getAlgoliaConfiguration.ts +++ b/apps/search/src/lib/algolia/getAlgoliaConfiguration.ts @@ -15,7 +15,7 @@ export const getAlgoliaConfiguration = async ({ authData }: GetAlgoliaConfigurat token: authData.token, }); - const settings = createSettingsManager(client); + const settings = createSettingsManager(client, authData.appId); try { const secretKey = await settings.get("secretKey", authData.domain); diff --git a/apps/search/src/lib/configuration.ts b/apps/search/src/lib/configuration.ts deleted file mode 100644 index 27cc692..0000000 --- a/apps/search/src/lib/configuration.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge"; -import { AppConfigurationFields } from "../domain/configuration"; - -export const fetchConfiguration = async (fetch: typeof window.fetch) => { - try { - const res = await fetch("/api/configuration"); - const data = (await res.json()) as { data?: AppConfigurationFields }; - - return data.data; - } catch (e) { - throw e; - } -}; - -export const useConfiguration = () => { - const fetch = useAuthenticatedFetch(); - - return useQuery({ - queryKey: ["configuration"], - queryFn: () => fetchConfiguration(fetch), - }); -}; diff --git a/apps/search/src/lib/metadata.ts b/apps/search/src/lib/metadata.ts index e29b2a9..6877d9c 100644 --- a/apps/search/src/lib/metadata.ts +++ b/apps/search/src/lib/metadata.ts @@ -1,91 +1,12 @@ -import { - EncryptedMetadataManager, - MetadataEntry, - SettingsManager, -} from "@saleor/app-sdk/settings-manager"; - -import { - FetchAppDetailsDocument, - FetchAppDetailsQuery, - UpdateAppMetadataDocument, -} from "../../generated/graphql"; -import { settingsManagerSecretKey } from "../../saleor-app"; +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; +import { EncryptedMetadataManagerFactory } from "@saleor/apps-shared"; import { Client } from "urql"; -/* - * Function is using urql graphql client to fetch all available metadata. - * Before returning query result, we are transforming response to list of objects with key and value fields - * which can be used by the manager. - * Result of this query is cached by the manager. - */ -export async function fetchAllMetadata(client: Client): Promise { - const { error, data } = await client - .query(FetchAppDetailsDocument, {}) - .toPromise(); +export const createSettingsManager = ( + client: Pick, + appId: string, +): SettingsManager => { + const metadataManagerFactory = new EncryptedMetadataManagerFactory(process.env.SECRET_KEY!); - if (error) { - console.debug("Error during fetching the metadata: ", error); - return []; - } - - return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || []; -} - -/* - * Mutate function takes urql client and metadata entries, and construct mutation to the API. - * Before data are send, additional query for required App ID is made. - * The manager will use updated entries returned by this mutation to update it's cache. - */ -export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) { - // to update the metadata, ID is required - const { error: idQueryError, data: idQueryData } = await client - .query(FetchAppDetailsDocument, {}) - .toPromise(); - - if (idQueryError) { - console.debug("Could not fetch the app id: ", idQueryError); - throw new Error( - "Could not fetch the app id. Please check if auth data for the client are valid." - ); - } - - const appId = idQueryData?.app?.id; - - if (!appId) { - console.debug("Missing app id"); - throw new Error("Could not fetch the app ID"); - } - - const { error: mutationError, data: mutationData } = await client - .mutation(UpdateAppMetadataDocument, { - id: appId, - input: metadata, - }) - .toPromise(); - - if (mutationError) { - console.debug("Mutation error: ", mutationError); - throw new Error(`Mutation error: ${mutationError.message}`); - } - - return ( - mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({ - key: md.key, - value: md.value, - })) || [] - ); -} - -export const createSettingsManager = (client: Client): SettingsManager => { - /* - * EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory. - * We recommend it for production, because all values are encrypted. - * If your use case require plain text values, you can use MetadataManager. - */ - return new EncryptedMetadataManager({ - // Secret key should be randomly created for production and set as environment variable - encryptionKey: settingsManagerSecretKey, - fetchMetadata: () => fetchAllMetadata(client), - mutateMetadata: (metadata) => mutateMetadata(client, metadata), - }); + return metadataManagerFactory.create(client, appId); }; diff --git a/apps/search/src/modules/configuration/configuration.router.ts b/apps/search/src/modules/configuration/configuration.router.ts new file mode 100644 index 0000000..2ba5628 --- /dev/null +++ b/apps/search/src/modules/configuration/configuration.router.ts @@ -0,0 +1,83 @@ +import { createLogger } from "@saleor/apps-shared"; +import { TRPCError } from "@trpc/server"; +import { ChannelsDocument } from "../../../generated/graphql"; +import { WebhookActivityTogglerService } from "../../domain/WebhookActivityToggler.service"; +import { AppConfigurationFields, AppConfigurationSchema } from "../../domain/configuration"; +import { AlgoliaSearchProvider } from "../../lib/algolia/algoliaSearchProvider"; +import { createSettingsManager } from "../../lib/metadata"; +import { protectedClientProcedure } from "../trpc/protected-client-procedure"; +import { router } from "../trpc/trpc-server"; + +const logger = createLogger({ name: "configuration.router" }); + +export const configurationRouter = router({ + getConfig: protectedClientProcedure.query(async ({ ctx }) => { + const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId); + + /** + * Backwards compatbitility + */ + const domain = new URL(ctx.saleorApiUrl).host; + + /** + * TODO - refactor to single config in one key + */ + const data: AppConfigurationFields = { + secretKey: (await settingsManager.get("secretKey", domain)) || "", + appId: (await settingsManager.get("appId", domain)) || "", + indexNamePrefix: (await settingsManager.get("indexNamePrefix", domain)) || "", + }; + + logger.debug("Will return config"); + + return data; + }), + setConfig: protectedClientProcedure + .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + .input(AppConfigurationSchema) + .mutation(async ({ input, ctx }) => { + const { data: channelsData } = await ctx.apiClient.query(ChannelsDocument, {}).toPromise(); + const channels = channelsData?.channels || []; + + const algoliaClient = new AlgoliaSearchProvider({ + appId: ctx.appId, + apiKey: input.secretKey, + indexNamePrefix: input.indexNamePrefix, + channels, + }); + + const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId); + + /** + * Backwards compatbitility + */ + const domain = new URL(ctx.saleorApiUrl).host; + + try { + logger.debug("Will ping Algolia"); + await algoliaClient.ping(); + + logger.debug("Algolia connection is ok. Will save settings"); + + await settingsManager.set([ + { key: "secretKey", value: input.secretKey || "", domain }, + { key: "appId", value: input.appId || "", domain }, + { key: "indexNamePrefix", value: input.indexNamePrefix || "", domain }, + ]); + + logger.debug("Settings set successfully"); + + const webhooksToggler = new WebhookActivityTogglerService(ctx.appId, ctx.apiClient); + + await webhooksToggler.enableOwnWebhooks(); + + logger.debug("Webhooks enabled"); + } catch (e) { + throw new TRPCError({ + code: "BAD_REQUEST", + }); + } + + return null; + }), +}); diff --git a/apps/search/src/modules/trpc/protected-client-procedure.ts b/apps/search/src/modules/trpc/protected-client-procedure.ts new file mode 100644 index 0000000..5355098 --- /dev/null +++ b/apps/search/src/modules/trpc/protected-client-procedure.ts @@ -0,0 +1,117 @@ +import { verifyJWT } from "@saleor/app-sdk/verify-jwt"; +import { middleware, procedure } from "./trpc-server"; +import { TRPCError } from "@trpc/server"; +import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next"; +import { createGraphQLClient, logger } from "@saleor/apps-shared"; +import { saleorApp } from "../../../saleor-app"; + +const attachAppToken = middleware(async ({ ctx, next }) => { + logger.debug("attachAppToken middleware"); + + if (!ctx.saleorApiUrl) { + logger.debug("ctx.saleorApiUrl not found, throwing"); + + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Missing saleorApiUrl in request", + }); + } + + const authData = await saleorApp.apl.get(ctx.saleorApiUrl); + + if (!authData) { + logger.debug("authData not found, throwing 401"); + + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Missing auth data", + }); + } + + return next({ + ctx: { + appToken: authData.token, + saleorApiUrl: authData.saleorApiUrl, + appId: authData.appId, + }, + }); +}); + +const validateClientToken = middleware(async ({ ctx, next, meta }) => { + logger.debug( + { + permissions: meta?.requiredClientPermissions, + }, + "Calling validateClientToken middleware with permissions required", + ); + + if (!ctx.token) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Missing token in request. This middleware can be used only in frontend", + }); + } + + if (!ctx.appId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Missing appId in request. This middleware can be used after auth is attached", + }); + } + + if (!ctx.saleorApiUrl) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Missing saleorApiUrl in request. This middleware can be used after auth is attached", + }); + } + + if (!ctx.ssr) { + try { + logger.debug("trying to verify JWT token from frontend"); + logger.debug({ token: ctx.token ? `${ctx.token[0]}...` : undefined }); + + await verifyJWT({ + appId: ctx.appId, + token: ctx.token, + saleorApiUrl: ctx.saleorApiUrl, + requiredPermissions: meta?.requiredClientPermissions ?? [], + }); + } catch (e) { + logger.debug("JWT verification failed, throwing"); + throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED"); + } + } + + return next({ + ctx: { + ...ctx, + saleorApiUrl: ctx.saleorApiUrl, + }, + }); +}); + +/** + * Construct common graphQL client and attach it to the context + * + * Can be used only if called from the frontend (react-query), + * otherwise jwks validation will fail (if createCaller used) + * + * TODO Rethink middleware composition to enable safe server-side router calls + */ +export const protectedClientProcedure = procedure + .use(attachAppToken) + .use(validateClientToken) + .use(async ({ ctx, next }) => { + const client = createGraphQLClient({ saleorApiUrl: ctx.saleorApiUrl, token: ctx.appToken }); + + return next({ + ctx: { + apiClient: client, + appToken: ctx.appToken, + saleorApiUrl: ctx.saleorApiUrl, + appId: ctx.appId!, + }, + }); + }); diff --git a/apps/search/src/modules/trpc/trpc-app-router.ts b/apps/search/src/modules/trpc/trpc-app-router.ts new file mode 100644 index 0000000..5093eed --- /dev/null +++ b/apps/search/src/modules/trpc/trpc-app-router.ts @@ -0,0 +1,8 @@ +import { configurationRouter } from "../configuration/configuration.router"; +import { router } from "./trpc-server"; + +export const appRouter = router({ + configuration: configurationRouter, +}); + +export type AppRouter = typeof appRouter; diff --git a/apps/search/src/modules/trpc/trpc-client.ts b/apps/search/src/modules/trpc/trpc-client.ts new file mode 100644 index 0000000..9cce5af --- /dev/null +++ b/apps/search/src/modules/trpc/trpc-client.ts @@ -0,0 +1,45 @@ +import { httpBatchLink } from "@trpc/client"; +import { createTRPCNext } from "@trpc/next"; + +import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const"; + +import { AppRouter } from "./trpc-app-router"; +import { appBridgeInstance } from "../../pages/_app"; + +function getBaseUrl() { + if (typeof window !== "undefined") return ""; + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; + + return `http://localhost:${process.env.PORT ?? 3000}`; +} + +export const trpcClient = createTRPCNext({ + config({ ctx }) { + return { + links: [ + httpBatchLink({ + url: `${getBaseUrl()}/api/trpc`, + headers() { + const { token, saleorApiUrl } = appBridgeInstance?.getState() || {}; + + if (!token || !saleorApiUrl) { + console.error( + "Can't initialize tRPC client before establishing the App Bridge connection", + ); + throw new Error("Token and Saleor API URL unknown"); + } + return { + /** + * Attach headers from app to client requests, so tRPC can add them to context + */ + [SALEOR_AUTHORIZATION_BEARER_HEADER]: appBridgeInstance?.getState().token, + [SALEOR_API_URL_HEADER]: appBridgeInstance?.getState().saleorApiUrl, + }; + }, + }), + ], + queryClientConfig: { defaultOptions: { queries: { refetchOnWindowFocus: false } } }, + }; + }, + ssr: false, +}); diff --git a/apps/search/src/modules/trpc/trpc-context.ts b/apps/search/src/modules/trpc/trpc-context.ts new file mode 100644 index 0000000..44d2614 --- /dev/null +++ b/apps/search/src/modules/trpc/trpc-context.ts @@ -0,0 +1,30 @@ +import * as trpcNext from "@trpc/server/adapters/next"; +import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const"; +import { inferAsyncReturnType } from "@trpc/server"; + +const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => { + const { host, "x-forwarded-proto": xForwardedProto = "http" } = headers; + + const xForwardedProtos = Array.isArray(xForwardedProto) + ? xForwardedProto.join(",") + : xForwardedProto; + const protocols = xForwardedProtos.split(","); + // prefer https over other protocols + const protocol = protocols.find((el) => el === "https") || protocols[0]; + + return `${protocol}://${host}`; +}; + +export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => { + const baseUrl = getBaseUrl(req.headers); + + return { + token: req.headers[SALEOR_AUTHORIZATION_BEARER_HEADER] as string | undefined, + saleorApiUrl: req.headers[SALEOR_API_URL_HEADER] as string | undefined, + appId: undefined as undefined | string, + ssr: undefined as undefined | boolean, + baseUrl, + }; +}; + +export type TrpcContext = inferAsyncReturnType; diff --git a/apps/search/src/modules/trpc/trpc-server.ts b/apps/search/src/modules/trpc/trpc-server.ts new file mode 100644 index 0000000..fc5be26 --- /dev/null +++ b/apps/search/src/modules/trpc/trpc-server.ts @@ -0,0 +1,31 @@ +import { initTRPC } from "@trpc/server"; +import { TrpcContext } from "./trpc-context"; +import { Permission } from "@saleor/app-sdk/types"; +import { ZodError } from "zod"; + +interface Meta { + requiredClientPermissions?: Permission[]; + updateWebhooks?: boolean; +} + +const t = initTRPC + .context() + .meta() + .create({ + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: + error.code === "BAD_REQUEST" && error.cause instanceof ZodError + ? error.cause.flatten() + : null, + }, + }; + }, + }); + +export const router = t.router; +export const procedure = t.procedure; +export const middleware = t.middleware; diff --git a/apps/search/src/pages/_app.tsx b/apps/search/src/pages/_app.tsx index fcbec68..04b41d7 100644 --- a/apps/search/src/pages/_app.tsx +++ b/apps/search/src/pages/_app.tsx @@ -9,12 +9,13 @@ import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next"; import { ThemeSynchronizer } from "../lib/theme-synchronizer"; import { Box, ThemeProvider } from "@saleor/macaw-ui/next"; import { NoSSRWrapper } from "@saleor/apps-shared"; +import { trpcClient } from "../modules/trpc/trpc-client"; /** * Ensure instance is a singleton. * TODO: This is React 18 issue, consider hiding this workaround inside app-sdk */ -const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined; +export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined; const queryClient = new QueryClient({ defaultOptions: { @@ -44,4 +45,4 @@ function NextApp({ Component, pageProps }: AppProps) { ); } -export default NextApp; +export default trpcClient.withTRPC(NextApp); diff --git a/apps/search/src/pages/api/configuration.ts b/apps/search/src/pages/api/configuration.ts deleted file mode 100644 index 6d13e5f..0000000 --- a/apps/search/src/pages/api/configuration.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { SettingsManager } from "@saleor/app-sdk/settings-manager"; - -import { createSettingsManager } from "../../lib/metadata"; -import { saleorApp } from "../../../saleor-app"; - -import { createProtectedHandler, ProtectedHandlerContext } from "@saleor/app-sdk/handlers/next"; -import { createLogger } from "../../lib/logger"; -import { AppConfigurationFields } from "../../domain/configuration"; -import { AlgoliaSearchProvider } from "../../lib/algolia/algoliaSearchProvider"; -import { WebhookActivityTogglerService } from "../../domain/WebhookActivityToggler.service"; -import { createGraphQLClient } from "@saleor/apps-shared"; -import { ChannelsDocument } from "../../../generated/graphql"; - -const logger = createLogger({ - handler: "api/configuration", -}); - -export interface SettingsApiResponse { - success: boolean; - data?: AppConfigurationFields; -} - -const sendResponse = async ( - res: NextApiResponse, - statusCode: number, - settings: SettingsManager, - domain: string, -) => { - const data = { - secretKey: (await settings.get("secretKey", domain)) || "", - appId: (await settings.get("appId", domain)) || "", - indexNamePrefix: (await settings.get("indexNamePrefix", domain)) || "", - }; - - logger.debug(data, "Will return following settings"); - - res.status(statusCode).json({ - success: statusCode === 200, - data, - }); -}; - -export const handler = async ( - req: NextApiRequest, - res: NextApiResponse, - ctx: ProtectedHandlerContext, -) => { - const { - authData: { token, saleorApiUrl }, - } = ctx; - - logger.debug({ saleorApiUrl }, "handler called"); - - const client = createGraphQLClient({ - saleorApiUrl, - token, - }); - - const settings = createSettingsManager(client); - - const domain = new URL(saleorApiUrl).host; - - if (req.method === "GET") { - logger.debug("Returning configuration"); - - await sendResponse(res, 200, settings, domain); - return; - } else if (req.method === "POST") { - logger.debug("Updating the configuration"); - - const { appId, secretKey, indexNamePrefix } = JSON.parse(req.body) as AppConfigurationFields; - - const { data: channelsData } = await client.query(ChannelsDocument, {}).toPromise(); - const channels = channelsData?.channels || []; - - const algoliaClient = new AlgoliaSearchProvider({ - appId, - apiKey: secretKey, - indexNamePrefix: indexNamePrefix, - channels, - }); - - try { - logger.debug("Will ping Algolia"); - await algoliaClient.ping(); - - logger.debug("Algolia connection is ok. Will save settings"); - - await settings.set([ - { key: "secretKey", value: secretKey || "", domain }, - { key: "appId", value: appId || "", domain }, - { key: "indexNamePrefix", value: indexNamePrefix || "", domain }, - ]); - - logger.debug("Settings set"); - - const webhooksToggler = new WebhookActivityTogglerService(ctx.authData.appId, client); - - await webhooksToggler.enableOwnWebhooks(); - - logger.debug("Webhooks enabled"); - } catch (e) { - return res.status(400).end(); - } - - await sendResponse(res, 200, settings, domain); - - return; - } - logger.error("Method not supported"); - - res.status(405).end(); -}; - -export default createProtectedHandler(handler, saleorApp.apl, ["MANAGE_APPS", "MANAGE_PRODUCTS"]); diff --git a/apps/search/src/pages/api/recreate-webhooks.ts b/apps/search/src/pages/api/recreate-webhooks.ts index c7383df..18ed41c 100644 --- a/apps/search/src/pages/api/recreate-webhooks.ts +++ b/apps/search/src/pages/api/recreate-webhooks.ts @@ -20,7 +20,7 @@ const logger = createLogger({ * Simple dependency injection - factory injects all services, in tests everything can be configured without mocks */ type FactoryProps = { - settingsManagerFactory: (client: Client) => SettingsManager; + settingsManagerFactory: (client: Client, appId: string) => SettingsManager; webhookActivityTogglerFactory: (appId: string, client: Client) => IWebhookActivityTogglerService; graphqlClientFactory: (saleorApiUrl: string, token: string) => Client; }; @@ -40,7 +40,7 @@ export const recreateWebhooksHandlerFactory = logger.debug("Fetching settings"); const client = graphqlClientFactory(authData.saleorApiUrl, authData.token); const webhooksToggler = webhookActivityTogglerFactory(authData.appId, client); - const settingsManager = settingsManagerFactory(client); + const settingsManager = settingsManagerFactory(client, authData.appId); const domain = new URL(authData.saleorApiUrl).host; diff --git a/apps/search/src/pages/api/setup-indices.ts b/apps/search/src/pages/api/setup-indices.ts index 28718e6..badde2b 100644 --- a/apps/search/src/pages/api/setup-indices.ts +++ b/apps/search/src/pages/api/setup-indices.ts @@ -16,7 +16,7 @@ const logger = createLogger({ * Simple dependency injection - factory injects all services, in tests everything can be configured without mocks */ type FactoryProps = { - settingsManagerFactory: (client: Client) => SettingsManager; + settingsManagerFactory: (client: Client, appId: string) => SettingsManager; graphqlClientFactory: (saleorApiUrl: string, token: string) => Client; }; @@ -30,7 +30,7 @@ export const setupIndicesHandlerFactory = logger.debug("Fetching settings"); const client = graphqlClientFactory(authData.saleorApiUrl, authData.token); - const settingsManager = settingsManagerFactory(client); + const settingsManager = settingsManagerFactory(client, authData.appId); const domain = new URL(authData.saleorApiUrl).host; diff --git a/apps/search/src/pages/api/trpc/[trpc].ts b/apps/search/src/pages/api/trpc/[trpc].ts new file mode 100644 index 0000000..c1d733c --- /dev/null +++ b/apps/search/src/pages/api/trpc/[trpc].ts @@ -0,0 +1,8 @@ +import * as trpcNext from "@trpc/server/adapters/next"; +import { createTrpcContext } from "../../../modules/trpc/trpc-context"; +import { appRouter } from "../../../modules/trpc/trpc-app-router"; + +export default trpcNext.createNextApiHandler({ + router: appRouter, + createContext: createTrpcContext, +}); diff --git a/apps/search/src/pages/api/webhooks-status.ts b/apps/search/src/pages/api/webhooks-status.ts index b2cdbc5..b979a4b 100644 --- a/apps/search/src/pages/api/webhooks-status.ts +++ b/apps/search/src/pages/api/webhooks-status.ts @@ -22,10 +22,16 @@ const logger = createLogger({ * Simple dependency injection - factory injects all services, in tests everything can be configured without mocks */ type FactoryProps = { - settingsManagerFactory: (client: Client) => SettingsManager; - webhookActivityTogglerFactory: (appId: string, client: Client) => IWebhookActivityTogglerService; + settingsManagerFactory: ( + client: Pick, + appId: string, + ) => SettingsManager; + webhookActivityTogglerFactory: ( + appId: string, + client: Pick, + ) => IWebhookActivityTogglerService; algoliaSearchProviderFactory: (appId: string, apiKey: string) => Pick; - graphqlClientFactory: (saleorApiUrl: string, token: string) => Client; + graphqlClientFactory: (saleorApiUrl: string, token: string) => Pick; }; export type WebhooksStatusResponse = { @@ -46,7 +52,7 @@ export const webhooksStatusHandlerFactory = */ const client = graphqlClientFactory(authData.saleorApiUrl, authData.token); const webhooksToggler = webhookActivityTogglerFactory(authData.appId, client); - const settingsManager = settingsManagerFactory(client); + const settingsManager = settingsManagerFactory(client, authData.appId); const domain = new URL(authData.saleorApiUrl).host; diff --git a/apps/search/src/test/handlers/webhooks-status-handler.test.ts b/apps/search/src/test/handlers/webhooks-status-handler.test.ts index aec92ee..e19e985 100644 --- a/apps/search/src/test/handlers/webhooks-status-handler.test.ts +++ b/apps/search/src/test/handlers/webhooks-status-handler.test.ts @@ -28,6 +28,7 @@ const mockWebhookContext = { const appWebhooksResponseData: Pick, "data"> = { data: { app: { + id: "appID", webhooks: [ { name: "W1", @@ -39,6 +40,7 @@ const appWebhooksResponseData: Pick, eventDeliveries: { edges: [], }, + targetUrl: "localhost:3000/api/webhooks/test", }, ], }, diff --git a/apps/search/turbo.json b/apps/search/turbo.json index 154607b..11982ef 100644 --- a/apps/search/turbo.json +++ b/apps/search/turbo.json @@ -19,7 +19,9 @@ "NEXT_PUBLIC_SENTRY_DSN", "SENTRY_ENVIRONMENT", "APP_IFRAME_BASE_URL", - "APP_API_BASE_URL" + "APP_API_BASE_URL", + "VERCEL_URL", + "PORT" ] } } diff --git a/packages/shared/src/metadata-manager.ts b/packages/shared/src/metadata-manager.ts index b9d3aae..bf4e940 100644 --- a/packages/shared/src/metadata-manager.ts +++ b/packages/shared/src/metadata-manager.ts @@ -107,6 +107,7 @@ async function updatePrivateMetadata( export class EncryptedMetadataManagerFactory { constructor(private encryptionKey: string) { if (!encryptionKey) { + console.error("Encryption key is required, will throw"); throw new Error("Encryption key is required"); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ef9712..5a4f44b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,5 @@ lockfileVersion: '6.0' -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - importers: .: @@ -1053,6 +1049,18 @@ importers: '@tanstack/react-query': specifier: 4.29.19 version: 4.29.19(react-dom@18.2.0)(react@18.2.0) + '@trpc/client': + specifier: 10.34.0 + version: 10.34.0(@trpc/server@10.34.0) + '@trpc/next': + specifier: 10.34.0 + version: 10.34.0(@tanstack/react-query@4.29.19)(@trpc/client@10.34.0)(@trpc/react-query@10.34.0)(@trpc/server@10.34.0)(next@13.4.8)(react-dom@18.2.0)(react@18.2.0) + '@trpc/react-query': + specifier: 10.34.0 + version: 10.34.0(@tanstack/react-query@4.29.19)(@trpc/client@10.34.0)(@trpc/server@10.34.0)(react-dom@18.2.0)(react@18.2.0) + '@trpc/server': + specifier: 10.34.0 + version: 10.34.0 '@types/debug': specifier: ^4.1.7 version: 4.1.8 @@ -5661,6 +5669,7 @@ packages: engines: {node: '>=6.9.0'} dependencies: regenerator-runtime: 0.14.0 + dev: true /@babel/runtime@7.22.11: resolution: {integrity: sha512-ee7jVNlWN09+KftVOu9n7S8gQzD/Z6hN/I8VBRXW4P1+Xe7kJGXMwu8vds4aGIMHZnNbdpSWCfZZtinytpcAvA==} @@ -5794,7 +5803,7 @@ packages: /@changesets/apply-release-plan@6.1.4: resolution: {integrity: sha512-FMpKF1fRlJyCZVYHr3CbinpZZ+6MwvOtWUuO8uo+svcATEoc1zRDcj23pAurJ2TZ/uVz1wFHH6K3NlACy0PLew==} dependencies: - '@babel/runtime': 7.22.10 + '@babel/runtime': 7.22.11 '@changesets/config': 2.3.1 '@changesets/get-version-range-type': 0.3.2 '@changesets/git': 2.0.0 @@ -5811,7 +5820,7 @@ packages: /@changesets/assemble-release-plan@5.2.4: resolution: {integrity: sha512-xJkWX+1/CUaOUWTguXEbCDTyWJFECEhmdtbkjhn5GVBGxdP/JwaHBIU9sW3FR6gD07UwZ7ovpiPclQZs+j+mvg==} dependencies: - '@babel/runtime': 7.22.10 + '@babel/runtime': 7.22.11 '@changesets/errors': 0.1.4 '@changesets/get-dependents-graph': 1.3.6 '@changesets/types': 5.2.1 @@ -5889,7 +5898,7 @@ packages: /@changesets/get-release-plan@3.0.17: resolution: {integrity: sha512-6IwKTubNEgoOZwDontYc2x2cWXfr6IKxP3IhKeK+WjyD6y3M4Gl/jdQvBw+m/5zWILSOCAaGLu2ZF6Q+WiPniw==} dependencies: - '@babel/runtime': 7.22.10 + '@babel/runtime': 7.22.11 '@changesets/assemble-release-plan': 5.2.4 '@changesets/config': 2.3.1 '@changesets/pre': 1.0.14 @@ -5903,7 +5912,7 @@ packages: /@changesets/git@2.0.0: resolution: {integrity: sha512-enUVEWbiqUTxqSnmesyJGWfzd51PY4H7mH9yUw0hPVpZBJ6tQZFMU3F3mT/t9OJ/GjyiM4770i+sehAn6ymx6A==} dependencies: - '@babel/runtime': 7.22.10 + '@babel/runtime': 7.22.11 '@changesets/errors': 0.1.4 '@changesets/types': 5.2.1 '@manypkg/get-packages': 1.1.3 @@ -5925,7 +5934,7 @@ packages: /@changesets/pre@1.0.14: resolution: {integrity: sha512-dTsHmxQWEQekHYHbg+M1mDVYFvegDh9j/kySNuDKdylwfMEevTeDouR7IfHNyVodxZXu17sXoJuf2D0vi55FHQ==} dependencies: - '@babel/runtime': 7.22.10 + '@babel/runtime': 7.22.11 '@changesets/errors': 0.1.4 '@changesets/types': 5.2.1 '@manypkg/get-packages': 1.1.3 @@ -5934,7 +5943,7 @@ packages: /@changesets/read@0.5.9: resolution: {integrity: sha512-T8BJ6JS6j1gfO1HFq50kU3qawYxa4NTbI/ASNVVCBTsKquy2HYwM9r7ZnzkiMe8IEObAJtUVGSrePCOxAK2haQ==} dependencies: - '@babel/runtime': 7.22.10 + '@babel/runtime': 7.22.11 '@changesets/git': 2.0.0 '@changesets/logger': 0.0.5 '@changesets/parse': 0.3.16 @@ -5952,7 +5961,7 @@ packages: /@changesets/write@0.2.3: resolution: {integrity: sha512-Dbamr7AIMvslKnNYsLFafaVORx4H0pvCA2MHqgtNCySMe1blImEyAEOzDmcgKAkgz4+uwoLz7demIrX+JBr/Xw==} dependencies: - '@babel/runtime': 7.22.10 + '@babel/runtime': 7.22.11 '@changesets/types': 5.2.1 fs-extra: 7.0.1 human-id: 1.0.2 @@ -7693,7 +7702,7 @@ packages: /@manypkg/get-packages@1.1.3: resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} dependencies: - '@babel/runtime': 7.22.10 + '@babel/runtime': 7.22.11 '@changesets/types': 4.1.0 '@manypkg/find-root': 1.1.0 fs-extra: 8.1.0 @@ -21366,3 +21375,7 @@ packages: /zod@3.21.4: resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false