Skip to content
Draft
Show file tree
Hide file tree
Changes from 11 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 .changeset/open-eels-yell.md
Original file line number Diff line number Diff line change
@@ -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
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct grammar in the changeset text: “clean up it's Auth Data” should use the possessive “its”.

Suggested change
App now will listen on APP_DELETED webhook and clean up it's Auth Data once removed
App now will listen on APP_DELETED webhook and clean up its Auth Data once removed

Copilot uses AI. Check for mistakes.
5 changes: 5 additions & 0 deletions .changeset/petite-days-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@saleor/webhook-utils": minor
---

Added APP_DELETED shared webhook that automatically cleans up APL
6 changes: 5 additions & 1 deletion apps/avatax/src/app/api/manifest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type AppExtension, 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 { getWebhookManifest as appDeletedWebhookManifest } from "@/app/api/webhooks/app-deleted/webhook-definition";
import { env } from "@/env";
import { withFlushOtelMetrics } from "@/lib/otel/with-flush-otel-metrics";
import { withLoggerContext } from "@/logger-context";
Expand Down Expand Up @@ -54,7 +55,10 @@ const handler = createManifestHandler({
supportUrl: "https://github.com/saleor/apps/discussions",
tokenTargetUrl: `${apiBaseURL}/api/register`,
version: packageJson.version,
webhooks: appWebhooks.map((w) => w.getWebhookManifest(apiBaseURL)),
webhooks: [
...appWebhooks.map((w) => w.getWebhookManifest(apiBaseURL)),
appDeletedWebhookManifest(apiBaseURL),
],
extensions,
};

Expand Down
3 changes: 3 additions & 0 deletions apps/avatax/src/app/api/webhooks/app-deleted/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { handler } from "./webhook-definition";

export const POST = handler;
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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";

const { handler, getWebhookManifest } = 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 });
},
Comment on lines +30 to +37
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

repo.pruneAllLogs returns a Result, but the returned value is ignored here. That means pruning failures will be silently swallowed and the webhook will continue (and still delete APL auth data), making cleanup unreliable and hard to diagnose. Handle the Result explicitly (log and/or throw on Err) so failures are observable and can fail the webhook when appropriate.

Copilot uses AI. Check for mistakes.
},
});

export { getWebhookManifest, handler };
4 changes: 4 additions & 0 deletions apps/avatax/src/modules/client-logs/dynamo-logs-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
119 changes: 119 additions & 0 deletions apps/avatax/src/modules/client-logs/logs-repository.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -38,6 +40,7 @@ export interface ILogsRepository {
saleorApiUrl: string;
appId: string;
}): Promise<Result<undefined, unknown>>;
pruneAllLogs(args: { saleorApiUrl: string }): Promise<Result<undefined, unknown>>;
}

