diff --git a/.changeset/open-eels-yell.md b/.changeset/open-eels-yell.md new file mode 100644 index 000000000..ce7a247ca --- /dev/null +++ b/.changeset/open-eels-yell.md @@ -0,0 +1,5 @@ +--- +"saleor-app-klaviyo": minor +--- + +App now will listen on APP_DELETED webhook and clean up it's Auth Data once removed diff --git a/.changeset/petite-days-watch.md b/.changeset/petite-days-watch.md new file mode 100644 index 000000000..30d586456 --- /dev/null +++ b/.changeset/petite-days-watch.md @@ -0,0 +1,5 @@ +--- +"@saleor/webhook-utils": minor +--- + +Added APP_DELETED shared webhook that automatically cleans up APL diff --git a/apps/avatax/src/app/api/webhooks/app-deleted/route.ts b/apps/avatax/src/app/api/webhooks/app-deleted/route.ts new file mode 100644 index 000000000..e794498c1 --- /dev/null +++ b/apps/avatax/src/app/api/webhooks/app-deleted/route.ts @@ -0,0 +1,3 @@ +import { appDeletedWebhook } from "./webhook-definition"; + +export const POST = appDeletedWebhook.handler; diff --git a/apps/avatax/src/app/api/webhooks/app-deleted/webhook-definition.ts b/apps/avatax/src/app/api/webhooks/app-deleted/webhook-definition.ts new file mode 100644 index 000000000..69c257d60 --- /dev/null +++ b/apps/avatax/src/app/api/webhooks/app-deleted/webhook-definition.ts @@ -0,0 +1,39 @@ +import { createAppDeletedHandler } from "@saleor/webhook-utils/app-deleted-handler"; + +import { env } from "@/env"; +import { ClientLogDynamoEntityFactory, LogsTable } from "@/modules/client-logs/dynamo-logs-table"; +import { LogsRepositoryDynamodb } from "@/modules/client-logs/logs-repository"; +import { createDocumentClient, createDynamoClient } from "@/modules/dynamodb/dynamo-client"; + +import { saleorApp } from "../../../../../saleor-app"; +import { createLogger } from "../../../../logger"; + +export const appDeletedWebhook = createAppDeletedHandler({ + apl: saleorApp.apl, + logger: createLogger("APP_DELETED handler"), + webhookPath: "api/webhooks/app-deleted", + hooks: { + async onEvent({ authData }) { + const logsTable = LogsTable.create({ + documentClient: createDocumentClient( + createDynamoClient({ + connectionTimeout: env.DYNAMODB_CONNECTION_TIMEOUT_MS, + requestTimeout: env.DYNAMODB_REQUEST_TIMEOUT_MS, + }), + ), + tableName: env.DYNAMODB_LOGS_TABLE_NAME, + }); + const logByDateEntity = ClientLogDynamoEntityFactory.createLogByDate(logsTable); + const logByCheckoutOrOrderId = + ClientLogDynamoEntityFactory.createLogByCheckoutOrOrderId(logsTable); + + const repo = new LogsRepositoryDynamodb({ + logsTable, + logByDateEntity, + logByCheckoutOrOrderId, + }); + + await repo.pruneAllLogs({ saleorApiUrl: authData.saleorApiUrl }); + }, + }, +}); diff --git a/apps/avatax/src/modules/client-logs/dynamo-logs-table.ts b/apps/avatax/src/modules/client-logs/dynamo-logs-table.ts index cb4a095fb..582f0bed8 100644 --- a/apps/avatax/src/modules/client-logs/dynamo-logs-table.ts +++ b/apps/avatax/src/modules/client-logs/dynamo-logs-table.ts @@ -60,6 +60,10 @@ export class LogsTable extends Table< return `${saleorApiUrl}#${appId}`; } + static decomposePrimaryKey(pk: string) { + return pk.split("#") as [saleorApiUrl: string, appId: string]; + } + static getDefaultTTL() { const daysUntilExpire = env.DYNAMODB_LOGS_ITEM_TTL_IN_DAYS; const now = new Date(); diff --git a/apps/avatax/src/modules/client-logs/logs-repository.ts b/apps/avatax/src/modules/client-logs/logs-repository.ts index 66c8a42f1..998903aea 100644 --- a/apps/avatax/src/modules/client-logs/logs-repository.ts +++ b/apps/avatax/src/modules/client-logs/logs-repository.ts @@ -1,9 +1,11 @@ import { type BatchWriteCommandOutput } from "@aws-sdk/lib-dynamodb"; import { + BatchDeleteRequest, BatchPutRequest, BatchWriteCommand, executeBatchWrite, QueryCommand, + ScanCommand, } from "dynamodb-toolbox"; import { err, ok, Result, ResultAsync } from "neverthrow"; import { ulid } from "ulid"; @@ -38,6 +40,7 @@ export interface ILogsRepository { saleorApiUrl: string; appId: string; }): Promise>; + pruneAllLogs(args: { saleorApiUrl: string }): Promise>; } /** @@ -323,6 +326,108 @@ export class LogsRepositoryDynamodb implements ILogsRepository { return ok(undefined); } + + async pruneAllLogs({ + saleorApiUrl, + }: { + saleorApiUrl: string; + }): Promise< + Result< + undefined, + | InstanceType + | InstanceType + | InstanceType + > + > { + this.logger.debug("Starting pruning logs for saleorApiUrl", { saleorApiUrl }); + + const pkPrefix = `${saleorApiUrl}#`; + + let lastEvaluatedKey: LastEvaluatedKey; + let deletedCount = 0; + + do { + const scanResult = await ResultAsync.fromPromise( + this.logsTable + .build(ScanCommand) + .entities(this.logByDateEntity, this.logsByCheckoutOrOrderId) + .options({ + exclusiveStartKey: lastEvaluatedKey, + showEntityAttr: true, + filters: { + LOG_BY_DATE: { attr: "PK", beginsWith: pkPrefix }, + LOG_BY_CHECKOUT_OR_ORDER_ID: { attr: "PK", beginsWith: pkPrefix }, + }, + }) + .send(), + (error) => + new LogsRepositoryDynamodb.LogsFetchError("Error while scanning logs for pruning", { + cause: error, + }), + ); + + if (scanResult.isErr()) { + this.logger.error("Error while scanning logs for pruning", { error: scanResult.error }); + + return err(scanResult.error); + } + + lastEvaluatedKey = scanResult.value.LastEvaluatedKey; + + const items = scanResult.value.Items ?? []; + + for (let i = 0; i < items.length; i += 25) { + const chunk = items.slice(i, i + 25); + + const requests = chunk.map((item) => + item.entity === "LOG_BY_DATE" + ? this.logByDateEntity + .build(BatchDeleteRequest) + .key({ PK: item.PK, ulid: item.ulid, date: item.date }) + : this.logsByCheckoutOrOrderId.build(BatchDeleteRequest).key({ + PK: item.PK, + ulid: item.ulid, + date: item.date, + checkoutOrOrderId: item.checkoutOrOrderId, + }), + ); + + const cmd = this.logsTable.build(BatchWriteCommand).requests(...requests); + + const deleteResult = await ResultAsync.fromPromise( + executeBatchWrite({ capacity: "TOTAL", maxAttempts: 3 }, cmd), + (error) => + new LogsRepositoryDynamodb.WriteLogError("Error while deleting logs from DynamoDB", { + cause: error, + }), + ); + + if (deleteResult.isErr()) { + this.logger.error("Error while batch-deleting logs from DynamoDB", { + error: deleteResult.error, + }); + + return err(deleteResult.error); + } + + if (this.hasUnprocessedItems(deleteResult.value.UnprocessedItems)) { + this.logger.warn("Some logs were not deleted from DynamoDB", { + unprocessedItems: deleteResult.value.UnprocessedItems, + }); + + return err( + new LogsRepositoryDynamodb.UnprocessedItemsError("Some logs were not deleted"), + ); + } + + deletedCount += chunk.length; + } + } while (lastEvaluatedKey); + + this.logger.info("Pruned all logs for saleorApiUrl", { saleorApiUrl, deletedCount }); + + return ok(undefined); + } } /** @@ -376,4 +481,18 @@ export class LogsRepositoryMemory implements ILogsRepository { }): Promise> { return ok({ clientLogs: this.logs, lastEvaluatedKey: undefined }); } + + async pruneAllLogs(args: { + saleorApiUrl: string; + appId: string; + }): Promise> { + this.logs = this.logs.filter((l) => { + const log = l.getValue(); + const [saleorApiUrl] = LogsTable.decomposePrimaryKey(log.id); + + return args.saleorApiUrl !== saleorApiUrl; + }); + + return ok(undefined); + } } diff --git a/apps/avatax/webhooks.ts b/apps/avatax/webhooks.ts index a8d797ef1..daf03c7b0 100644 --- a/apps/avatax/webhooks.ts +++ b/apps/avatax/webhooks.ts @@ -1,3 +1,5 @@ +import { appDeletedWebhook } from "@/app/api/webhooks/app-deleted/webhook-definition"; + import { checkoutCalculateTaxesSyncWebhook } from "./src/modules/webhooks/definitions/checkout-calculate-taxes"; import { orderCalculateTaxesSyncWebhook } from "./src/modules/webhooks/definitions/order-calculate-taxes"; import { orderCancelledAsyncWebhook } from "./src/modules/webhooks/definitions/order-cancelled"; @@ -8,4 +10,5 @@ export const appWebhooks = [ orderCalculateTaxesSyncWebhook, orderCancelledAsyncWebhook, orderConfirmedAsyncWebhook, + appDeletedWebhook, ]; diff --git a/apps/cms/package.json b/apps/cms/package.json index d5fdeb4f5..94b8c3dce 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -40,6 +40,7 @@ "@saleor/macaw-ui": "catalog:", "@saleor/react-hook-form-macaw": "workspace:*", "@saleor/sentry-utils": "workspace:*", + "@saleor/webhook-utils": "workspace:*", "@sentry/cli": "catalog:", "@sentry/nextjs": "catalog:", "@t3-oss/env-nextjs": "catalog:", diff --git a/apps/cms/src/app/api/webhooks/app-deleted/route.ts b/apps/cms/src/app/api/webhooks/app-deleted/route.ts new file mode 100644 index 000000000..e794498c1 --- /dev/null +++ b/apps/cms/src/app/api/webhooks/app-deleted/route.ts @@ -0,0 +1,3 @@ +import { appDeletedWebhook } from "./webhook-definition"; + +export const POST = appDeletedWebhook.handler; diff --git a/apps/cms/src/app/api/webhooks/app-deleted/webhook-definition.ts b/apps/cms/src/app/api/webhooks/app-deleted/webhook-definition.ts new file mode 100644 index 000000000..d2de3c2dd --- /dev/null +++ b/apps/cms/src/app/api/webhooks/app-deleted/webhook-definition.ts @@ -0,0 +1,11 @@ +import { createAppDeletedHandler } from "@saleor/webhook-utils/app-deleted-handler"; + +import { saleorApp } from "@/saleor-app"; + +import { createLogger } from "../../../../logger"; + +export const appDeletedWebhook = createAppDeletedHandler({ + apl: saleorApp.apl, + logger: createLogger("APP_DELETED handler"), + webhookPath: "api/webhooks/app-deleted", +}); diff --git a/apps/cms/src/pages/api/manifest.ts b/apps/cms/src/pages/api/manifest.ts index b89ff17b9..c23a65f4f 100644 --- a/apps/cms/src/pages/api/manifest.ts +++ b/apps/cms/src/pages/api/manifest.ts @@ -3,6 +3,7 @@ import { type AppManifest } from "@saleor/app-sdk/types"; import { wrapWithLoggerContext } from "@saleor/apps-logger/node"; import { withSpanAttributes } from "@saleor/apps-otel/src/with-span-attributes"; +import { appDeletedWebhook } from "@/app/api/webhooks/app-deleted/webhook-definition"; import { env } from "@/env"; import packageJson from "../../../package.json"; @@ -62,6 +63,10 @@ const handler = createManifestHandler({ * Detect changes in parent product (slug, name) and create/update all variants in CMS */ productUpdatedWebhook.getWebhookManifest(apiBaseURL), + /** + * Cleans app after uninstall + */ + appDeletedWebhook.getWebhookManifest(apiBaseURL), ], }; diff --git a/apps/klaviyo/next-env.d.ts b/apps/klaviyo/next-env.d.ts index 52e831b43..3cd7048ed 100644 --- a/apps/klaviyo/next-env.d.ts +++ b/apps/klaviyo/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/klaviyo/package.json b/apps/klaviyo/package.json index a9276d69a..8de8bfd5d 100644 --- a/apps/klaviyo/package.json +++ b/apps/klaviyo/package.json @@ -28,6 +28,7 @@ "@opentelemetry/semantic-conventions": "catalog:", "@saleor/app-sdk": "link:../../node_modules/@saleor/app-sdk", "@saleor/apps-logger": "workspace:*", + "@saleor/webhook-utils": "workspace:*", "@saleor/apps-otel": "workspace:*", "@saleor/apps-shared": "workspace:*", "@saleor/apps-ui": "workspace:*", diff --git a/apps/klaviyo/src/app/api/webhooks/app-deleted/route.ts b/apps/klaviyo/src/app/api/webhooks/app-deleted/route.ts new file mode 100644 index 000000000..e794498c1 --- /dev/null +++ b/apps/klaviyo/src/app/api/webhooks/app-deleted/route.ts @@ -0,0 +1,3 @@ +import { appDeletedWebhook } from "./webhook-definition"; + +export const POST = appDeletedWebhook.handler; diff --git a/apps/klaviyo/src/app/api/webhooks/app-deleted/webhook-definition.ts b/apps/klaviyo/src/app/api/webhooks/app-deleted/webhook-definition.ts new file mode 100644 index 000000000..ad92a2bba --- /dev/null +++ b/apps/klaviyo/src/app/api/webhooks/app-deleted/webhook-definition.ts @@ -0,0 +1,10 @@ +import { createAppDeletedHandler } from "@saleor/webhook-utils/app-deleted-handler"; + +import { saleorApp } from "../../../../../saleor-app"; +import { createLogger } from "../../../../logger"; + +export const appDeletedWebhook = createAppDeletedHandler({ + apl: saleorApp.apl, + logger: createLogger("APP_DELETED handler"), + webhookPath: "api/webhooks/app-deleted", +}); diff --git a/apps/klaviyo/src/pages/api/manifest.ts b/apps/klaviyo/src/pages/api/manifest.ts index b4dea0ced..34f4cae4e 100644 --- a/apps/klaviyo/src/pages/api/manifest.ts +++ b/apps/klaviyo/src/pages/api/manifest.ts @@ -1,9 +1,10 @@ import { createManifestHandler } from "@saleor/app-sdk/handlers/next"; -import { AppManifest } from "@saleor/app-sdk/types"; +import { type AppManifest } from "@saleor/app-sdk/types"; import { wrapWithLoggerContext } from "@saleor/apps-logger/node"; import { withSpanAttributes } from "@saleor/apps-otel/src/with-span-attributes"; import pkg from "../../../package.json"; +import { appDeletedWebhook } from "../../app/api/webhooks/app-deleted/webhook-definition"; import { env } from "../../env"; import { loggerContext } from "../../logger-context"; import { customerCreatedWebhook } from "./webhooks/customer-created"; @@ -41,6 +42,7 @@ const handler = wrapWithLoggerContext( fulfillmentCreatedWebhook.getWebhookManifest(appBaseUrl), orderCreatedWebhook.getWebhookManifest(appBaseUrl), orderFullyPaidWebhook.getWebhookManifest(appBaseUrl), + appDeletedWebhook.getWebhookManifest(appBaseUrl), ], }; }, diff --git a/apps/klaviyo/tsconfig.json b/apps/klaviyo/tsconfig.json index 8266f2ebf..ec6494bce 100644 --- a/apps/klaviyo/tsconfig.json +++ b/apps/klaviyo/tsconfig.json @@ -1,8 +1,22 @@ { "extends": "@saleor/typescript-config-apps/base.json", "compilerOptions": { - "baseUrl": "." + "baseUrl": ".", + "plugins": [ + { + "name": "next" + } + ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.ts", "graphql.config.ts"], - "exclude": ["node_modules"] + "include": [ + "**/*.ts", + "**/*.tsx", + "graphql.config.ts", + "next-env.d.ts", + "next.config.ts", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/apps/np-atobarai/src/app/api/manifest/route.ts b/apps/np-atobarai/src/app/api/manifest/route.ts index 57565bd28..b22dae6d8 100644 --- a/apps/np-atobarai/src/app/api/manifest/route.ts +++ b/apps/np-atobarai/src/app/api/manifest/route.ts @@ -3,6 +3,7 @@ import { type AppManifest } from "@saleor/app-sdk/types"; import { withSpanAttributesAppRouter } from "@saleor/apps-otel/src/with-span-attributes"; import { compose } from "@saleor/apps-shared/compose"; +import { appDeletedWebhook } from "@/app/api/webhooks/saleor/app-deleted/webhook-definition"; import { env } from "@/lib/env"; import { withLoggerContext } from "@/lib/logger-context"; import packageJson from "@/package.json"; @@ -47,6 +48,7 @@ const handler = createManifestHandler({ transactionProcessSessionWebhookDefinition.getWebhookManifest(apiBaseUrl), transactionRefundRequestedWebhookDefinition.getWebhookManifest(apiBaseUrl), fulfillmentTrackingNumberUpdatedWebhookDefinition.getWebhookManifest(apiBaseUrl), + appDeletedWebhook.getWebhookManifest(apiBaseUrl), ], }; diff --git a/apps/np-atobarai/src/app/api/webhooks/saleor/app-deleted/route.ts b/apps/np-atobarai/src/app/api/webhooks/saleor/app-deleted/route.ts new file mode 100644 index 000000000..e794498c1 --- /dev/null +++ b/apps/np-atobarai/src/app/api/webhooks/saleor/app-deleted/route.ts @@ -0,0 +1,3 @@ +import { appDeletedWebhook } from "./webhook-definition"; + +export const POST = appDeletedWebhook.handler; diff --git a/apps/np-atobarai/src/app/api/webhooks/saleor/app-deleted/webhook-definition.ts b/apps/np-atobarai/src/app/api/webhooks/saleor/app-deleted/webhook-definition.ts new file mode 100644 index 000000000..88c994394 --- /dev/null +++ b/apps/np-atobarai/src/app/api/webhooks/saleor/app-deleted/webhook-definition.ts @@ -0,0 +1,20 @@ +import { createSaleorApiUrl } from "@saleor/apps-domain/saleor-api-url"; +import { createAppDeletedHandler } from "@saleor/webhook-utils/app-deleted-handler"; + +import { createLogger } from "@/lib/logger"; +import { saleorApp } from "@/lib/saleor-app"; +import { appConfigRepo } from "@/modules/app-config/repo/app-config-repo"; + +export const appDeletedWebhook = createAppDeletedHandler({ + apl: saleorApp.apl, + logger: createLogger("APP_DELETED handler"), + webhookPath: "api/webhooks/saleor/app-deleted", + hooks: { + async onEvent(ctx) { + const { authData } = ctx; + + // TODO: Use next.js "waitFor". Make retry strategy or queue. Saleor will not retry this delivery. + await appConfigRepo.pruneTenant(createSaleorApiUrl(authData.saleorApiUrl)); + }, + }, +}); diff --git a/packages/dynamo-config-repository/src/base-repository.ts b/packages/dynamo-config-repository/src/base-repository.ts index d1f135b10..71ae44f3f 100644 --- a/packages/dynamo-config-repository/src/base-repository.ts +++ b/packages/dynamo-config-repository/src/base-repository.ts @@ -1,29 +1,29 @@ -import { SaleorApiUrl } from "@saleor/apps-domain/saleor-api-url"; +import { type SaleorApiUrl } from "@saleor/apps-domain/saleor-api-url"; import { DeleteItemCommand, Entity, EntityParser, GetItemCommand, - InputValue, + type InputValue, item, Parser, PutItemCommand, QueryCommand, - Schema, + type Schema, string, - Table, - ValidValue, + type Table, + type ValidValue, } from "dynamodb-toolbox"; -import { err, ok, Result } from "neverthrow"; +import { err, ok, type Result } from "neverthrow"; import { GenericRootConfig } from "./generic-root-config"; import { - BaseAccessPattern, - BaseConfig, - ConfigByChannelIdAccessPattern, - ConfigByConfigIdAccessPattern, - GenericRepo, - GetChannelConfigAccessPattern, + type BaseAccessPattern, + type BaseConfig, + type ConfigByChannelIdAccessPattern, + type ConfigByConfigIdAccessPattern, + type GenericRepo, + type GetChannelConfigAccessPattern, RepoError, } from "./types"; @@ -352,6 +352,12 @@ export class DynamoConfigRepository< ); } } + + async pruneTenant(_saleorApiUrl: SaleorApiUrl) { + // todo implement pruning all rows with pk starting with saleorApiUrl + + return ok(undefined); + } } export function createDynamoConfigRepository< diff --git a/packages/dynamo-config-repository/src/types.ts b/packages/dynamo-config-repository/src/types.ts index 7fa7ee824..788612ee1 100644 --- a/packages/dynamo-config-repository/src/types.ts +++ b/packages/dynamo-config-repository/src/types.ts @@ -1,8 +1,8 @@ -import { SaleorApiUrl } from "@saleor/apps-domain/saleor-api-url"; +import { type SaleorApiUrl } from "@saleor/apps-domain/saleor-api-url"; import { BaseError } from "@saleor/errors"; -import { Result } from "neverthrow"; +import { type Result } from "neverthrow"; -import { GenericRootConfig } from "./generic-root-config"; +import { type GenericRootConfig } from "./generic-root-config"; export type BaseAccessPattern = { saleorApiUrl: SaleorApiUrl; @@ -52,4 +52,7 @@ export interface GenericRepo { channelId: string; }, ) => Promise>>; + pruneTenant?: ( + saleorApiUrl: SaleorApiUrl, + ) => Promise>>; } diff --git a/packages/webhook-utils/generated/graphql.ts b/packages/webhook-utils/generated/graphql.ts index 2b20d6e27..612d91238 100644 --- a/packages/webhook-utils/generated/graphql.ts +++ b/packages/webhook-utils/generated/graphql.ts @@ -1,5 +1,4 @@ import gql from 'graphql-tag'; -import * as Urql from 'urql'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; type JSONValue = string | number | boolean | null | { [key: string]: JSONValue } | JSONValue[]; export type Maybe = T | null; @@ -913,6 +912,11 @@ export type GetSaleorInstanceDataQueryVariables = Exact<{ [key: string]: never; export type GetSaleorInstanceDataQuery = { readonly shop: { readonly version: string } }; +export type AppDeletedSubscriptionVariables = Exact<{ [key: string]: never; }>; + + +export type AppDeletedSubscription = { readonly event?: { readonly app?: { readonly id: string } | null } | {} | null }; + export const UntypedWebhookDetailsFragmentFragmentDoc = gql` fragment WebhookDetailsFragment on Webhook { id @@ -944,10 +948,6 @@ export const UntypedCreateAppWebhookDocument = gql` } } ${UntypedWebhookDetailsFragmentFragmentDoc}`; - -export function useCreateAppWebhookMutation() { - return Urql.useMutation(UntypedCreateAppWebhookDocument); -}; export const UntypedModifyAppWebhookDocument = gql` mutation ModifyAppWebhook($id: ID!, $input: WebhookUpdateInput!) { webhookUpdate(id: $id, input: $input) { @@ -960,10 +960,6 @@ export const UntypedModifyAppWebhookDocument = gql` } } ${UntypedWebhookDetailsFragmentFragmentDoc}`; - -export function useModifyAppWebhookMutation() { - return Urql.useMutation(UntypedModifyAppWebhookDocument); -}; export const UntypedRemoveAppWebhookDocument = gql` mutation RemoveAppWebhook($id: ID!) { webhookDelete(id: $id) { @@ -977,10 +973,6 @@ export const UntypedRemoveAppWebhookDocument = gql` } } `; - -export function useRemoveAppWebhookMutation() { - return Urql.useMutation(UntypedRemoveAppWebhookDocument); -}; export const UntypedGetAppDetailsAndWebhooksDataDocument = gql` query GetAppDetailsAndWebhooksData { app { @@ -993,10 +985,6 @@ export const UntypedGetAppDetailsAndWebhooksDataDocument = gql` } } ${UntypedWebhookDetailsFragmentFragmentDoc}`; - -export function useGetAppDetailsAndWebhooksDataQuery(options?: Omit, 'query'>) { - return Urql.useQuery({ query: UntypedGetAppDetailsAndWebhooksDataDocument, ...options }); -}; export const UntypedGetSaleorInstanceDataDocument = gql` query GetSaleorInstanceData { shop { @@ -1004,13 +992,21 @@ export const UntypedGetSaleorInstanceDataDocument = gql` } } `; - -export function useGetSaleorInstanceDataQuery(options?: Omit, 'query'>) { - return Urql.useQuery({ query: UntypedGetSaleorInstanceDataDocument, ...options }); -}; +export const UntypedAppDeletedDocument = gql` + subscription AppDeleted { + event { + ... on AppDeleted { + app { + id + } + } + } +} + `; export const WebhookDetailsFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WebhookDetailsFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Webhook"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"targetUrl"}},{"kind":"Field","name":{"kind":"Name","value":"subscriptionQuery"}},{"kind":"Field","name":{"kind":"Name","value":"syncEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"asyncEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}}]}}]}}]} as unknown as DocumentNode; export const CreateAppWebhookDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateAppWebhook"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WebhookCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"webhookCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"webhook"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WebhookDetailsFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"field"}},{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WebhookDetailsFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Webhook"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"targetUrl"}},{"kind":"Field","name":{"kind":"Name","value":"subscriptionQuery"}},{"kind":"Field","name":{"kind":"Name","value":"syncEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"asyncEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}}]}}]}}]} as unknown as DocumentNode; export const ModifyAppWebhookDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ModifyAppWebhook"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WebhookUpdateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"webhookUpdate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"Field","name":{"kind":"Name","value":"webhook"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WebhookDetailsFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WebhookDetailsFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Webhook"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"targetUrl"}},{"kind":"Field","name":{"kind":"Name","value":"subscriptionQuery"}},{"kind":"Field","name":{"kind":"Name","value":"syncEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"asyncEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}}]}}]}}]} as unknown as DocumentNode; export const RemoveAppWebhookDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveAppWebhook"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"webhookDelete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"webhook"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"field"}},{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetAppDetailsAndWebhooksDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppDetailsAndWebhooksData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"appUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"webhooks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WebhookDetailsFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WebhookDetailsFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Webhook"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"targetUrl"}},{"kind":"Field","name":{"kind":"Name","value":"subscriptionQuery"}},{"kind":"Field","name":{"kind":"Name","value":"syncEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"asyncEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}}]}}]}}]} as unknown as DocumentNode; -export const GetSaleorInstanceDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSaleorInstanceData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"shop"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const GetSaleorInstanceDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSaleorInstanceData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"shop"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]} as unknown as DocumentNode; +export const AppDeletedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"AppDeleted"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AppDeleted"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/packages/webhook-utils/graphql.config.ts b/packages/webhook-utils/graphql.config.ts index 283d4c56e..f79231181 100644 --- a/packages/webhook-utils/graphql.config.ts +++ b/packages/webhook-utils/graphql.config.ts @@ -53,6 +53,7 @@ const config: IGraphQLConfig = { "typescript-urql": { documentVariablePrefix: "Untyped", fragmentVariablePrefix: "Untyped", + withHooks: false, }, }, "typed-document-node", diff --git a/packages/webhook-utils/graphql/subscriptions/app-deleted.graphql b/packages/webhook-utils/graphql/subscriptions/app-deleted.graphql new file mode 100644 index 000000000..178fcedb6 --- /dev/null +++ b/packages/webhook-utils/graphql/subscriptions/app-deleted.graphql @@ -0,0 +1,9 @@ +subscription AppDeleted { + event { + ... on AppDeleted { + app { + id + } + } + } +} diff --git a/packages/webhook-utils/package.json b/packages/webhook-utils/package.json index bb91aff20..166b11042 100644 --- a/packages/webhook-utils/package.json +++ b/packages/webhook-utils/package.json @@ -2,6 +2,10 @@ "name": "@saleor/webhook-utils", "version": "0.2.10", "type": "module", + "exports": { + ".": "./index.ts", + "./app-deleted-handler": "./src/app-deleted-handler.ts" + }, "main": "index.ts", "scripts": { "check-types": "tsc", @@ -38,6 +42,7 @@ "vitest": "catalog:" }, "peerDependencies": { + "@saleor/apps-logger": "workspace:*", "next": "catalog:", "urql": "catalog:" } diff --git a/packages/webhook-utils/src/app-deleted-handler.ts b/packages/webhook-utils/src/app-deleted-handler.ts new file mode 100644 index 000000000..349fad5e7 --- /dev/null +++ b/packages/webhook-utils/src/app-deleted-handler.ts @@ -0,0 +1,64 @@ +import { type APL } from "@saleor/app-sdk/APL"; +import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next-app-router"; +import { type WebhookContext } from "@saleor/app-sdk/handlers/shared"; +import { type Logger } from "@saleor/apps-logger"; + +import { AppDeletedDocument } from "../generated/graphql"; + +type Params = { + apl: APL; + webhookPath: string; + logger: Logger; + hooks?: { + onEvent?: (ctx: WebhookContext) => Promise; + onAuthDataDeleted?: () => Promise; + onAuthDataDeleteError?: (e: Error) => Promise; + }; +}; + +/** + * TODO: + * 1. Move to app-sdk + * 2. Implement into non-monorepo apps + */ +export const createAppDeletedHandler = ({ apl, webhookPath, hooks = {}, logger }: Params) => { + const webhook = new SaleorAsyncWebhook({ + apl, + name: "APP_DELETED", + query: AppDeletedDocument, + event: "APP_DELETED", + isActive: true, + webhookPath, + }); + + const handler = webhook.createHandler(async (_req, ctx) => { + try { + logger.info("APP_DELETED event received. Auth Data will be removed"); + + await hooks.onEvent?.(ctx); + + try { + await apl.delete(ctx.authData.saleorApiUrl); + + await hooks.onAuthDataDeleted?.(); + + return new Response("ok", { status: 200 }); + } catch (e) { + logger.error("Error deleting auth data on APP_DELETED", { error: e }); + + await hooks.onAuthDataDeleteError?.(e as Error); + + return new Response('"Failed to clean up auth data."', { status: 500 }); + } + } catch (e) { + logger.error("Failed to execute APP_DELETED event", { error: e }); + + return new Response('"Failed to clean up auth data."', { status: 500 }); + } + }); + + return { + handler: handler.bind(webhook), + getWebhookManifest: webhook.getWebhookManifest.bind(webhook), + }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77c9ff6e0..77eb5bba5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -575,6 +575,9 @@ importers: '@saleor/sentry-utils': specifier: workspace:* version: link:../../packages/sentry-utils + '@saleor/webhook-utils': + specifier: workspace:* + version: link:../../packages/webhook-utils '@sentry/cli': specifier: 'catalog:' version: 1.77.3 @@ -780,6 +783,9 @@ importers: '@saleor/sentry-utils': specifier: workspace:* version: link:../../packages/sentry-utils + '@saleor/webhook-utils': + specifier: workspace:* + version: link:../../packages/webhook-utils '@sentry/cli': specifier: 'catalog:' version: 1.77.3 @@ -2912,6 +2918,9 @@ importers: packages/webhook-utils: dependencies: + '@saleor/apps-logger': + specifier: workspace:* + version: link:../logger graphql: specifier: 'catalog:' version: 16.7.1