Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 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
44 changes: 44 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,44 @@
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 }) {
// todo share factory
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 });
},
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.

The pruneAllLogs() call returns a Result, but it’s awaited and ignored. If pruning fails, it will be silently skipped (and because it doesn’t throw, the shared handler will continue). Handle the Result explicitly (log on error or convert to a thrown error if you want the webhook to fail).

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

export { getWebhookManifest };

export const POST = 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
14 changes: 14 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,14 @@
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 };

Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

This API route is missing export const config = { api: { bodyParser: false } }, which all other Saleor webhook routes in this app export. Without disabling Next.js body parsing, webhook signature verification can break because the raw request body is no longer available.

Suggested change
export const config = {
api: {
bodyParser: false,
},
};

Copilot uses AI. Check for mistakes.
export const POST = 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/route";
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