From c088d5eea7ce70596cfbf3de2afaace316c2056f Mon Sep 17 00:00:00 2001 From: KONFeature Date: Tue, 29 Oct 2024 17:19:44 +0100 Subject: [PATCH 01/13] =?UTF-8?q?=F0=9F=9A=A7=20Startup=20WooCommerce=20ho?= =?UTF-8?q?ok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/oracle/dto/WooCommerceWebhook.ts | 35 ++++ .../backend-elysia/src/domain/oracle/index.ts | 4 +- .../domain/oracle/routes/shopifyWebhook.ts | 8 + .../oracle/routes/wooCommerceWebhook.ts | 175 ++++++++++++++++++ 4 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 packages/backend-elysia/src/domain/oracle/dto/WooCommerceWebhook.ts create mode 100644 packages/backend-elysia/src/domain/oracle/routes/wooCommerceWebhook.ts diff --git a/packages/backend-elysia/src/domain/oracle/dto/WooCommerceWebhook.ts b/packages/backend-elysia/src/domain/oracle/dto/WooCommerceWebhook.ts new file mode 100644 index 000000000..49c491bfe --- /dev/null +++ b/packages/backend-elysia/src/domain/oracle/dto/WooCommerceWebhook.ts @@ -0,0 +1,35 @@ +export type WooCommerceOrderUpdateWebhookDto = Readonly<{ + id: number; // order_id + status: WooCommerceOrderStatus; // The financial status of the order (could include "paid", "refunded", etc.) + total: string; // Total price of the order + currency: string; // Currency code (ISO 4217) + date_created_gmt: string; // The creation date of the order + date_modified_gmt?: string; // The date when the order was last updated + date_completed_gmt?: string; // The date when the order was last updated + date_paid_gmt?: string; // The date when the order was last updated + customer_id: number; // The customer id + order_key: string; // The key of the order + transaction_id: string; // The id of the transaction + line_items: { + id: number; // The product id + product_id: number; // The product id + quantity: number; // The quantity of the product + price: number; // The price of the product + name: string; // The name of the product + image: { + id?: string; + src?: string; + }; + }[]; +}>; + +export type WooCommerceOrderStatus = + | "pending" + | "processing" + | "on-hold" + | "completed" + | "canncelled" + | "refunded" + | "failed" + | "trash" + | (string & {}); diff --git a/packages/backend-elysia/src/domain/oracle/index.ts b/packages/backend-elysia/src/domain/oracle/index.ts index 91c1d9a08..501f78bb8 100644 --- a/packages/backend-elysia/src/domain/oracle/index.ts +++ b/packages/backend-elysia/src/domain/oracle/index.ts @@ -4,10 +4,12 @@ import { updateMerkleRootJob } from "./jobs/updateOrale"; import { managmentRoutes } from "./routes/managment"; import { proofRoutes } from "./routes/proof"; import { shopifyWebhook } from "./routes/shopifyWebhook"; +import { wooCommerceWebhook } from "./routes/wooCommerceWebhook"; export const oracle = new Elysia({ prefix: "/oracle" }) - .use(managmentRoutes) .use(shopifyWebhook) + .use(wooCommerceWebhook) + .use(managmentRoutes) .use(updateMerkleRootJob) .use(proofRoutes) .get( diff --git a/packages/backend-elysia/src/domain/oracle/routes/shopifyWebhook.ts b/packages/backend-elysia/src/domain/oracle/routes/shopifyWebhook.ts index a86bfd9fd..682e957cc 100644 --- a/packages/backend-elysia/src/domain/oracle/routes/shopifyWebhook.ts +++ b/packages/backend-elysia/src/domain/oracle/routes/shopifyWebhook.ts @@ -26,6 +26,14 @@ export const shopifyWebhook = new Elysia({ prefix: "/shopify" }) ); return new Response("ko", { status: 200 }); }) + .mapResponse(({ response }) => { + if ("code" in response && response.code !== 200) { + log.error({ response }, "Error while handling shopify webhook"); + return new Response("ko", { status: 200 }); + } + + return response; + }) .guard({ headers: t.Partial( t.Object({ diff --git a/packages/backend-elysia/src/domain/oracle/routes/wooCommerceWebhook.ts b/packages/backend-elysia/src/domain/oracle/routes/wooCommerceWebhook.ts new file mode 100644 index 000000000..770fa1bcf --- /dev/null +++ b/packages/backend-elysia/src/domain/oracle/routes/wooCommerceWebhook.ts @@ -0,0 +1,175 @@ +import { log } from "@backend-common"; +import { t } from "@backend-utils"; +import { eq } from "drizzle-orm"; +import { Elysia } from "elysia"; +import { concatHex, keccak256, toHex } from "viem"; +import { oracleContext } from "../context"; +import { + productOracleTable, + purchaseItemTable, + type purchaseStatusEnum, + purchaseStatusTable, +} from "../db/schema"; +import type { + WooCommerceOrderStatus, + WooCommerceOrderUpdateWebhookDto, +} from "../dto/WooCommerceWebhook"; + +export const wooCommerceWebhook = new Elysia({ prefix: "/wooCommerce" }) + .use(oracleContext) + // Error failsafe, to never fail on shopify webhook + .onError(({ error, code, body, path, headers }) => { + log.error( + { error, code, reqPath: path, reqBody: body, reqHeaders: headers }, + "Error while handling woo commerce webhook" + ); + return new Response("ko", { status: 200 }); + }) + .mapResponse(({ response }) => { + if ("code" in response && response.code !== 200) { + log.error({ response }, "Error while handling WooCommerce webhook"); + return new Response("ko", { status: 200 }); + } + + return response; + }) + // .guard({ + // headers: t.Partial( + // t.Object({ + // "X-WC-Webhook-Source": t.String(), + // "X-WC-Webhook-Topic": t.String(), + // "X-WC-Webhook-Resource": t.String(), + // "X-WC-Webhook-Event": t.String(), + // "X-WC-Webhook-Signature": t.String(), + // "X-WC-Webhook-ID": t.String(), + // "X-WC-Webhook-Delivery-ID": t.String(), + // }) + // ), + // }) + // Request pre validation hook + .onBeforeHandle(({ headers, error, body, path }) => { + log.info( + { + headers, + signature: headers["X-WC-Webhook-Signature"] ?? "none", + resource: headers["X-WC-Webhook-Resource"] ?? "none", + body, + bodyType: typeof body, + path, + }, + "WooCommerce on before handle" + ); + // if (!headers["X-WC-Webhook-Signature"]) { + // return error(400, "Missing signature"); + // } + // if (!headers["X-WC-Webhook-Resource"]?.startsWith("orders/")) { + // return error(400, "Unsupported woo commerce resource"); + // } + }) + // Shopify only give us 5sec to answer, all the heavy logic should be in a cron running elsewhere, + // here we should just validate the request and save it + .post( + ":productId/hook", + async ({ params: { productId }, body, headers, oracleDb, error }) => { + log.debug( + { + productId, + body, + headers, + bodyType: typeof body, + }, + "WooCommerce hooks yougouuu" + ); + + // Try to parse the body as a shopify webhook type and ensure the type validity + const webhookData = body as WooCommerceOrderUpdateWebhookDto; + + // Find the product oracle for this product id + if (!productId) { + return error(400, "Missing product id"); + } + const oracle = await oracleDb.query.productOracleTable.findFirst({ + where: eq(productOracleTable.productId, productId), + }); + if (!oracle) { + return error(404, "Product oracle not found"); + } + + // Prebuild some data before insert + const purchaseStatus = mapOrderStatus(webhookData.status); + const purchaseId = keccak256( + concatHex([oracle.productId, toHex(webhookData.id)]) + ); + + // Insert purchase and items + await oracleDb.transaction(async (trx) => { + // Insert the purchase first + await trx + .insert(purchaseStatusTable) + .values({ + oracleId: oracle.id, + purchaseId, + externalId: webhookData.id.toString(), + externalCustomerId: webhookData.customer_id.toString(), + purchaseToken: + webhookData.order_key ?? webhookData.transaction_id, + status: purchaseStatus, + totalPrice: webhookData.total, + currencyCode: webhookData.currency, + }) + .onConflictDoUpdate({ + target: [purchaseStatusTable.purchaseId], + set: { + status: purchaseStatus, + totalPrice: webhookData.total, + currencyCode: webhookData.currency, + updatedAt: new Date(), + ...(webhookData.order_key + ? { + purchaseToken: webhookData.order_key, + } + : {}), + }, + }); + // Insert the items if needed + if (webhookData.line_items.length === 0) { + return; + } + const mappedItems = webhookData.line_items.map((item) => ({ + purchaseId, + externalId: item.product_id.toString(), + price: item.price.toString(), + name: item.name, + title: item.name, + quantity: item.quantity, + })); + await trx.insert(purchaseItemTable).values(mappedItems); + }); + + // Return the success state + return "ok"; + }, + { + response: { + 200: t.String(), + 400: t.String(), + 404: t.String(), + }, + } + ); + +function mapOrderStatus( + orderStatus: WooCommerceOrderStatus +): (typeof purchaseStatusEnum.enumValues)[number] { + if (orderStatus === "confirmed") { + return "confirmed"; + } + if (orderStatus === "refunded") { + return "refunded"; + } + if (orderStatus === "cancelled") { + return "cancelled"; + } + + return "pending"; +} From 81aeccdac8ce566863dab89f44df9eadd7252ee3 Mon Sep 17 00:00:00 2001 From: KONFeature Date: Tue, 29 Oct 2024 18:37:15 +0100 Subject: [PATCH 02/13] =?UTF-8?q?=F0=9F=90=9B=20A=20few=20woo=20commerce?= =?UTF-8?q?=20hook=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oracle/routes/wooCommerceWebhook.ts | 80 ++++++++++--------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/packages/backend-elysia/src/domain/oracle/routes/wooCommerceWebhook.ts b/packages/backend-elysia/src/domain/oracle/routes/wooCommerceWebhook.ts index 770fa1bcf..7e2afe025 100644 --- a/packages/backend-elysia/src/domain/oracle/routes/wooCommerceWebhook.ts +++ b/packages/backend-elysia/src/domain/oracle/routes/wooCommerceWebhook.ts @@ -18,9 +18,16 @@ import type { export const wooCommerceWebhook = new Elysia({ prefix: "/wooCommerce" }) .use(oracleContext) // Error failsafe, to never fail on shopify webhook - .onError(({ error, code, body, path, headers }) => { + .onError(({ error, code, body, path, headers, response }) => { log.error( - { error, code, reqPath: path, reqBody: body, reqHeaders: headers }, + { + error, + code, + reqPath: path, + reqBody: body, + reqHeaders: headers, + response, + }, "Error while handling woo commerce webhook" ); return new Response("ko", { status: 200 }); @@ -33,38 +40,27 @@ export const wooCommerceWebhook = new Elysia({ prefix: "/wooCommerce" }) return response; }) - // .guard({ - // headers: t.Partial( - // t.Object({ - // "X-WC-Webhook-Source": t.String(), - // "X-WC-Webhook-Topic": t.String(), - // "X-WC-Webhook-Resource": t.String(), - // "X-WC-Webhook-Event": t.String(), - // "X-WC-Webhook-Signature": t.String(), - // "X-WC-Webhook-ID": t.String(), - // "X-WC-Webhook-Delivery-ID": t.String(), - // }) - // ), - // }) + .guard({ + headers: t.Partial( + t.Object({ + "x-wc-webhook-source": t.String(), + "x-wc-webhook-topic": t.String(), + "x-wc-webhook-resource": t.String(), + "x-wc-webhook-event": t.String(), + "x-wc-webhook-signature": t.String(), + "x-wc-webhook-id": t.String(), + "x-wc-webhook-delivery-id": t.String(), + }) + ), + }) // Request pre validation hook - .onBeforeHandle(({ headers, error, body, path }) => { - log.info( - { - headers, - signature: headers["X-WC-Webhook-Signature"] ?? "none", - resource: headers["X-WC-Webhook-Resource"] ?? "none", - body, - bodyType: typeof body, - path, - }, - "WooCommerce on before handle" - ); - // if (!headers["X-WC-Webhook-Signature"]) { - // return error(400, "Missing signature"); - // } - // if (!headers["X-WC-Webhook-Resource"]?.startsWith("orders/")) { - // return error(400, "Unsupported woo commerce resource"); - // } + .onBeforeHandle(({ headers, error }) => { + if (!headers["x-wc-webhook-signature"]) { + return error(400, "Missing signature"); + } + if (headers["x-wc-webhook-resource"] !== "order") { + return error(400, "Unsupported woo commerce webhook"); + } }) // Shopify only give us 5sec to answer, all the heavy logic should be in a cron running elsewhere, // here we should just validate the request and save it @@ -76,9 +72,8 @@ export const wooCommerceWebhook = new Elysia({ prefix: "/wooCommerce" }) productId, body, headers, - bodyType: typeof body, }, - "WooCommerce hooks yougouuu" + "WooCommerce inner hook" ); // Try to parse the body as a shopify webhook type and ensure the type validity @@ -145,6 +140,19 @@ export const wooCommerceWebhook = new Elysia({ prefix: "/wooCommerce" }) })); await trx.insert(purchaseItemTable).values(mappedItems); }); + log.debug( + { + purchaseId, + purchaseStatus, + externalId: webhookData.id.toString(), + externalCustomerId: webhookData.customer_id.toString(), + purchaseToken: + webhookData.order_key ?? webhookData.transaction_id, + totalPrice: webhookData.total, + currencyCode: webhookData.currency, + }, + "WooCommerce Purchase inserted" + ); // Return the success state return "ok"; @@ -161,7 +169,7 @@ export const wooCommerceWebhook = new Elysia({ prefix: "/wooCommerce" }) function mapOrderStatus( orderStatus: WooCommerceOrderStatus ): (typeof purchaseStatusEnum.enumValues)[number] { - if (orderStatus === "confirmed") { + if (orderStatus === "completed") { return "confirmed"; } if (orderStatus === "refunded") { From eb011b2bef195f27b5dbce3ec6aa8ac57be45f5d Mon Sep 17 00:00:00 2001 From: KONFeature Date: Tue, 29 Oct 2024 20:26:45 +0100 Subject: [PATCH 03/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Share=20some=20webho?= =?UTF-8?q?ok=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/domain/oracle/db/schema.ts | 4 + .../backend-elysia/src/domain/oracle/index.ts | 4 +- .../routes/{ => webhook}/shopifyWebhook.ts | 72 ++++++---------- .../{ => webhook}/wooCommerceWebhook.ts | 83 ++++++------------- .../src/domain/oracle/services/hookService.ts | 64 ++++++++++++++ 5 files changed, 121 insertions(+), 106 deletions(-) rename packages/backend-elysia/src/domain/oracle/routes/{ => webhook}/shopifyWebhook.ts (71%) rename packages/backend-elysia/src/domain/oracle/routes/{ => webhook}/wooCommerceWebhook.ts (67%) create mode 100644 packages/backend-elysia/src/domain/oracle/services/hookService.ts diff --git a/packages/backend-elysia/src/domain/oracle/db/schema.ts b/packages/backend-elysia/src/domain/oracle/db/schema.ts index e26272ff5..0e91e295c 100644 --- a/packages/backend-elysia/src/domain/oracle/db/schema.ts +++ b/packages/backend-elysia/src/domain/oracle/db/schema.ts @@ -101,5 +101,9 @@ export const purchaseItemTable = pgTable( }, (table) => ({ purchaseIdIdx: index("item_purchase_id_idx").on(table.purchaseId), + externalIdIdx: uniqueIndex("unique_external_id").on( + table.externalId, + table.purchaseId + ), }) ); diff --git a/packages/backend-elysia/src/domain/oracle/index.ts b/packages/backend-elysia/src/domain/oracle/index.ts index 501f78bb8..d1aad2d5d 100644 --- a/packages/backend-elysia/src/domain/oracle/index.ts +++ b/packages/backend-elysia/src/domain/oracle/index.ts @@ -3,8 +3,8 @@ import { Elysia } from "elysia"; import { updateMerkleRootJob } from "./jobs/updateOrale"; import { managmentRoutes } from "./routes/managment"; import { proofRoutes } from "./routes/proof"; -import { shopifyWebhook } from "./routes/shopifyWebhook"; -import { wooCommerceWebhook } from "./routes/wooCommerceWebhook"; +import { shopifyWebhook } from "./routes/webhook/shopifyWebhook"; +import { wooCommerceWebhook } from "./routes/webhook/wooCommerceWebhook"; export const oracle = new Elysia({ prefix: "/oracle" }) .use(shopifyWebhook) diff --git a/packages/backend-elysia/src/domain/oracle/routes/shopifyWebhook.ts b/packages/backend-elysia/src/domain/oracle/routes/webhook/shopifyWebhook.ts similarity index 71% rename from packages/backend-elysia/src/domain/oracle/routes/shopifyWebhook.ts rename to packages/backend-elysia/src/domain/oracle/routes/webhook/shopifyWebhook.ts index 682e957cc..12e746214 100644 --- a/packages/backend-elysia/src/domain/oracle/routes/shopifyWebhook.ts +++ b/packages/backend-elysia/src/domain/oracle/routes/webhook/shopifyWebhook.ts @@ -4,20 +4,15 @@ import { isRunningInProd } from "@frak-labs/app-essentials"; import { eq } from "drizzle-orm"; import { Elysia } from "elysia"; import { concatHex, keccak256, toHex } from "viem"; -import { oracleContext } from "../context"; -import { - productOracleTable, - purchaseItemTable, - type purchaseStatusEnum, - purchaseStatusTable, -} from "../db/schema"; +import { productOracleTable, type purchaseStatusEnum } from "../../db/schema"; import type { OrderFinancialStatus, ShopifyOrderUpdateWebhookDto, -} from "../dto/ShopifyWebhook"; +} from "../../dto/ShopifyWebhook"; +import { purchaseWebhookService } from "../../services/hookService"; export const shopifyWebhook = new Elysia({ prefix: "/shopify" }) - .use(oracleContext) + .use(purchaseWebhookService) // Error failsafe, to never fail on shopify webhook .onError(({ error, code, body, path, headers }) => { log.error( @@ -69,7 +64,14 @@ export const shopifyWebhook = new Elysia({ prefix: "/shopify" }) // here we should just validate the request and save it .post( ":productId/hook", - async ({ params: { productId }, body, headers, oracleDb, error }) => { + async ({ + params: { productId }, + body, + headers, + oracleDb, + upsertPurchase, + error, + }) => { // todo: hmac validation in the `onParse` hook? https://shopify.dev/docs/apps/build/webhooks/subscribe/https#step-2-validate-the-origin-of-your-webhook-to-ensure-its-coming-from-shopify // Try to parse the body as a shopify webhook type and ensure the type validity @@ -117,48 +119,26 @@ export const shopifyWebhook = new Elysia({ prefix: "/shopify" }) ); // Insert purchase and items - await oracleDb.transaction(async (trx) => { - // Insert the purchase first - await trx - .insert(purchaseStatusTable) - .values({ - oracleId: oracle.id, - purchaseId, - externalId: webhookData.id.toString(), - externalCustomerId: webhookData.customer.id.toString(), - purchaseToken: - webhookData.checkout_token ?? webhookData.token, - status: purchaseStatus, - totalPrice: webhookData.total_price, - currencyCode: webhookData.currency, - }) - .onConflictDoUpdate({ - target: [purchaseStatusTable.purchaseId], - set: { - status: purchaseStatus, - totalPrice: webhookData.total_price, - currencyCode: webhookData.currency, - updatedAt: new Date(), - ...(webhookData.checkout_token - ? { - purchaseToken: webhookData.checkout_token, - } - : {}), - }, - }); - // Insert the items if needed - if (webhookData.line_items.length === 0) { - return; - } - const mappedItems = webhookData.line_items.map((item) => ({ + await upsertPurchase({ + purchase: { + oracleId: oracle.id, + purchaseId, + externalId: webhookData.id.toString(), + externalCustomerId: webhookData.customer.id.toString(), + purchaseToken: + webhookData.checkout_token ?? webhookData.token, + status: purchaseStatus, + totalPrice: webhookData.total_price, + currencyCode: webhookData.currency, + }, + purchaseItems: webhookData.line_items.map((item) => ({ purchaseId, externalId: item.product_id.toString(), price: item.price, name: item.name, title: item.title, quantity: item.quantity, - })); - await trx.insert(purchaseItemTable).values(mappedItems); + })), }); // Return the success state diff --git a/packages/backend-elysia/src/domain/oracle/routes/wooCommerceWebhook.ts b/packages/backend-elysia/src/domain/oracle/routes/webhook/wooCommerceWebhook.ts similarity index 67% rename from packages/backend-elysia/src/domain/oracle/routes/wooCommerceWebhook.ts rename to packages/backend-elysia/src/domain/oracle/routes/webhook/wooCommerceWebhook.ts index 7e2afe025..a12dd0b03 100644 --- a/packages/backend-elysia/src/domain/oracle/routes/wooCommerceWebhook.ts +++ b/packages/backend-elysia/src/domain/oracle/routes/webhook/wooCommerceWebhook.ts @@ -3,20 +3,15 @@ import { t } from "@backend-utils"; import { eq } from "drizzle-orm"; import { Elysia } from "elysia"; import { concatHex, keccak256, toHex } from "viem"; -import { oracleContext } from "../context"; -import { - productOracleTable, - purchaseItemTable, - type purchaseStatusEnum, - purchaseStatusTable, -} from "../db/schema"; +import { productOracleTable, type purchaseStatusEnum } from "../../db/schema"; import type { WooCommerceOrderStatus, WooCommerceOrderUpdateWebhookDto, -} from "../dto/WooCommerceWebhook"; +} from "../../dto/WooCommerceWebhook"; +import { purchaseWebhookService } from "../../services/hookService"; export const wooCommerceWebhook = new Elysia({ prefix: "/wooCommerce" }) - .use(oracleContext) + .use(purchaseWebhookService) // Error failsafe, to never fail on shopify webhook .onError(({ error, code, body, path, headers, response }) => { log.error( @@ -66,7 +61,14 @@ export const wooCommerceWebhook = new Elysia({ prefix: "/wooCommerce" }) // here we should just validate the request and save it .post( ":productId/hook", - async ({ params: { productId }, body, headers, oracleDb, error }) => { + async ({ + params: { productId }, + body, + headers, + oracleDb, + upsertPurchase, + error, + }) => { log.debug( { productId, @@ -97,62 +99,27 @@ export const wooCommerceWebhook = new Elysia({ prefix: "/wooCommerce" }) ); // Insert purchase and items - await oracleDb.transaction(async (trx) => { - // Insert the purchase first - await trx - .insert(purchaseStatusTable) - .values({ - oracleId: oracle.id, - purchaseId, - externalId: webhookData.id.toString(), - externalCustomerId: webhookData.customer_id.toString(), - purchaseToken: - webhookData.order_key ?? webhookData.transaction_id, - status: purchaseStatus, - totalPrice: webhookData.total, - currencyCode: webhookData.currency, - }) - .onConflictDoUpdate({ - target: [purchaseStatusTable.purchaseId], - set: { - status: purchaseStatus, - totalPrice: webhookData.total, - currencyCode: webhookData.currency, - updatedAt: new Date(), - ...(webhookData.order_key - ? { - purchaseToken: webhookData.order_key, - } - : {}), - }, - }); - // Insert the items if needed - if (webhookData.line_items.length === 0) { - return; - } - const mappedItems = webhookData.line_items.map((item) => ({ + await upsertPurchase({ + purchase: { + oracleId: oracle.id, purchaseId, - externalId: item.product_id.toString(), - price: item.price.toString(), - name: item.name, - title: item.name, - quantity: item.quantity, - })); - await trx.insert(purchaseItemTable).values(mappedItems); - }); - log.debug( - { - purchaseId, - purchaseStatus, externalId: webhookData.id.toString(), externalCustomerId: webhookData.customer_id.toString(), purchaseToken: webhookData.order_key ?? webhookData.transaction_id, + status: purchaseStatus, totalPrice: webhookData.total, currencyCode: webhookData.currency, }, - "WooCommerce Purchase inserted" - ); + purchaseItems: webhookData.line_items.map((item) => ({ + purchaseId, + externalId: item.product_id.toString(), + price: item.price.toString(), + name: item.name, + title: item.name, + quantity: item.quantity, + })), + }); // Return the success state return "ok"; diff --git a/packages/backend-elysia/src/domain/oracle/services/hookService.ts b/packages/backend-elysia/src/domain/oracle/services/hookService.ts new file mode 100644 index 000000000..9446f055e --- /dev/null +++ b/packages/backend-elysia/src/domain/oracle/services/hookService.ts @@ -0,0 +1,64 @@ +import { log } from "@backend-common"; +import Elysia from "elysia"; +import { oracleContext } from "../context"; +import { purchaseItemTable, purchaseStatusTable } from "../db/schema"; + +export const purchaseWebhookService = new Elysia({ + name: "Service.PurchaseWebhook", +}) + .use(oracleContext) + .decorate(({ oracleDb, ...decorators }) => { + /** + * Upsert a purchase in the database + * @returns + */ + async function upsertPurchase({ + purchase, + purchaseItems, + }: { + purchase: typeof purchaseStatusTable.$inferInsert; + purchaseItems: (typeof purchaseItemTable.$inferInsert)[]; + }) { + const dbId = await oracleDb.transaction(async (trx) => { + // Insert the purchase first + const insertedId = await trx + .insert(purchaseStatusTable) + .values(purchase) + .onConflictDoUpdate({ + target: [purchaseStatusTable.purchaseId], + set: { + status: purchase.status, + totalPrice: purchase.totalPrice, + currencyCode: purchase.currencyCode, + updatedAt: new Date(), + ...(purchase.purchaseToken + ? { + purchaseToken: purchase.purchaseToken, + } + : {}), + }, + }) + .returning({ purchaseId: purchaseStatusTable.id }); + + // Insert the items if needed + if (purchaseItems.length > 0) { + await trx + .insert(purchaseItemTable) + .values(purchaseItems) + .onConflictDoNothing(); + } + + return insertedId; + }); + log.debug( + { purchase, purchaseItems, insertedId: dbId }, + "Purchase upserted" + ); + } + + return { + ...decorators, + oracleDb, + upsertPurchase, + }; + }); From 4c833b05e3284a7a4067a56b9e1ee26be272b927 Mon Sep 17 00:00:00 2001 From: KONFeature Date: Tue, 29 Oct 2024 21:41:42 +0100 Subject: [PATCH 04/13] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20Add=20webhook=20h?= =?UTF-8?q?mac=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drizzle/dev/0005_bumpy_odin.sql | 10 + .../drizzle/dev/meta/0005_snapshot.json | 802 ++++++++++++++++++ .../drizzle/dev/meta/_journal.json | 7 + .../src/domain/oracle/db/schema.ts | 2 +- .../oracle/routes/webhook/shopifyWebhook.ts | 28 +- .../routes/webhook/wooCommerceWebhook.ts | 42 +- .../src/domain/oracle/services/hookService.ts | 47 + 7 files changed, 900 insertions(+), 38 deletions(-) create mode 100644 packages/backend-elysia/drizzle/dev/0005_bumpy_odin.sql create mode 100644 packages/backend-elysia/drizzle/dev/meta/0005_snapshot.json diff --git a/packages/backend-elysia/drizzle/dev/0005_bumpy_odin.sql b/packages/backend-elysia/drizzle/dev/0005_bumpy_odin.sql new file mode 100644 index 000000000..173f56f20 --- /dev/null +++ b/packages/backend-elysia/drizzle/dev/0005_bumpy_odin.sql @@ -0,0 +1,10 @@ +-- Drop items that would conflict with the created index +DELETE FROM "product_oracle_purchase_item" +WHERE id NOT IN ( + SELECT MIN(id) + FROM "product_oracle_purchase_item" + GROUP BY "external_id", "purchase_id" +); + +-- Create the index +CREATE UNIQUE INDEX IF NOT EXISTS "unique_external_purchase_item_id" ON "product_oracle_purchase_item" USING btree ("external_id","purchase_id"); \ No newline at end of file diff --git a/packages/backend-elysia/drizzle/dev/meta/0005_snapshot.json b/packages/backend-elysia/drizzle/dev/meta/0005_snapshot.json new file mode 100644 index 000000000..dff0db58c --- /dev/null +++ b/packages/backend-elysia/drizzle/dev/meta/0005_snapshot.json @@ -0,0 +1,802 @@ +{ + "id": "1b47cbcd-6d4e-4182-8ca5-f07fffe8c31e", + "prevId": "0c1813b9-1fd3-4fba-8c93-f47b3e8a8318", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.sso_session": { + "name": "sso_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "sso_id": { + "name": "sso_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "consume_key": { + "name": "consume_key", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "authenticator_id": { + "name": "authenticator_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_idx": { + "name": "sso_idx", + "columns": [ + { + "expression": "sso_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_product_idx": { + "name": "sso_product_idx", + "columns": [ + { + "expression": "sso_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.interactions_purchase_tracker": { + "name": "interactions_purchase_tracker", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "external_purchase_id": { + "name": "external_purchase_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "external_customer_id": { + "name": "external_customer_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pushed": { + "name": "pushed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_interactions_purchase_map_idx": { + "name": "wallet_interactions_purchase_map_idx", + "columns": [ + { + "expression": "wallet", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_map_idx": { + "name": "unique_map_idx", + "nullsNotDistinct": false, + "columns": ["external_purchase_id", "external_customer_id"] + } + } + }, + "public.interactions_pending": { + "name": "interactions_pending", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "type_denominator": { + "name": "type_denominator", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "interaction_data": { + "name": "interaction_data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "simulation_status": { + "name": "simulation_status", + "type": "interactions_simulation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": { + "wallet_pending_interactions_idx": { + "name": "wallet_pending_interactions_idx", + "columns": [ + { + "expression": "wallet", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_idx": { + "name": "product_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.interactions_pushed": { + "name": "interactions_pushed", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "type_denominator": { + "name": "type_denominator", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "interaction_data": { + "name": "interaction_data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_pushed_interactions_idx": { + "name": "wallet_pushed_interactions_idx", + "columns": [ + { + "expression": "wallet", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.push_tokens": { + "name": "push_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key_p256dh": { + "name": "key_p256dh", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key_auth": { + "name": "key_auth", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expire_at": { + "name": "expire_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_push_tokens_idx": { + "name": "wallet_push_tokens_idx", + "columns": [ + { + "expression": "wallet", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_push_token": { + "name": "unique_push_token", + "nullsNotDistinct": false, + "columns": ["wallet", "endpoint", "key_p256dh"] + } + } + }, + "public.product_oracle": { + "name": "product_oracle", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "hook_signature_key": { + "name": "hook_signature_key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "merkle_root": { + "name": "merkle_root", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "synced": { + "name": "synced", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "last_sync_tx_hash": { + "name": "last_sync_tx_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_product_id": { + "name": "unique_product_id", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "product_oracle_product_id_unique": { + "name": "product_oracle_product_id_unique", + "nullsNotDistinct": false, + "columns": ["product_id"] + } + } + }, + "public.product_oracle_purchase_item": { + "name": "product_oracle_purchase_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "purchase_id": { + "name": "purchase_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "item_purchase_id_idx": { + "name": "item_purchase_id_idx", + "columns": [ + { + "expression": "purchase_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_external_purchase_item_id": { + "name": "unique_external_purchase_item_id", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purchase_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_oracle_purchase_item_purchase_id_product_oracle_purchase_purchase_id_fk": { + "name": "product_oracle_purchase_item_purchase_id_product_oracle_purchase_purchase_id_fk", + "tableFrom": "product_oracle_purchase_item", + "tableTo": "product_oracle_purchase", + "columnsFrom": ["purchase_id"], + "columnsTo": ["purchase_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.product_oracle_purchase": { + "name": "product_oracle_purchase", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "oracle_id": { + "name": "oracle_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purchase_id": { + "name": "purchase_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "external_customer_id": { + "name": "external_customer_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "purchase_token": { + "name": "purchase_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "total_price": { + "name": "total_price", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency_code": { + "name": "currency_code", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "purchase_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "leaf": { + "name": "leaf", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_external_id": { + "name": "unique_external_id", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oracle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_id_idx": { + "name": "purchase_id_idx", + "columns": [ + { + "expression": "purchase_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_listener_id": { + "name": "external_listener_id", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purchase_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_oracle_purchase_oracle_id_product_oracle_id_fk": { + "name": "product_oracle_purchase_oracle_id_product_oracle_id_fk", + "tableFrom": "product_oracle_purchase", + "tableTo": "product_oracle", + "columnsFrom": ["oracle_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "product_oracle_purchase_purchase_id_unique": { + "name": "product_oracle_purchase_purchase_id_unique", + "nullsNotDistinct": false, + "columns": ["purchase_id"] + } + } + } + }, + "enums": { + "public.interactions_simulation_status": { + "name": "interactions_simulation_status", + "schema": "public", + "values": ["pending", "no_session", "failed", "succeeded"] + }, + "public.purchase_status": { + "name": "purchase_status", + "schema": "public", + "values": ["pending", "confirmed", "cancelled", "refunded"] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/backend-elysia/drizzle/dev/meta/_journal.json b/packages/backend-elysia/drizzle/dev/meta/_journal.json index f7a22f6dc..7cb7a7361 100644 --- a/packages/backend-elysia/drizzle/dev/meta/_journal.json +++ b/packages/backend-elysia/drizzle/dev/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1730116392056, "tag": "0004_tired_mephisto", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1730234900586, + "tag": "0005_bumpy_odin", + "breakpoints": true } ] } diff --git a/packages/backend-elysia/src/domain/oracle/db/schema.ts b/packages/backend-elysia/src/domain/oracle/db/schema.ts index 0e91e295c..6aa2e333d 100644 --- a/packages/backend-elysia/src/domain/oracle/db/schema.ts +++ b/packages/backend-elysia/src/domain/oracle/db/schema.ts @@ -101,7 +101,7 @@ export const purchaseItemTable = pgTable( }, (table) => ({ purchaseIdIdx: index("item_purchase_id_idx").on(table.purchaseId), - externalIdIdx: uniqueIndex("unique_external_id").on( + externalIdIdx: uniqueIndex("unique_external_purchase_item_id").on( table.externalId, table.purchaseId ), diff --git a/packages/backend-elysia/src/domain/oracle/routes/webhook/shopifyWebhook.ts b/packages/backend-elysia/src/domain/oracle/routes/webhook/shopifyWebhook.ts index 12e746214..bdfaa90ce 100644 --- a/packages/backend-elysia/src/domain/oracle/routes/webhook/shopifyWebhook.ts +++ b/packages/backend-elysia/src/domain/oracle/routes/webhook/shopifyWebhook.ts @@ -21,14 +21,6 @@ export const shopifyWebhook = new Elysia({ prefix: "/shopify" }) ); return new Response("ko", { status: 200 }); }) - .mapResponse(({ response }) => { - if ("code" in response && response.code !== 200) { - log.error({ response }, "Error while handling shopify webhook"); - return new Response("ko", { status: 200 }); - } - - return response; - }) .guard({ headers: t.Partial( t.Object({ @@ -68,14 +60,15 @@ export const shopifyWebhook = new Elysia({ prefix: "/shopify" }) params: { productId }, body, headers, + error, oracleDb, + validateBodyHmac, upsertPurchase, - error, }) => { - // todo: hmac validation in the `onParse` hook? https://shopify.dev/docs/apps/build/webhooks/subscribe/https#step-2-validate-the-origin-of-your-webhook-to-ensure-its-coming-from-shopify - // Try to parse the body as a shopify webhook type and ensure the type validity - const webhookData = body as ShopifyOrderUpdateWebhookDto; + const webhookData = JSON.parse( + body + ) as ShopifyOrderUpdateWebhookDto; // Ensure the order id match the one in the headers if ( webhookData?.id !== @@ -99,6 +92,13 @@ export const shopifyWebhook = new Elysia({ prefix: "/shopify" }) return error(404, "Product oracle not found"); } + // Validate the body hmac + validateBodyHmac({ + body, + secret: oracle.hookSignatureKey, + signature: headers["x-shopify-hmac-sha256"], + }); + // Prebuild some data before insert const purchaseStatus = mapFinancialStatus( webhookData.financial_status @@ -143,6 +143,10 @@ export const shopifyWebhook = new Elysia({ prefix: "/shopify" }) // Return the success state return "ok"; + }, + { + type: "text", + body: t.String(), } ); diff --git a/packages/backend-elysia/src/domain/oracle/routes/webhook/wooCommerceWebhook.ts b/packages/backend-elysia/src/domain/oracle/routes/webhook/wooCommerceWebhook.ts index a12dd0b03..91f865356 100644 --- a/packages/backend-elysia/src/domain/oracle/routes/webhook/wooCommerceWebhook.ts +++ b/packages/backend-elysia/src/domain/oracle/routes/webhook/wooCommerceWebhook.ts @@ -23,18 +23,10 @@ export const wooCommerceWebhook = new Elysia({ prefix: "/wooCommerce" }) reqHeaders: headers, response, }, - "Error while handling woo commerce webhook" + "Error while handling WooCommerce webhook" ); return new Response("ko", { status: 200 }); }) - .mapResponse(({ response }) => { - if ("code" in response && response.code !== 200) { - log.error({ response }, "Error while handling WooCommerce webhook"); - return new Response("ko", { status: 200 }); - } - - return response; - }) .guard({ headers: t.Partial( t.Object({ @@ -62,24 +54,20 @@ export const wooCommerceWebhook = new Elysia({ prefix: "/wooCommerce" }) .post( ":productId/hook", async ({ + // Query params: { productId }, body, headers, + error, + // Context oracleDb, upsertPurchase, - error, + validateBodyHmac, }) => { - log.debug( - { - productId, - body, - headers, - }, - "WooCommerce inner hook" - ); - // Try to parse the body as a shopify webhook type and ensure the type validity - const webhookData = body as WooCommerceOrderUpdateWebhookDto; + const webhookData = JSON.parse( + body + ) as WooCommerceOrderUpdateWebhookDto; // Find the product oracle for this product id if (!productId) { @@ -92,6 +80,13 @@ export const wooCommerceWebhook = new Elysia({ prefix: "/wooCommerce" }) return error(404, "Product oracle not found"); } + // Validate the body hmac + validateBodyHmac({ + body, + secret: oracle.hookSignatureKey, + signature: headers["x-wc-webhook-signature"], + }); + // Prebuild some data before insert const purchaseStatus = mapOrderStatus(webhookData.status); const purchaseId = keccak256( @@ -125,11 +120,8 @@ export const wooCommerceWebhook = new Elysia({ prefix: "/wooCommerce" }) return "ok"; }, { - response: { - 200: t.String(), - 400: t.String(), - 404: t.String(), - }, + type: "text", + body: t.String(), } ); diff --git a/packages/backend-elysia/src/domain/oracle/services/hookService.ts b/packages/backend-elysia/src/domain/oracle/services/hookService.ts index 9446f055e..c03170e89 100644 --- a/packages/backend-elysia/src/domain/oracle/services/hookService.ts +++ b/packages/backend-elysia/src/domain/oracle/services/hookService.ts @@ -1,4 +1,5 @@ import { log } from "@backend-common"; +import { CryptoHasher } from "bun"; import Elysia from "elysia"; import { oracleContext } from "../context"; import { purchaseItemTable, purchaseStatusTable } from "../db/schema"; @@ -8,6 +9,51 @@ export const purchaseWebhookService = new Elysia({ }) .use(oracleContext) .decorate(({ oracleDb, ...decorators }) => { + /** + * Validate a body hmac signature + */ + function validateBodyHmac({ + body, + secret, + signature, + }: { + body: string; + secret: string; + signature?: string; + }) { + // hmac hash of the body + const hasher = new CryptoHasher("sha256", secret); + hasher.update(body); + + // Convert both to buffer + const recomputedSignature = hasher.digest(); + const baseSignature = Buffer.from(signature ?? "", "base64"); + + // Compare the two + if (!baseSignature.equals(recomputedSignature)) { + log.warn( + { + signature, + baseSignature: baseSignature.toString("hex"), + recomputedSignatureHex: + recomputedSignature.toString("hex"), + recomputedSignatureB64: + recomputedSignature.toString("base64"), + }, + "Signature mismatch" + ); + } else { + log.debug( + { + recomputedSignature: + recomputedSignature.toString("hex"), + baseSignature: baseSignature.toString("hex"), + }, + "Signature matches" + ); + } + } + /** * Upsert a purchase in the database * @returns @@ -60,5 +106,6 @@ export const purchaseWebhookService = new Elysia({ ...decorators, oracleDb, upsertPurchase, + validateBodyHmac, }; }); From 82bc64a83bd4d9ed3549444bb6dfea302e00deba Mon Sep 17 00:00:00 2001 From: KONFeature Date: Wed, 30 Oct 2024 12:25:25 +0100 Subject: [PATCH 05/13] =?UTF-8?q?=E2=9C=A8=20WooCommerce=20setup=20on=20da?= =?UTF-8?q?shboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drizzle/dev/0006_moaning_mastermind.sql | 8 + .../dev/0007_flashy_shinko_yamashiro.sql | 1 + .../drizzle/dev/meta/0006_snapshot.json | 821 ++++++++++++++++++ .../drizzle/dev/meta/0007_snapshot.json | 821 ++++++++++++++++++ .../drizzle/dev/meta/_journal.json | 14 + .../src/domain/oracle/db/schema.ts | 12 + .../src/domain/oracle/routes/managment.ts | 15 +- .../routes/webhook/wooCommerceWebhook.ts | 1 + .../component/ProductDetails/ManageTeam.tsx | 16 - ....module.css => PurchaseTracker.module.css} | 4 +- ...PurchaseOracle.tsx => PurchaseTracker.tsx} | 173 +--- .../ProductDetails/PurchaseTrackerWebhook.tsx | 300 +++++++ .../component/ProductDetails/index.tsx | 4 +- .../module/product/component/Team/index.tsx | 8 +- .../module/product/hook/useOracleSetupData.ts | 55 ++ .../module/component/TextWithCopy/index.tsx | 7 +- 16 files changed, 2073 insertions(+), 187 deletions(-) create mode 100644 packages/backend-elysia/drizzle/dev/0006_moaning_mastermind.sql create mode 100644 packages/backend-elysia/drizzle/dev/0007_flashy_shinko_yamashiro.sql create mode 100644 packages/backend-elysia/drizzle/dev/meta/0006_snapshot.json create mode 100644 packages/backend-elysia/drizzle/dev/meta/0007_snapshot.json delete mode 100644 packages/dashboard/src/module/product/component/ProductDetails/ManageTeam.tsx rename packages/dashboard/src/module/product/component/ProductDetails/{PurchaseOracle.module.css => PurchaseTracker.module.css} (63%) rename packages/dashboard/src/module/product/component/ProductDetails/{PurchaseOracle.tsx => PurchaseTracker.tsx} (50%) create mode 100644 packages/dashboard/src/module/product/component/ProductDetails/PurchaseTrackerWebhook.tsx create mode 100644 packages/dashboard/src/module/product/hook/useOracleSetupData.ts diff --git a/packages/backend-elysia/drizzle/dev/0006_moaning_mastermind.sql b/packages/backend-elysia/drizzle/dev/0006_moaning_mastermind.sql new file mode 100644 index 000000000..86d913e67 --- /dev/null +++ b/packages/backend-elysia/drizzle/dev/0006_moaning_mastermind.sql @@ -0,0 +1,8 @@ +DO $$ BEGIN + CREATE TYPE "public"."product_oracle_plateform" AS ENUM('shopify', 'woocommerce', 'custom'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +ALTER TABLE "product_oracle" ADD COLUMN "plateform" "product_oracle_plateform" DEFAULT 'shopify' NOT NULL;--> statement-breakpoint +ALTER TABLE "product_oracle_purchase_item" ADD COLUMN "image_url" varchar; \ No newline at end of file diff --git a/packages/backend-elysia/drizzle/dev/0007_flashy_shinko_yamashiro.sql b/packages/backend-elysia/drizzle/dev/0007_flashy_shinko_yamashiro.sql new file mode 100644 index 000000000..5a96b3569 --- /dev/null +++ b/packages/backend-elysia/drizzle/dev/0007_flashy_shinko_yamashiro.sql @@ -0,0 +1 @@ +ALTER TABLE "product_oracle" RENAME COLUMN "plateform" TO "platform"; \ No newline at end of file diff --git a/packages/backend-elysia/drizzle/dev/meta/0006_snapshot.json b/packages/backend-elysia/drizzle/dev/meta/0006_snapshot.json new file mode 100644 index 000000000..f32e06186 --- /dev/null +++ b/packages/backend-elysia/drizzle/dev/meta/0006_snapshot.json @@ -0,0 +1,821 @@ +{ + "id": "fcea6915-8fc5-490b-9938-51fc7492dbe8", + "prevId": "1b47cbcd-6d4e-4182-8ca5-f07fffe8c31e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.sso_session": { + "name": "sso_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "sso_id": { + "name": "sso_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "consume_key": { + "name": "consume_key", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "authenticator_id": { + "name": "authenticator_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_idx": { + "name": "sso_idx", + "columns": [ + { + "expression": "sso_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_product_idx": { + "name": "sso_product_idx", + "columns": [ + { + "expression": "sso_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.interactions_purchase_tracker": { + "name": "interactions_purchase_tracker", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "external_purchase_id": { + "name": "external_purchase_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "external_customer_id": { + "name": "external_customer_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pushed": { + "name": "pushed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_interactions_purchase_map_idx": { + "name": "wallet_interactions_purchase_map_idx", + "columns": [ + { + "expression": "wallet", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_map_idx": { + "name": "unique_map_idx", + "nullsNotDistinct": false, + "columns": ["external_purchase_id", "external_customer_id"] + } + } + }, + "public.interactions_pending": { + "name": "interactions_pending", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "type_denominator": { + "name": "type_denominator", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "interaction_data": { + "name": "interaction_data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "simulation_status": { + "name": "simulation_status", + "type": "interactions_simulation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": { + "wallet_pending_interactions_idx": { + "name": "wallet_pending_interactions_idx", + "columns": [ + { + "expression": "wallet", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_idx": { + "name": "product_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.interactions_pushed": { + "name": "interactions_pushed", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "type_denominator": { + "name": "type_denominator", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "interaction_data": { + "name": "interaction_data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_pushed_interactions_idx": { + "name": "wallet_pushed_interactions_idx", + "columns": [ + { + "expression": "wallet", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.push_tokens": { + "name": "push_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key_p256dh": { + "name": "key_p256dh", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key_auth": { + "name": "key_auth", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expire_at": { + "name": "expire_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_push_tokens_idx": { + "name": "wallet_push_tokens_idx", + "columns": [ + { + "expression": "wallet", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_push_token": { + "name": "unique_push_token", + "nullsNotDistinct": false, + "columns": ["wallet", "endpoint", "key_p256dh"] + } + } + }, + "public.product_oracle": { + "name": "product_oracle", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "hook_signature_key": { + "name": "hook_signature_key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "plateform": { + "name": "plateform", + "type": "product_oracle_plateform", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'shopify'" + }, + "merkle_root": { + "name": "merkle_root", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "synced": { + "name": "synced", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "last_sync_tx_hash": { + "name": "last_sync_tx_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_product_id": { + "name": "unique_product_id", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "product_oracle_product_id_unique": { + "name": "product_oracle_product_id_unique", + "nullsNotDistinct": false, + "columns": ["product_id"] + } + } + }, + "public.product_oracle_purchase_item": { + "name": "product_oracle_purchase_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "purchase_id": { + "name": "purchase_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "item_purchase_id_idx": { + "name": "item_purchase_id_idx", + "columns": [ + { + "expression": "purchase_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_external_purchase_item_id": { + "name": "unique_external_purchase_item_id", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purchase_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_oracle_purchase_item_purchase_id_product_oracle_purchase_purchase_id_fk": { + "name": "product_oracle_purchase_item_purchase_id_product_oracle_purchase_purchase_id_fk", + "tableFrom": "product_oracle_purchase_item", + "tableTo": "product_oracle_purchase", + "columnsFrom": ["purchase_id"], + "columnsTo": ["purchase_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.product_oracle_purchase": { + "name": "product_oracle_purchase", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "oracle_id": { + "name": "oracle_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purchase_id": { + "name": "purchase_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "external_customer_id": { + "name": "external_customer_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "purchase_token": { + "name": "purchase_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "total_price": { + "name": "total_price", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency_code": { + "name": "currency_code", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "purchase_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "leaf": { + "name": "leaf", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_external_id": { + "name": "unique_external_id", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oracle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_id_idx": { + "name": "purchase_id_idx", + "columns": [ + { + "expression": "purchase_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_listener_id": { + "name": "external_listener_id", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purchase_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_oracle_purchase_oracle_id_product_oracle_id_fk": { + "name": "product_oracle_purchase_oracle_id_product_oracle_id_fk", + "tableFrom": "product_oracle_purchase", + "tableTo": "product_oracle", + "columnsFrom": ["oracle_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "product_oracle_purchase_purchase_id_unique": { + "name": "product_oracle_purchase_purchase_id_unique", + "nullsNotDistinct": false, + "columns": ["purchase_id"] + } + } + } + }, + "enums": { + "public.interactions_simulation_status": { + "name": "interactions_simulation_status", + "schema": "public", + "values": ["pending", "no_session", "failed", "succeeded"] + }, + "public.product_oracle_plateform": { + "name": "product_oracle_plateform", + "schema": "public", + "values": ["shopify", "woocommerce", "custom"] + }, + "public.purchase_status": { + "name": "purchase_status", + "schema": "public", + "values": ["pending", "confirmed", "cancelled", "refunded"] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/backend-elysia/drizzle/dev/meta/0007_snapshot.json b/packages/backend-elysia/drizzle/dev/meta/0007_snapshot.json new file mode 100644 index 000000000..d8bc79286 --- /dev/null +++ b/packages/backend-elysia/drizzle/dev/meta/0007_snapshot.json @@ -0,0 +1,821 @@ +{ + "id": "918f013e-1ebd-4009-b637-e9e7492a4a5e", + "prevId": "fcea6915-8fc5-490b-9938-51fc7492dbe8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.sso_session": { + "name": "sso_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "sso_id": { + "name": "sso_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "consume_key": { + "name": "consume_key", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "authenticator_id": { + "name": "authenticator_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_idx": { + "name": "sso_idx", + "columns": [ + { + "expression": "sso_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_product_idx": { + "name": "sso_product_idx", + "columns": [ + { + "expression": "sso_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.interactions_purchase_tracker": { + "name": "interactions_purchase_tracker", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "external_purchase_id": { + "name": "external_purchase_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "external_customer_id": { + "name": "external_customer_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pushed": { + "name": "pushed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_interactions_purchase_map_idx": { + "name": "wallet_interactions_purchase_map_idx", + "columns": [ + { + "expression": "wallet", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_map_idx": { + "name": "unique_map_idx", + "nullsNotDistinct": false, + "columns": ["external_purchase_id", "external_customer_id"] + } + } + }, + "public.interactions_pending": { + "name": "interactions_pending", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "type_denominator": { + "name": "type_denominator", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "interaction_data": { + "name": "interaction_data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "simulation_status": { + "name": "simulation_status", + "type": "interactions_simulation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": { + "wallet_pending_interactions_idx": { + "name": "wallet_pending_interactions_idx", + "columns": [ + { + "expression": "wallet", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_idx": { + "name": "product_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.interactions_pushed": { + "name": "interactions_pushed", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "type_denominator": { + "name": "type_denominator", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "interaction_data": { + "name": "interaction_data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_pushed_interactions_idx": { + "name": "wallet_pushed_interactions_idx", + "columns": [ + { + "expression": "wallet", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.push_tokens": { + "name": "push_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "wallet": { + "name": "wallet", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key_p256dh": { + "name": "key_p256dh", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key_auth": { + "name": "key_auth", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expire_at": { + "name": "expire_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_push_tokens_idx": { + "name": "wallet_push_tokens_idx", + "columns": [ + { + "expression": "wallet", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_push_token": { + "name": "unique_push_token", + "nullsNotDistinct": false, + "columns": ["wallet", "endpoint", "key_p256dh"] + } + } + }, + "public.product_oracle": { + "name": "product_oracle", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "hook_signature_key": { + "name": "hook_signature_key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "platform": { + "name": "platform", + "type": "product_oracle_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'shopify'" + }, + "merkle_root": { + "name": "merkle_root", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "synced": { + "name": "synced", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "last_sync_tx_hash": { + "name": "last_sync_tx_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_product_id": { + "name": "unique_product_id", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "product_oracle_product_id_unique": { + "name": "product_oracle_product_id_unique", + "nullsNotDistinct": false, + "columns": ["product_id"] + } + } + }, + "public.product_oracle_purchase_item": { + "name": "product_oracle_purchase_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "purchase_id": { + "name": "purchase_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "item_purchase_id_idx": { + "name": "item_purchase_id_idx", + "columns": [ + { + "expression": "purchase_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_external_purchase_item_id": { + "name": "unique_external_purchase_item_id", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purchase_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_oracle_purchase_item_purchase_id_product_oracle_purchase_purchase_id_fk": { + "name": "product_oracle_purchase_item_purchase_id_product_oracle_purchase_purchase_id_fk", + "tableFrom": "product_oracle_purchase_item", + "tableTo": "product_oracle_purchase", + "columnsFrom": ["purchase_id"], + "columnsTo": ["purchase_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.product_oracle_purchase": { + "name": "product_oracle_purchase", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "oracle_id": { + "name": "oracle_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purchase_id": { + "name": "purchase_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "external_customer_id": { + "name": "external_customer_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "purchase_token": { + "name": "purchase_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "total_price": { + "name": "total_price", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency_code": { + "name": "currency_code", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "purchase_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "leaf": { + "name": "leaf", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_external_id": { + "name": "unique_external_id", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oracle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_id_idx": { + "name": "purchase_id_idx", + "columns": [ + { + "expression": "purchase_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_listener_id": { + "name": "external_listener_id", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purchase_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_oracle_purchase_oracle_id_product_oracle_id_fk": { + "name": "product_oracle_purchase_oracle_id_product_oracle_id_fk", + "tableFrom": "product_oracle_purchase", + "tableTo": "product_oracle", + "columnsFrom": ["oracle_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "product_oracle_purchase_purchase_id_unique": { + "name": "product_oracle_purchase_purchase_id_unique", + "nullsNotDistinct": false, + "columns": ["purchase_id"] + } + } + } + }, + "enums": { + "public.interactions_simulation_status": { + "name": "interactions_simulation_status", + "schema": "public", + "values": ["pending", "no_session", "failed", "succeeded"] + }, + "public.product_oracle_platform": { + "name": "product_oracle_platform", + "schema": "public", + "values": ["shopify", "woocommerce", "custom"] + }, + "public.purchase_status": { + "name": "purchase_status", + "schema": "public", + "values": ["pending", "confirmed", "cancelled", "refunded"] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/backend-elysia/drizzle/dev/meta/_journal.json b/packages/backend-elysia/drizzle/dev/meta/_journal.json index 7cb7a7361..92b275cd9 100644 --- a/packages/backend-elysia/drizzle/dev/meta/_journal.json +++ b/packages/backend-elysia/drizzle/dev/meta/_journal.json @@ -43,6 +43,20 @@ "when": 1730234900586, "tag": "0005_bumpy_odin", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1730286897092, + "tag": "0006_moaning_mastermind", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1730287205572, + "tag": "0007_flashy_shinko_yamashiro", + "breakpoints": true } ] } diff --git a/packages/backend-elysia/src/domain/oracle/db/schema.ts b/packages/backend-elysia/src/domain/oracle/db/schema.ts index 6aa2e333d..b21582288 100644 --- a/packages/backend-elysia/src/domain/oracle/db/schema.ts +++ b/packages/backend-elysia/src/domain/oracle/db/schema.ts @@ -12,6 +12,12 @@ import { } from "drizzle-orm/pg-core"; import { customHex } from "../../../utils/drizzle/customTypes"; +export const productOraclePlatformEnum = pgEnum("product_oracle_platform", [ + "shopify", + "woocommerce", + "custom", +]); + export const productOracleTable = pgTable( "product_oracle", { @@ -21,6 +27,10 @@ export const productOracleTable = pgTable( hookSignatureKey: varchar("hook_signature_key").notNull(), // Date infos createdAt: timestamp("created_at").defaultNow(), + // The plateform of the oracle + platform: productOraclePlatformEnum("platform") + .notNull() + .default("shopify"), // The current merkle root for this oracle merkleRoot: customHex("merkle_root"), // If the oracle is synced with the blockchain @@ -94,6 +104,8 @@ export const purchaseItemTable = pgTable( name: varchar("name").notNull(), // The title of the product title: varchar("title").notNull(), + // Potential image for the purchase item + imageUrl: varchar("image_url"), // The quantity of the product quantity: integer("quantity").notNull(), // Update infos diff --git a/packages/backend-elysia/src/domain/oracle/routes/managment.ts b/packages/backend-elysia/src/domain/oracle/routes/managment.ts index ab98443eb..663c4eb55 100644 --- a/packages/backend-elysia/src/domain/oracle/routes/managment.ts +++ b/packages/backend-elysia/src/domain/oracle/routes/managment.ts @@ -45,6 +45,7 @@ export const managmentRoutes = new Elysia() // Return the oracle status return { setup: true, + platform: currentOracle.platform, webhookSigninKey: currentOracle.hookSignatureKey, stats: { firstPurchase: stats[0]?.firstPurchase ?? undefined, @@ -61,6 +62,11 @@ export const managmentRoutes = new Elysia() }), t.Object({ setup: t.Literal(true), + platform: t.Union([ + t.Literal("shopify"), + t.Literal("woocommerce"), + t.Literal("custom"), + ]), webhookSigninKey: t.String(), stats: t.Optional( t.Partial( @@ -84,7 +90,7 @@ export const managmentRoutes = new Elysia() return error(400, "Invalid product id"); } - const { hookSignatureKey } = body; + const { hookSignatureKey, platform } = body; // todo: Role check for the wallet @@ -94,11 +100,13 @@ export const managmentRoutes = new Elysia() .values({ productId, hookSignatureKey, + platform, }) .onConflictDoUpdate({ target: [productOracleTable.productId], set: { hookSignatureKey, + platform, }, }) .execute(); @@ -107,6 +115,11 @@ export const managmentRoutes = new Elysia() isAuthenticated: "business", body: t.Object({ hookSignatureKey: t.String(), + platform: t.Union([ + t.Literal("shopify"), + t.Literal("woocommerce"), + t.Literal("custom"), + ]), }), } ) diff --git a/packages/backend-elysia/src/domain/oracle/routes/webhook/wooCommerceWebhook.ts b/packages/backend-elysia/src/domain/oracle/routes/webhook/wooCommerceWebhook.ts index 91f865356..b58b3a7d5 100644 --- a/packages/backend-elysia/src/domain/oracle/routes/webhook/wooCommerceWebhook.ts +++ b/packages/backend-elysia/src/domain/oracle/routes/webhook/wooCommerceWebhook.ts @@ -113,6 +113,7 @@ export const wooCommerceWebhook = new Elysia({ prefix: "/wooCommerce" }) name: item.name, title: item.name, quantity: item.quantity, + imageUrl: item.image?.src?.length ? item.image.src : null, })), }); diff --git a/packages/dashboard/src/module/product/component/ProductDetails/ManageTeam.tsx b/packages/dashboard/src/module/product/component/ProductDetails/ManageTeam.tsx deleted file mode 100644 index d24883e1d..000000000 --- a/packages/dashboard/src/module/product/component/ProductDetails/ManageTeam.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Panel } from "@/module/common/component/Panel"; -import type { Hex } from "viem"; -import { TableTeam } from "../TableTeam"; - -/** - * Component to manage a product team - * @constructor - */ -export function ManageProductTeam({ productId }: { productId: Hex }) { - return ( - - {/* Display the administrators */} - - - ); -} diff --git a/packages/dashboard/src/module/product/component/ProductDetails/PurchaseOracle.module.css b/packages/dashboard/src/module/product/component/ProductDetails/PurchaseTracker.module.css similarity index 63% rename from packages/dashboard/src/module/product/component/ProductDetails/PurchaseOracle.module.css rename to packages/dashboard/src/module/product/component/ProductDetails/PurchaseTracker.module.css index 10657ec82..ecfb2e902 100644 --- a/packages/dashboard/src/module/product/component/ProductDetails/PurchaseOracle.module.css +++ b/packages/dashboard/src/module/product/component/ProductDetails/PurchaseTracker.module.css @@ -1,4 +1,4 @@ -.purchaseOracleSetup__inner { +.purchaseTrackerAccordionContent { & p, & form, & button { @@ -6,7 +6,7 @@ } } -p.purchaseOracleSetup__description { +p.purchaseTracker__description { margin: 0 0 10px 0; font-size: 16px; } diff --git a/packages/dashboard/src/module/product/component/ProductDetails/PurchaseOracle.tsx b/packages/dashboard/src/module/product/component/ProductDetails/PurchaseTracker.tsx similarity index 50% rename from packages/dashboard/src/module/product/component/ProductDetails/PurchaseOracle.tsx rename to packages/dashboard/src/module/product/component/ProductDetails/PurchaseTracker.tsx index 907bd4caf..6923cc3ef 100644 --- a/packages/dashboard/src/module/product/component/ProductDetails/PurchaseOracle.tsx +++ b/packages/dashboard/src/module/product/component/ProductDetails/PurchaseTracker.tsx @@ -1,34 +1,26 @@ -import { viemClient } from "@/context/blockchain/provider"; import { Badge } from "@/module/common/component/Badge"; import { PanelAccordion } from "@/module/common/component/PanelAccordion"; import { Title } from "@/module/common/component/Title"; -import { useGetAdminWallet } from "@/module/common/hook/useGetAdminWallet"; import { useHasRoleOnProduct } from "@/module/common/hook/useHasRoleOnProduct"; -import { Form, FormLabel } from "@/module/forms/Form"; import { addresses, productAdministratorRegistryAbi, productRoles, } from "@frak-labs/app-essentials"; import { useSendTransactionAction } from "@frak-labs/nexus-sdk/react"; -import { backendApi } from "@frak-labs/shared/context/server"; import { Button } from "@module/component/Button"; import { Column, Columns } from "@module/component/Columns"; import { Spinner } from "@module/component/Spinner"; -import { TextWithCopy } from "@module/component/TextWithCopy"; -import { Input } from "@module/component/forms/Input"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { useMemo, useState } from "react"; -import { useForm } from "react-hook-form"; import { type Address, type Hex, encodeFunctionData } from "viem"; -import { readContract } from "viem/actions"; +import { useOracleSetupData } from "../../hook/useOracleSetupData"; import { useProductMetadata } from "../../hook/useProductMetadata"; -import styles from "./PurchaseOracle.module.css"; +import styles from "./PurchaseTracker.module.css"; +import { PurchaseTrackerWebhook } from "./PurchaseTrackerWebhook"; /** * Setup data for the purchase oracle */ -export function PurchaseOracleSetup({ productId }: { productId: Hex }) { +export function PurchasseTrackerSetup({ productId }: { productId: Hex }) { const { data: product } = useProductMetadata({ productId }); // Early exit if the product is not a purchase @@ -42,59 +34,23 @@ export function PurchaseOracleSetup({ productId }: { productId: Hex }) { id={"purchaseTracker"} className={styles.purchaseOracleSetup} > -

+

The purchase tracker will permit to create campaigns and distribute rewards based on user purchase on your website.

- + ); } /** - * - * @param param0 - * @returns + * The content of the accordion */ -function ProductOracleSetupInner({ productId }: { productId: Hex }) { +function PurchasseTrackerAccordionContent({ productId }: { productId: Hex }) { const { isAdministrator } = useHasRoleOnProduct({ productId }); - const { data: oracleUpdater } = useGetAdminWallet({ - key: "oracle-updater", - }); // Fetch some data about the current oracle setup - const { data: oracleSetupData, refetch: refresh } = useQuery({ - enabled: !!oracleUpdater, - queryKey: ["product", "oracle-setup-data"], - queryFn: async () => { - if (!oracleUpdater) { - return null; - } - - // Get the current backend setup status - const { data: webhookStatus } = await backendApi - .oracle({ productId }) - .status.get(); - - // Check if the updater is allowed on this product - const isOracleUpdaterAllowed = await readContract(viemClient, { - abi: productAdministratorRegistryAbi, - address: addresses.productAdministratorRegistry, - functionName: "hasAllRolesOrOwner", - args: [ - BigInt(productId), - oracleUpdater, - productRoles.purchaseOracleUpdater, - ], - }); - - return { - oracleUpdater: oracleUpdater, - isOracleUpdaterAllowed, - isWebhookSetup: webhookStatus?.setup, - webhookUrl: `${process.env.BACKEND_URL}/oracle/shopify/${productId}/hook`, - webhookStatus, - }; - }, + const { data: oracleSetupData, refetch: refresh } = useOracleSetupData({ + productId, }); if (!oracleSetupData) { @@ -102,7 +58,8 @@ function ProductOracleSetupInner({ productId }: { productId: Hex }) { } return ( -
+
+ Oracle @@ -130,46 +87,6 @@ function ProductOracleSetupInner({ productId }: { productId: Hex }) {

- - - Webhook status -

- - {oracleSetupData.isWebhookSetup - ? "Webhook registered on Frak" - : "Webhook not registered on Frak"} - -

- -

- Webhook URL to use in your shopify notification centers:{" "} -

- -
{oracleSetupData.webhookUrl}
-
-
-
- - - Webhook registration - - - -
); @@ -307,69 +224,3 @@ function ToggleOracleUpdaterRole({ ); } - -function WebhookRegistrationForm({ - productId, - currentSigninKey, - refresh, -}: { - productId: Hex; - currentSigninKey?: string; - refresh: () => Promise; -}) { - const { mutate: setupWebhook, isPending } = useMutation({ - mutationKey: ["product", "oracle-webhook", "setup"], - mutationFn: async ({ webhookKey }: { webhookKey: string }) => { - await backendApi - .oracle({ productId }) - .setup.post({ hookSignatureKey: webhookKey }); - }, - onSettled: async () => { - await refresh(); - }, - }); - - const [error, setError] = useState(); - - const form = useForm({ - values: useMemo(() => ({ key: currentSigninKey }), [currentSigninKey]), - defaultValues: { - key: currentSigninKey, - }, - }); - - return ( -
- { - const { key } = values; - setError(undefined); - if (!key || key === "") { - setError("Missing signin key"); - return; - } - setupWebhook({ webhookKey: key }); - })} - > - - The webhook signin key from your shopify admin panel - - - {error &&

{error}

} - -
- - ); -} diff --git a/packages/dashboard/src/module/product/component/ProductDetails/PurchaseTrackerWebhook.tsx b/packages/dashboard/src/module/product/component/ProductDetails/PurchaseTrackerWebhook.tsx new file mode 100644 index 000000000..a119b8a2a --- /dev/null +++ b/packages/dashboard/src/module/product/component/ProductDetails/PurchaseTrackerWebhook.tsx @@ -0,0 +1,300 @@ +import { Badge } from "@/module/common/component/Badge"; +import { Row } from "@/module/common/component/Row"; +import { Title } from "@/module/common/component/Title"; +import { Form, FormLabel } from "@/module/forms/Form"; +import { backendApi } from "@frak-labs/shared/context/server"; +import { Button } from "@module/component/Button"; +import { Column, Columns } from "@module/component/Columns"; +import { Spinner } from "@module/component/Spinner"; +import { TextWithCopy } from "@module/component/TextWithCopy"; +import { Input } from "@module/component/forms/Input"; +import { useMutation } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import type { Hex } from "viem"; +import { generatePrivateKey } from "viem/accounts"; +import { useOracleSetupData } from "../../hook/useOracleSetupData"; + +type OraclePlatform = "shopify" | "woocommerce" | "custom"; + +export function PurchaseTrackerWebhook({ productId }: { productId: Hex }) { + // Fetch some data about the current oracle setup + const { data: oracleSetupData } = useOracleSetupData({ + productId, + }); + + // Current platform and webhook url to setup + const [currentPlatform, setCurrentPlatform] = useState( + oracleSetupData?.webhookStatus?.platform ?? "shopify" + ); + const webhookUrl = useMemo(() => { + return `${process.env.BACKEND_URL}/oracle/${currentPlatform}/${productId}/hook`; + }, [productId, currentPlatform]); + + if (!oracleSetupData) { + return ; + } + + return ( + <> + + + Status + + + {oracleSetupData.isWebhookSetup + ? "Webhook registered" + : "Webhook not registered"} + + {oracleSetupData.webhookStatus?.setup && + oracleSetupData?.webhookStatus?.platform && ( + + {oracleSetupData?.webhookStatus?.platform} + + )} + + + + + + + Purchase platform + + + + + + + + ); +} + +function PlatformSelector({ + currentPlatform, + setPlatform, +}: { + currentPlatform: OraclePlatform; + setPlatform: (platform: OraclePlatform) => void; +}) { + return ( + + setPlatform("shopify")} + variant={ + currentPlatform === "shopify" ? "success" : "secondary" + } + style={{ cursor: "pointer" }} + > + Shopify + + setPlatform("woocommerce")} + variant={ + currentPlatform === "woocommerce" ? "success" : "secondary" + } + style={{ cursor: "pointer" }} + > + WooCommerce + + + ); +} + +function PlatformRegistration({ + platform, + webhookUrl, + productId, + currentSigninKey, +}: { + platform: OraclePlatform; + webhookUrl: string; + productId: Hex; + currentSigninKey?: string; +}) { + if (platform === "woocommerce") { + return ( + + ); + } + + return ( + + ); +} + +function WooCommerceRegistrationForm({ + productId, + webhookUrl, + currentSigninKey, +}: { + productId: Hex; + webhookUrl: string; + currentSigninKey?: string; +}) { + const { mutate: setupWebhook, isPending } = useWebhookSetup({ + productId, + }); + + // The key that will be used for the woocommerce webhook + const signinKey = useMemo(() => { + if (currentSigninKey) { + return currentSigninKey; + } + + return generatePrivateKey(); + }, [currentSigninKey]); + + return ( + <> +

+ To register the webhook on WooCommerce, go to your WordPress + admin console, and then in +
+ + WooCommerce {">"} Settings {">"} Advanced {">"} Webhooks + +
+ Create a new WebHook with the topic Order Updated with + the following URL and Secret: +

+ + URL:
{webhookUrl}
+
+ + Secret:
{signinKey}
+
+

And finally Register it on Frak via this button

+ + + ); +} + +function StripeRegistrationForm({ + productId, + currentSigninKey, + webhookUrl, +}: { + productId: Hex; + currentSigninKey?: string; + webhookUrl?: string; +}) { + const { mutate: setupWebhook, isPending } = useWebhookSetup({ productId }); + + const [error, setError] = useState(); + + const form = useForm({ + values: useMemo(() => ({ key: currentSigninKey }), [currentSigninKey]), + defaultValues: { + key: currentSigninKey, + }, + }); + + return ( + <> +

+ To register the webhook on Shopify, go to your Shopify admin + console, and then in
+ + Settings {">"} Notifications {">"} WebHook + +
+ Create a new WebHook with the event Order Updated with + the following URL: +

+ + URL:
{webhookUrl}
+
+
+ { + const { key } = values; + setError(undefined); + if (!key || key === "") { + setError("Missing signin key"); + return; + } + setupWebhook({ webhookKey: key, platform: "shopify" }); + })} + > + + Copy the signature in the bottom of the webhooks list + from your shopify panel + + + {error &&

{error}

} + +
+ + + ); +} + +function useWebhookSetup({ productId }: { productId: Hex }) { + const { refetch } = useOracleSetupData({ productId }); + return useMutation({ + mutationKey: ["product", "oracle-webhook", "setup", productId], + mutationFn: async ({ + webhookKey, + platform, + }: { webhookKey: string; platform: OraclePlatform }) => { + await backendApi + .oracle({ productId }) + .setup.post({ hookSignatureKey: webhookKey, platform }); + }, + onSettled: async () => { + await refetch(); + }, + }); +} diff --git a/packages/dashboard/src/module/product/component/ProductDetails/index.tsx b/packages/dashboard/src/module/product/component/ProductDetails/index.tsx index e6bef4612..b13ca11b2 100644 --- a/packages/dashboard/src/module/product/component/ProductDetails/index.tsx +++ b/packages/dashboard/src/module/product/component/ProductDetails/index.tsx @@ -14,7 +14,7 @@ import { } from "@/module/forms/Form"; import { MultiSelect, type MultiSelectProps } from "@/module/forms/MultiSelect"; import { InteractionSettings } from "@/module/product/component/ProductDetails/InteractionSettings"; -import { PurchaseOracleSetup } from "@/module/product/component/ProductDetails/PurchaseOracle"; +import { PurchasseTrackerSetup } from "@/module/product/component/ProductDetails/PurchaseTracker"; import { ProductHead } from "@/module/product/component/ProductHead"; import { useEditProduct } from "@/module/product/hook/useEditProduct"; import { useProductMetadata } from "@/module/product/hook/useProductMetadata"; @@ -204,7 +204,7 @@ export function ProductDetails({ productId }: { productId: Hex }) { )} - + diff --git a/packages/dashboard/src/module/product/component/Team/index.tsx b/packages/dashboard/src/module/product/component/Team/index.tsx index 6020a051f..5635f0316 100644 --- a/packages/dashboard/src/module/product/component/Team/index.tsx +++ b/packages/dashboard/src/module/product/component/Team/index.tsx @@ -1,15 +1,19 @@ "use client"; +import { Panel } from "@/module/common/component/Panel"; import { FormLayout } from "@/module/forms/Form"; -import { ManageProductTeam } from "@/module/product/component/ProductDetails/ManageTeam"; import { ProductHead } from "@/module/product/component/ProductHead"; import type { Hex } from "viem"; +import { TableTeam } from "../TableTeam"; export function Team({ productId }: { productId: Hex }) { return ( - + + {/* Display the administrators */} + + ); } diff --git a/packages/dashboard/src/module/product/hook/useOracleSetupData.ts b/packages/dashboard/src/module/product/hook/useOracleSetupData.ts new file mode 100644 index 000000000..dbdcbf512 --- /dev/null +++ b/packages/dashboard/src/module/product/hook/useOracleSetupData.ts @@ -0,0 +1,55 @@ +import { viemClient } from "@/context/blockchain/provider"; +import { useGetAdminWallet } from "@/module/common/hook/useGetAdminWallet"; +import { + addresses, + productAdministratorRegistryAbi, + productRoles, +} from "@frak-labs/app-essentials"; +import { backendApi } from "@frak-labs/shared/context/server"; +import { useQuery } from "@tanstack/react-query"; +import type { Hex } from "viem"; +import { readContract } from "viem/actions"; + +/** + * Hook to fetch the oracle setup data + */ +export function useOracleSetupData({ productId }: { productId: Hex }) { + const { data: oracleUpdater } = useGetAdminWallet({ + key: "oracle-updater", + }); + // Fetch some data about the current oracle setup + return useQuery({ + enabled: !!oracleUpdater, + queryKey: ["product", "oracle-setup-data", productId], + queryFn: async () => { + if (!oracleUpdater) { + return null; + } + + // Get the current backend setup status + const { data: webhookStatus } = await backendApi + .oracle({ productId }) + .status.get(); + + // Check if the updater is allowed on this product + const isOracleUpdaterAllowed = await readContract(viemClient, { + abi: productAdministratorRegistryAbi, + address: addresses.productAdministratorRegistry, + functionName: "hasAllRolesOrOwner", + args: [ + BigInt(productId), + oracleUpdater, + productRoles.purchaseOracleUpdater, + ], + }); + + return { + oracleUpdater: oracleUpdater, + isOracleUpdaterAllowed, + isWebhookSetup: webhookStatus?.setup, + webhookUrl: `${process.env.BACKEND_URL}/oracle/shopify/${productId}/hook`, + webhookStatus, + }; + }, + }); +} diff --git a/packages/shared/module/component/TextWithCopy/index.tsx b/packages/shared/module/component/TextWithCopy/index.tsx index db94de5c5..4c5139e54 100644 --- a/packages/shared/module/component/TextWithCopy/index.tsx +++ b/packages/shared/module/component/TextWithCopy/index.tsx @@ -1,13 +1,14 @@ import { useCopyToClipboardWithState } from "@module/hook/useCopyToClipboardWithState"; import { Check, Clipboard } from "lucide-react"; -import type { PropsWithChildren } from "react"; +import type { CSSProperties, PropsWithChildren } from "react"; import { Button } from "../Button"; import styles from "./index.module.css"; export function TextWithCopy({ text, children, -}: PropsWithChildren<{ text?: string }>) { + style, +}: PropsWithChildren<{ text?: string; style?: CSSProperties }>) { const { copied, copy } = useCopyToClipboardWithState(); if (!text) { @@ -15,7 +16,7 @@ export function TextWithCopy({ } return ( -
+
{children}
); } @@ -445,24 +431,22 @@ function NewProductVerify({ */ function ProductSuccessInfo({ txHash, - isWaitingForFinalisedCreation, - isConfirmed, + infoTxt, }: { txHash?: Hex; - isWaitingForFinalisedCreation: boolean; - isConfirmed?: boolean | null; + infoTxt?: string; }) { - if (!txHash) return null; - - if (txHash && isWaitingForFinalisedCreation && !isConfirmed) { + if (infoTxt) { return (

- Setting all the right blockchain data + {infoTxt} ...

); } + if (!txHash) return null; + return ( <>

diff --git a/packages/dashboard/src/module/dashboard/hooks/useMintMyProduct.ts b/packages/dashboard/src/module/dashboard/hooks/useMintMyProduct.ts index f5c773a44..ae27741fb 100644 --- a/packages/dashboard/src/module/dashboard/hooks/useMintMyProduct.ts +++ b/packages/dashboard/src/module/dashboard/hooks/useMintMyProduct.ts @@ -1,18 +1,42 @@ -import { useSetupInteractionContract } from "@/module/product/hook/useSetupInteractionContract"; -import type { ProductTypesKey } from "@frak-labs/nexus-sdk/core"; +import { useGetAdminWallet } from "@/module/common/hook/useGetAdminWallet"; +import { useWaitForTxAndInvalidateQueries } from "@/module/common/utils/useWaitForTxAndInvalidateQueries"; +import { + addresses, + campaignBankAbi, + interactionValidatorRoles, + productAdministratorRegistryAbi, + productInteractionManagerAbi, + productRoles, +} from "@frak-labs/app-essentials/blockchain"; +import type { + ProductTypesKey, + SendTransactionModalStepType, +} from "@frak-labs/nexus-sdk/core"; +import { useSendTransactionAction } from "@frak-labs/nexus-sdk/react"; import { backendApi } from "@frak-labs/shared/context/server"; import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; +import { encodeFunctionData } from "viem"; /** * Hook to mint the user product */ export function useMintMyProduct() { - const { mutateAsync: deployInteractionContract } = - useSetupInteractionContract(); + const { data: oracleUpdater } = useGetAdminWallet({ + key: "oracle-updater", + }); + const { mutateAsync: sendTransaction } = useSendTransactionAction(); + const waitForTxAndInvalidateQueries = useWaitForTxAndInvalidateQueries(); + + const [infoTxt, setInfoTxt] = useState(); - return useMutation({ + const mutation = useMutation({ mutationKey: ["product", "launch-mint"], - mutationFn: async ({ + onSettled() { + // Clear info post mutation + setInfoTxt(undefined); + }, + async mutationFn({ name, domain, setupCode, @@ -22,7 +46,9 @@ export function useMintMyProduct() { domain: string; setupCode: string; productTypes: ProductTypesKey[]; - }) => { + }) { + // Trigger the backend mint + setInfoTxt("Registering your product"); const { data, error } = await backendApi.business.mint.put({ name, domain, @@ -31,9 +57,89 @@ export function useMintMyProduct() { }); if (error) throw error; - // Setup the interaction contract if needed - await deployInteractionContract({ - productId: data.productId, + // Compute the post mint transaction to be done + setInfoTxt("Preparing post setup validation"); + const tx: SendTransactionModalStepType["params"]["tx"] = []; + + // If we got a banking contract, activate it on mint + if (data.bankContract) { + tx.push({ + to: data.bankContract, + data: encodeFunctionData({ + abi: campaignBankAbi, + functionName: "updateDistributionState", + args: [true], + }), + }); + } + + // If we got a interaction contract, allow the managed validator on it + if (data.interactionContract) { + // Get the manager validator address + const { data: delegatedManagerWallet } = + await backendApi.common.adminWallet.get({ + query: { + productId: data.productId, + }, + }); + if (delegatedManagerWallet?.pubKey) { + tx.push({ + to: data.interactionContract, + data: encodeFunctionData({ + abi: productInteractionManagerAbi, + functionName: "grantRoles", + args: [ + delegatedManagerWallet?.pubKey, + interactionValidatorRoles, + ], + }), + }); + } + } + + // If that's a product related oracle, enable the purchase oracle by default + if (productTypes.includes("purchase") && oracleUpdater) { + tx.push({ + to: addresses.productAdministratorRegistry, + data: encodeFunctionData({ + abi: productAdministratorRegistryAbi, + functionName: "grantRoles", + args: [ + BigInt(data.productId), + oracleUpdater, + productRoles.purchaseOracleUpdater, + ], + }), + }); + } + + // Send all the post mint transactions + if (tx.length) { + setInfoTxt("Waiting for post setup validation"); + const { hash } = await sendTransaction({ + tx, + metadata: { + header: { + title: "Post mint setup", + }, + context: "Setting up the product post mint", + }, + }); + + // Wait for the post setup tx to be done, and invalidate product + await waitForTxAndInvalidateQueries({ + hash, + queryKey: ["product"], + confirmations: 4, + }); + } + + // Wait for the mint tx to be done, and invalidated everything related to the campaigns + setInfoTxt("Verifying everything"); + await waitForTxAndInvalidateQueries({ + hash: data.txHash, + queryKey: ["product"], + confirmations: 16, }); return { @@ -41,4 +147,6 @@ export function useMintMyProduct() { }; }, }); + + return { mutation, infoTxt }; } diff --git a/packages/dashboard/src/module/product/hook/useSetupInteractionContract.ts b/packages/dashboard/src/module/product/hook/useSetupInteractionContract.ts index eaecd1374..4a35ebb29 100644 --- a/packages/dashboard/src/module/product/hook/useSetupInteractionContract.ts +++ b/packages/dashboard/src/module/product/hook/useSetupInteractionContract.ts @@ -104,7 +104,8 @@ export function useSetupInteractionContract() { } /** - * Get a potentially futur interaction contract + * Get a potentially future interaction contract + * @param wallet * @param productId * @param salt */ From cfc6d8c4f7e24e2298b9d99084a28bd05650adf3 Mon Sep 17 00:00:00 2001 From: KONFeature Date: Thu, 31 Oct 2024 11:15:36 +0100 Subject: [PATCH 11/13] =?UTF-8?q?=E2=9C=A8=20Include=20the=20purchase=20st?= =?UTF-8?q?atus=20tracker=20as=20helper=20in=20the=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/sdk/src/core/actions/index.ts | 3 +- .../actions/referral/referralInteraction.ts | 4 +- .../src/core/actions/trackPurchaseStatus.ts | 31 +++++++++++++++ .../sdk/src/core/actions/watchWalletStatus.ts | 38 +++++++++++++++---- .../src/core/actions/wrapper/walletStatus.ts | 26 ------------- .../sdk/src/react/hook/useWalletStatus.ts | 4 +- 6 files changed, 67 insertions(+), 39 deletions(-) create mode 100644 packages/sdk/src/core/actions/trackPurchaseStatus.ts delete mode 100644 packages/sdk/src/core/actions/wrapper/walletStatus.ts diff --git a/packages/sdk/src/core/actions/index.ts b/packages/sdk/src/core/actions/index.ts index 0864ef788..65d38ef02 100644 --- a/packages/sdk/src/core/actions/index.ts +++ b/packages/sdk/src/core/actions/index.ts @@ -2,6 +2,8 @@ export { watchWalletStatus } from "./watchWalletStatus"; export { sendInteraction } from "./sendInteraction"; export { displayModal } from "./displayModal"; export { openSso } from "./openSso"; +// Helper to track the purchase status +export { trackPurchaseStatus } from "./trackPurchaseStatus"; // Modal wrappers export { siweAuthenticate, @@ -11,7 +13,6 @@ export { sendTransaction, type SendTransactionParams, } from "./wrapper/sendTransaction"; -export { walletStatus } from "./wrapper/walletStatus"; // Referral interaction export { referralInteraction } from "./referral/referralInteraction"; export { processReferral } from "./referral/processReferral"; diff --git a/packages/sdk/src/core/actions/referral/referralInteraction.ts b/packages/sdk/src/core/actions/referral/referralInteraction.ts index eaba9319f..a3488d62a 100644 --- a/packages/sdk/src/core/actions/referral/referralInteraction.ts +++ b/packages/sdk/src/core/actions/referral/referralInteraction.ts @@ -1,5 +1,5 @@ import type { Hex } from "viem"; -import { walletStatus } from "../"; +import { watchWalletStatus } from "../"; import type { DisplayModalParamsType, ModalStepTypes, @@ -29,7 +29,7 @@ export async function referralInteraction( }); // Get the current wallet status - const currentWalletStatus = await walletStatus(client); + const currentWalletStatus = await watchWalletStatus(client); try { return await processReferral(client, { diff --git a/packages/sdk/src/core/actions/trackPurchaseStatus.ts b/packages/sdk/src/core/actions/trackPurchaseStatus.ts new file mode 100644 index 000000000..66f6d1263 --- /dev/null +++ b/packages/sdk/src/core/actions/trackPurchaseStatus.ts @@ -0,0 +1,31 @@ +/** + * Function used to track the status of a purchase + */ +export async function trackPurchaseStatus(args: { + customerId: string | number; + orderId: string | number; + token: string; +}) { + if (typeof window === "undefined") { + console.warn("[Frak] No window found, can't track purchase"); + return; + } + const interactionToken = window.sessionStorage.getItem( + "frak-wallet-interaction-token" + ); + if (!interactionToken) { + console.warn("[Frak] No frak session found, skipping purchase check"); + return; + } + + // Submit the listening request + await fetch("https://backend.frak.id/interactions/listenForPurchase", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "x-wallet-sdk-auth": interactionToken, + }, + body: JSON.stringify(args), + }); +} diff --git a/packages/sdk/src/core/actions/watchWalletStatus.ts b/packages/sdk/src/core/actions/watchWalletStatus.ts index 93a6ecd03..57552f15d 100644 --- a/packages/sdk/src/core/actions/watchWalletStatus.ts +++ b/packages/sdk/src/core/actions/watchWalletStatus.ts @@ -1,5 +1,6 @@ import type { NexusClient } from "../types/client"; import type { WalletStatusReturnType } from "../types/rpc/walletStatus"; +import { Deferred } from "../utils"; /** * Function used to watch the current nexus wallet status @@ -8,12 +9,33 @@ import type { WalletStatusReturnType } from "../types/rpc/walletStatus"; */ export function watchWalletStatus( client: NexusClient, - callback: (status: WalletStatusReturnType) => void -) { - return client.listenerRequest( - { - method: "frak_listenToWalletStatus", - }, - callback - ); + callback?: (status: WalletStatusReturnType) => void +): Promise { + // If no callback is provided, just do a request with deferred result + if (!callback) { + return client.request({ method: "frak_listenToWalletStatus" }); + } + + // Otherwise, listen to the wallet status and return the first one received + const firstResult = new Deferred(); + let hasResolved = false; + + // Start the listening request, and return the first result + return client + .listenerRequest( + { + method: "frak_listenToWalletStatus", + }, + (status) => { + // Transmit the status to the callback + callback(status); + + // If the promise hasn't resolved yet, resolve it + if (!hasResolved) { + firstResult.resolve(status); + hasResolved = true; + } + } + ) + .then(() => firstResult.promise); } diff --git a/packages/sdk/src/core/actions/wrapper/walletStatus.ts b/packages/sdk/src/core/actions/wrapper/walletStatus.ts deleted file mode 100644 index 52a6690b5..000000000 --- a/packages/sdk/src/core/actions/wrapper/walletStatus.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { NexusClient, WalletStatusReturnType } from "../../types"; -import { Deferred } from "../../utils/Deferred"; -import { watchWalletStatus } from "../watchWalletStatus"; - -export async function walletStatus( - client: NexusClient, - callback?: (status: WalletStatusReturnType) => void -) { - // Our first result deferred - const firstResult = new Deferred(); - let hasResolved = false; - - // Setup the listener, with a callback request that will resolve the first result - await watchWalletStatus(client, (status) => { - callback?.(status); - - // If the promise hasn't resolved yet, resolve it - if (!hasResolved) { - firstResult.resolve(status); - hasResolved = true; - } - }); - - // Wait for the first response - return firstResult.promise; -} diff --git a/packages/sdk/src/react/hook/useWalletStatus.ts b/packages/sdk/src/react/hook/useWalletStatus.ts index d89535623..c6c2d6845 100644 --- a/packages/sdk/src/react/hook/useWalletStatus.ts +++ b/packages/sdk/src/react/hook/useWalletStatus.ts @@ -1,7 +1,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; import type { WalletStatusReturnType } from "../../core"; -import { walletStatus } from "../../core/actions"; +import { watchWalletStatus } from "../../core/actions"; import { ClientNotFound } from "../../core/types/rpc/error"; import { useNexusClient } from "./useNexusClient"; @@ -37,7 +37,7 @@ export function useWalletStatus() { throw new ClientNotFound(); } - return walletStatus(client, newStatusUpdated); + return watchWalletStatus(client, newStatusUpdated); }, enabled: !!client, }); From 0581ab85d3763049fcdba3372376a2bac5b81329 Mon Sep 17 00:00:00 2001 From: KONFeature Date: Thu, 31 Oct 2024 15:56:02 +0100 Subject: [PATCH 12/13] =?UTF-8?q?=E2=9C=A8=20Modal=20builder=20+=20interac?= =?UTF-8?q?tion=20token=20in=20session=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/sdk/src/core/actions/index.ts | 1 + .../sdk/src/core/actions/watchWalletStatus.ts | 34 +++- .../src/core/actions/wrapper/modalBuilder.ts | 149 ++++++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/src/core/actions/wrapper/modalBuilder.ts diff --git a/packages/sdk/src/core/actions/index.ts b/packages/sdk/src/core/actions/index.ts index 65d38ef02..622be228a 100644 --- a/packages/sdk/src/core/actions/index.ts +++ b/packages/sdk/src/core/actions/index.ts @@ -13,6 +13,7 @@ export { sendTransaction, type SendTransactionParams, } from "./wrapper/sendTransaction"; +export { modalBuilder } from "./wrapper/modalBuilder"; // Referral interaction export { referralInteraction } from "./referral/referralInteraction"; export { processReferral } from "./referral/processReferral"; diff --git a/packages/sdk/src/core/actions/watchWalletStatus.ts b/packages/sdk/src/core/actions/watchWalletStatus.ts index 57552f15d..4d7990387 100644 --- a/packages/sdk/src/core/actions/watchWalletStatus.ts +++ b/packages/sdk/src/core/actions/watchWalletStatus.ts @@ -13,7 +13,15 @@ export function watchWalletStatus( ): Promise { // If no callback is provided, just do a request with deferred result if (!callback) { - return client.request({ method: "frak_listenToWalletStatus" }); + return client + .request({ method: "frak_listenToWalletStatus" }) + .then((result) => { + // Save the potential interaction token + savePotentialToken(result.interactionToken); + + // Return the result + return result; + }); } // Otherwise, listen to the wallet status and return the first one received @@ -30,6 +38,9 @@ export function watchWalletStatus( // Transmit the status to the callback callback(status); + // Save the potential interaction token + savePotentialToken(status.interactionToken); + // If the promise hasn't resolved yet, resolve it if (!hasResolved) { firstResult.resolve(status); @@ -39,3 +50,24 @@ export function watchWalletStatus( ) .then(() => firstResult.promise); } + +/** + * Helper to save a potential interaction token + * @param interactionToken + */ +function savePotentialToken(interactionToken?: string) { + if (typeof window === "undefined") { + return; + } + + if (interactionToken) { + // If we got an interaction token, save it + window.sessionStorage.setItem( + "frak-wallet-interaction-token", + interactionToken + ); + } else { + // Otherwise, remove it + window.sessionStorage.removeItem("frak.interaction-token"); + } +} diff --git a/packages/sdk/src/core/actions/wrapper/modalBuilder.ts b/packages/sdk/src/core/actions/wrapper/modalBuilder.ts new file mode 100644 index 000000000..7986f57b9 --- /dev/null +++ b/packages/sdk/src/core/actions/wrapper/modalBuilder.ts @@ -0,0 +1,149 @@ +import type { + DisplayModalParamsType, + FinalActionType, + FinalModalStepType, + LoginModalStepType, + ModalRpcMetadata, + ModalRpcStepsResultType, + ModalStepTypes, + NexusClient, + OpenInteractionSessionModalStepType, + SendTransactionModalStepType, +} from "../../types"; +import { displayModal } from "../displayModal"; + +/** + * Simple modal builder params builder + * @param client + * @param metadata + * @param login + * @param openSession + */ +export function modalBuilder( + client: NexusClient, + { + metadata, + login, + openSession, + }: { + metadata: ModalRpcMetadata; + login?: LoginModalStepType["params"]; + openSession?: OpenInteractionSessionModalStepType["params"]; + } +): ModalStepBuilder<[LoginModalStepType, OpenInteractionSessionModalStepType]> { + // Build the initial modal params + const baseParams: DisplayModalParamsType< + [LoginModalStepType, OpenInteractionSessionModalStepType] + > = { + steps: { + login: login ?? {}, + openSession: openSession ?? {}, + }, + metadata, + }; + + // Return the step builder + return modalStepsBuilder(client, baseParams); +} + +/** + * Represent the type of the modal step builder + */ +type ModalStepBuilder = { + params: DisplayModalParamsType; + sendTx: ( + options: SendTransactionModalStepType["params"] + ) => ModalStepBuilder<[...Steps, SendTransactionModalStepType]>; + reward: ( + options?: Omit + ) => ModalStepBuilder<[...Steps, FinalModalStepType]>; + sharing: ( + sharingOptions?: Extract< + FinalActionType, + { key: "sharing" } + >["options"], + options?: Omit + ) => ModalStepBuilder<[...Steps, FinalModalStepType]>; + display: () => Promise>; +}; + +/** + * Build builder helping to add steps to the modal + * @param client + * @param params + */ +function modalStepsBuilder( + client: NexusClient, + params: DisplayModalParamsType +): ModalStepBuilder { + // Function add the send tx step + function sendTx(options: SendTransactionModalStepType["params"]) { + return modalStepsBuilder< + [...CurrentSteps, SendTransactionModalStepType] + >(client, { + ...params, + steps: { + ...params.steps, + sendTransaction: options, + }, + } as DisplayModalParamsType< + [...CurrentSteps, SendTransactionModalStepType] + >); + } + + // Function to add a reward step at the end + function reward(options?: Omit) { + return modalStepsBuilder<[...CurrentSteps, FinalModalStepType]>( + client, + { + ...params, + steps: { + ...params.steps, + final: { + ...options, + action: { key: "reward" }, + }, + }, + } as DisplayModalParamsType<[...CurrentSteps, FinalModalStepType]> + ); + } + + // Function to add sharing step at the end + function sharing( + sharingOptions?: Extract< + FinalActionType, + { key: "sharing" } + >["options"], + options?: Omit + ) { + return modalStepsBuilder<[...CurrentSteps, FinalModalStepType]>( + client, + { + ...params, + steps: { + ...params.steps, + final: { + ...options, + action: { key: "sharing", options: sharingOptions }, + }, + }, + } as DisplayModalParamsType<[...CurrentSteps, FinalModalStepType]> + ); + } + + // Function to display it + async function display() { + return await displayModal(client, params); + } + + return { + // Access current modal params + params, + // Function to add new steps + sendTx, + reward, + sharing, + // Display the modal + display, + }; +} From 8fe1ebc83ec96c6468aad013d9deb03c838b6987 Mon Sep 17 00:00:00 2001 From: KONFeature Date: Thu, 31 Oct 2024 16:37:20 +0100 Subject: [PATCH 13/13] =?UTF-8?q?=E2=9C=A8=20Update=20vanilla=20example=20?= =?UTF-8?q?to=20use=20the=20modal=20step=20builder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/orange-cars-worry.md | 5 ++ example/vanilla-js/src/main.ts | 30 ++++++----- example/vanilla-js/src/module/login.ts | 15 +----- example/vanilla-js/src/module/modalShare.ts | 32 +++-------- example/vanilla-js/src/types/globals.d.ts | 4 ++ packages/sdk/src/core/actions/index.ts | 6 ++- .../src/core/actions/wrapper/modalBuilder.ts | 53 +++++++++++-------- 7 files changed, 71 insertions(+), 74 deletions(-) create mode 100644 .changeset/orange-cars-worry.md diff --git a/.changeset/orange-cars-worry.md b/.changeset/orange-cars-worry.md new file mode 100644 index 000000000..d2c5b90c4 --- /dev/null +++ b/.changeset/orange-cars-worry.md @@ -0,0 +1,5 @@ +--- +"@frak-labs/nexus-sdk": patch +--- + +Add a `modalBuilder` to ease modal creation diff --git a/example/vanilla-js/src/main.ts b/example/vanilla-js/src/main.ts index 6b9b0d3fb..3024997ec 100644 --- a/example/vanilla-js/src/main.ts +++ b/example/vanilla-js/src/main.ts @@ -5,7 +5,12 @@ import { setupFrakClient } from "./module/setupClient"; import { displayWalletStatus } from "./module/walletStatus"; // Export the setup function and config for use in other files -window.FrakSetup = { frakConfig, frakClient: null, modalShare }; +window.FrakSetup = { + frakConfig, + frakClient: null, + modalShare, + modalBuilder: null, +}; document.addEventListener("DOMContentLoaded", () => { console.log("NexusSDK", window.NexusSDK); @@ -16,22 +21,19 @@ document.addEventListener("DOMContentLoaded", () => { return; } + const modalStepBuilder = window.NexusSDK.modalBuilder(frakClient, { + metadata: { + lang: "fr", + isDismissible: true, + }, + login: loginModalStep, + }); + window.FrakSetup.frakClient = frakClient; + window.FrakSetup.modalBuilder = modalStepBuilder; window.NexusSDK.referralInteraction(frakClient, { - modalConfig: { - steps: { - login: loginModalStep, - openSession: {}, - final: { - action: { key: "reward" }, - }, - }, - metadata: { - lang: "fr", - isDismissible: true, - }, - }, + modalConfig: modalStepBuilder.reward().params, options: { alwaysAppendUrl: true, }, diff --git a/example/vanilla-js/src/module/login.ts b/example/vanilla-js/src/module/login.ts index 2cd412b56..528ac852d 100644 --- a/example/vanilla-js/src/module/login.ts +++ b/example/vanilla-js/src/module/login.ts @@ -1,5 +1,3 @@ -import { loginModalStep } from "./config"; - export function bindLoginButton() { const loginButton = document.getElementById("login-button"); loginButton?.addEventListener("click", handleLogin); @@ -19,20 +17,11 @@ async function handleLogin() { loginButton.textContent = "Logging in..."; try { - if (!window.FrakSetup.frakClient) { + if (!window.FrakSetup.modalBuilder) { console.error("Frak client not initialized"); return; } - await window.NexusSDK.displayModal(window.FrakSetup.frakClient, { - metadata: { - lang: "fr", - isDismissible: true, - }, - steps: { - login: loginModalStep, - openSession: {}, - }, - }); + await window.FrakSetup.modalBuilder.display(); loginButton.textContent = "Logged In"; } catch (error) { console.error("Login error:", error); diff --git a/example/vanilla-js/src/module/modalShare.ts b/example/vanilla-js/src/module/modalShare.ts index e10d7d07f..a052cb6f0 100644 --- a/example/vanilla-js/src/module/modalShare.ts +++ b/example/vanilla-js/src/module/modalShare.ts @@ -1,29 +1,13 @@ -import { loginModalStep } from "./config"; - export function modalShare() { - const finalAction = { - key: "sharing", - options: { - popupTitle: "Share this article with your friends", - text: "Discover this awesome article", - link: typeof window !== "undefined" ? window.location.href : "", - }, - } as const; - if (!window.FrakSetup.frakClient) { + if (!window.FrakSetup.modalBuilder) { console.error("Frak client not initialized"); return; } - window.NexusSDK.displayModal(window.FrakSetup.frakClient, { - metadata: { - lang: "fr", - isDismissible: true, - }, - steps: { - login: loginModalStep, - openSession: {}, - final: { - action: finalAction, - }, - }, - }); + window.FrakSetup.modalBuilder + .sharing({ + popupTitle: "Share this article with your friends", + text: "Discover this awesome article", + link: typeof window !== "undefined" ? window.location.href : "", + }) + .display(); } diff --git a/example/vanilla-js/src/types/globals.d.ts b/example/vanilla-js/src/types/globals.d.ts index 223bb351a..949b71050 100644 --- a/example/vanilla-js/src/types/globals.d.ts +++ b/example/vanilla-js/src/types/globals.d.ts @@ -1,5 +1,7 @@ import type { + ModalBuilder, displayModal, + modalBuilder, referralInteraction, watchWalletStatus, } from "@frak-labs/nexus-sdk/actions"; @@ -18,11 +20,13 @@ declare global { displayModal: typeof displayModal; referralInteraction: typeof referralInteraction; watchWalletStatus: typeof watchWalletStatus; + modalBuilder: typeof modalBuilder; }; FrakSetup: { frakConfig: NexusWalletSdkConfig; frakClient: NexusClient | null; modalShare: () => void; + modalBuilder: ModalBuilder | null; }; } } diff --git a/packages/sdk/src/core/actions/index.ts b/packages/sdk/src/core/actions/index.ts index 622be228a..2264fa240 100644 --- a/packages/sdk/src/core/actions/index.ts +++ b/packages/sdk/src/core/actions/index.ts @@ -13,7 +13,11 @@ export { sendTransaction, type SendTransactionParams, } from "./wrapper/sendTransaction"; -export { modalBuilder } from "./wrapper/modalBuilder"; +export { + modalBuilder, + type ModalStepBuilder, + type ModalBuilder, +} from "./wrapper/modalBuilder"; // Referral interaction export { referralInteraction } from "./referral/referralInteraction"; export { processReferral } from "./referral/processReferral"; diff --git a/packages/sdk/src/core/actions/wrapper/modalBuilder.ts b/packages/sdk/src/core/actions/wrapper/modalBuilder.ts index 7986f57b9..7caf50cc1 100644 --- a/packages/sdk/src/core/actions/wrapper/modalBuilder.ts +++ b/packages/sdk/src/core/actions/wrapper/modalBuilder.ts @@ -12,6 +12,36 @@ import type { } from "../../types"; import { displayModal } from "../displayModal"; +/** + * Represent the type of the modal step builder + */ +export type ModalStepBuilder< + Steps extends ModalStepTypes[] = ModalStepTypes[], +> = { + params: DisplayModalParamsType; + sendTx: ( + options: SendTransactionModalStepType["params"] + ) => ModalStepBuilder<[...Steps, SendTransactionModalStepType]>; + reward: ( + options?: Omit + ) => ModalStepBuilder<[...Steps, FinalModalStepType]>; + sharing: ( + sharingOptions?: Extract< + FinalActionType, + { key: "sharing" } + >["options"], + options?: Omit + ) => ModalStepBuilder<[...Steps, FinalModalStepType]>; + display: () => Promise>; +}; + +/** + * Represent the output type of the modal builder + */ +export type ModalBuilder = ModalStepBuilder< + [LoginModalStepType, OpenInteractionSessionModalStepType] +>; + /** * Simple modal builder params builder * @param client @@ -30,7 +60,7 @@ export function modalBuilder( login?: LoginModalStepType["params"]; openSession?: OpenInteractionSessionModalStepType["params"]; } -): ModalStepBuilder<[LoginModalStepType, OpenInteractionSessionModalStepType]> { +): ModalBuilder { // Build the initial modal params const baseParams: DisplayModalParamsType< [LoginModalStepType, OpenInteractionSessionModalStepType] @@ -46,27 +76,6 @@ export function modalBuilder( return modalStepsBuilder(client, baseParams); } -/** - * Represent the type of the modal step builder - */ -type ModalStepBuilder = { - params: DisplayModalParamsType; - sendTx: ( - options: SendTransactionModalStepType["params"] - ) => ModalStepBuilder<[...Steps, SendTransactionModalStepType]>; - reward: ( - options?: Omit - ) => ModalStepBuilder<[...Steps, FinalModalStepType]>; - sharing: ( - sharingOptions?: Extract< - FinalActionType, - { key: "sharing" } - >["options"], - options?: Omit - ) => ModalStepBuilder<[...Steps, FinalModalStepType]>; - display: () => Promise>; -}; - /** * Build builder helping to add steps to the modal * @param client