/**
Expand Down Expand Up @@ -323,6 +326,108 @@ export class LogsRepositoryDynamodb implements ILogsRepository {

return ok(undefined);
}

async pruneAllLogs({
saleorApiUrl,
}: {
saleorApiUrl: string;
}): Promise<
Result<
undefined,
| InstanceType<typeof LogsRepositoryDynamodb.LogsFetchError>
| InstanceType<typeof LogsRepositoryDynamodb.WriteLogError>
| InstanceType<typeof LogsRepositoryDynamodb.UnprocessedItemsError>
>
> {
this.logger.debug("Starting pruning logs for saleorApiUrl", { saleorApiUrl });

const pkPrefix = `${saleorApiUrl}#`;

let lastEvaluatedKey: LastEvaluatedKey;
let deletedCount = 0;

do {
Comment on lines +330 to +349
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pruneAllLogs is a new public method on ILogsRepository / LogsRepositoryDynamodb, and this module already has unit tests (logs-repository.test.ts), but there are no tests validating pruning behavior (e.g. scan pagination, chunking into 25-item batch deletes, and handling of UnprocessedItems). Add unit tests (with DynamoDB toolbox client mocked) to cover at least the happy path and the UnprocessedItems error path.

Copilot uses AI. Check for mistakes.
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(),
Comment on lines +349 to +362
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pruneAllLogs() performs a full table Scan and filters by PK prefix. For a logs table that can grow large, this can be slow/expensive and may exceed webhook execution time limits during app deletion. Consider an access pattern that avoids scanning (e.g., a GSI partitioned by saleorApiUrl, or storing logs under a dedicated PK for saleorApiUrl so it can be queried and deleted in batches).

Copilot uses AI. Check for mistakes.
(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,
}),
Comment on lines +382 to +392
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pruneAllLogs() relies on item.entity to detect which entity a scanned item belongs to. In this table the entity attribute is _et (see LogsTable generic and existing tests asserting _et: "LOG_BY_DATE"/"LOG_BY_CHECKOUT_OR_ORDER_ID"), so this check will likely always fail and the wrong delete key shape will be built.

Copilot uses AI. Check for mistakes.
);

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);
}
}

/**
Expand Down Expand Up @@ -376,4 +481,18 @@ export class LogsRepositoryMemory implements ILogsRepository {
}): Promise<Result<{ clientLogs: ClientLog[]; lastEvaluatedKey: LastEvaluatedKey }, never>> {
return ok({ clientLogs: this.logs, lastEvaluatedKey: undefined });
}

async pruneAllLogs(args: {
saleorApiUrl: string;
appId: string;
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LogsRepositoryMemory.pruneAllLogs() requires { saleorApiUrl, appId }, but ILogsRepository.pruneAllLogs() is declared as { saleorApiUrl }. Even if this currently compiles, it’s inconsistent and can break callers that use the interface type; align the method signature with the interface (and drop appId if it’s not needed).

Suggested change
appId: string;

Copilot uses AI. Check for mistakes.
}): Promise<Result<undefined, unknown>> {
this.logs = this.logs.filter((l) => {
const log = l.getValue();
const [saleorApiUrl] = LogsTable.decomposePrimaryKey(log.id);

return args.saleorApiUrl !== saleorApiUrl;
Comment on lines +491 to +493
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LogsRepositoryMemory.pruneAllLogs() calls LogsTable.decomposePrimaryKey(log.id), but ClientLog.id is an ULID (see LogsTransformer mapping id: entity.ulid). Splitting the ULID by # won’t yield saleorApiUrl, so this prune will never remove any logs.

Suggested change
const [saleorApiUrl] = LogsTable.decomposePrimaryKey(log.id);
return args.saleorApiUrl !== saleorApiUrl;
return args.saleorApiUrl !== log.saleorApiUrl;

Copilot uses AI. Check for mistakes.
});
Comment on lines +485 to +494
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LogsRepositoryMemory.pruneAllLogs has a signature that doesn't match ILogsRepository (it requires appId, while the interface does not), which will break TypeScript compilation. Also, the pruning logic splits log.id using LogsTable.decomposePrimaryKey, but ClientLog.id is just a ULID (see LogsTransformer mapping), so this filter will never match and logs won't be pruned. Align the method signature with the interface and store enough context in the memory repo (e.g. persist PK / saleorApiUrl alongside the log) to make pruning work.

Copilot uses AI. Check for mistakes.

return ok(undefined);
}
}
3 changes: 2 additions & 1 deletion apps/klaviyo/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />

// 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.
1 change: 1 addition & 0 deletions apps/klaviyo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
3 changes: 3 additions & 0 deletions apps/klaviyo/src/app/api/webhooks/app-deleted/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { handler } from "./webhook-definition";

export const POST = handler;
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createAppDeletedHandler } from "@saleor/webhook-utils/app-deleted-handler";

import { saleorApp } from "../../../../../saleor-app";
import { createLogger } from "../../../../logger";

const { handler, getWebhookManifest } = createAppDeletedHandler({
apl: saleorApp.apl,
logger: createLogger("APP_DELETED handler"),
webhookPath: "api/webhooks/app-deleted",
});

export { getWebhookManifest, handler };
4 changes: 3 additions & 1 deletion apps/klaviyo/src/pages/api/manifest.ts
Original file line number Diff line number Diff line change
@@ -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 { getWebhookManifest as getAppDeletedWebhookManifest } from "../../app/api/webhooks/app-deleted/webhook-definition";
import { env } from "../../env";
import { loggerContext } from "../../logger-context";
import { customerCreatedWebhook } from "./webhooks/customer-created";
Expand Down Expand Up @@ -41,6 +42,7 @@ const handler = wrapWithLoggerContext(
fulfillmentCreatedWebhook.getWebhookManifest(appBaseUrl),
orderCreatedWebhook.getWebhookManifest(appBaseUrl),
orderFullyPaidWebhook.getWebhookManifest(appBaseUrl),
getAppDeletedWebhookManifest(appBaseUrl),
],
};
},
Expand Down
20 changes: 17 additions & 3 deletions apps/klaviyo/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
Loading
Loading