commit 807ac75be936f39fe163540efa0562c8010563ed Author: lzs Date: Fri Jun 19 00:53:13 2026 +0800 chore: initialize dreame shopify app diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..27aa38f --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +SHOPIFY_API_KEY= +SHOPIFY_API_SECRET= +SHOPIFY_APP_URL= +SCOPES=read_orders,read_metaobjects +DATABASE_URL="file:./dev.sqlite" +THIRD_PARTY_MODE=mock +THIRD_PARTY_TIMEOUT_MS=5000 +LOG_RETENTION_DAYS=7 +METAOBJECT_TYPE=dreame_activity +METAOBJECT_HANDLE=dreame-activity-config +DREAME_MOCK_STATUS_CODE=0 +DREAME_MOCK_STATUS_ACTIVE=true +DREAME_MOCK_PUSH_CODE=0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a78f9f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules +.env +.env.* +!.env.example +build +.shopify +.turbo +coverage +prisma/dev.sqlite +prisma/dev.sqlite-journal +extensions/**/dist +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..16d1bdb --- /dev/null +++ b/README.md @@ -0,0 +1,292 @@ +# Dreame Shopify App + +Shopify custom app scaffold for the Dreame Thank You Page activity flow. + +This project follows: + +- `/初始化需求/追觅Shopify App Codex编码方案指令.md` +- Scope: local runnable code and deployment documentation only. +- Out of scope: real deployment, production database provisioning, real third-party API integration, real test orders. + +## What This App Does + +1. Customer lands on Shopify Thank You Page. +2. Checkout UI Extension target `purchase.thank-you.block.render` loads. +3. Extension gets `orderId`, `orderNumber`, `locale`, and a Shopify session token. +4. Extension calls `POST /api/action/activity/check`. +5. Backend validates the checkout session token, queries order data through Admin GraphQL, reads the `dreame_activity` Metaobject config, checks order-level idempotency, calls Dreame status API, then calls Dreame push API. +6. On success, backend returns `success`, `redirectUrl`, and localized `buttonText`. +7. Extension renders a button and opens `redirectUrl` in the current window. + +Failures return `{ "success": false }`; the extension renders nothing and does not block the native Thank You Page. + +## Local Setup + +The Shopify CLI was not available in this workspace, and remote `npm create @shopify/app@latest` execution was blocked by local safety policy. The project has therefore been initialized as a local scaffold matching the requested Shopify Remix structure. + +When network access is available: + +```bash +cd "/Users/jason/Downloads/myproject/Shopify 插件开发/dreame-shopify-app" +npm install +cp .env.example .env +npx prisma generate +npx prisma migrate dev +shopify app dev +``` + +Install the app to the development shop: + +```text +function-613wh0ez.myshopify.com +``` + +## Environment + +Copy `.env.example` to `.env` and fill Shopify credentials: + +```env +SHOPIFY_API_KEY= +SHOPIFY_API_SECRET= +SHOPIFY_APP_URL= +SCOPES=read_orders,read_metaobjects +DATABASE_URL="file:./dev.sqlite" +THIRD_PARTY_MODE=mock +THIRD_PARTY_TIMEOUT_MS=5000 +LOG_RETENTION_DAYS=7 +METAOBJECT_TYPE=dreame_activity +METAOBJECT_HANDLE=dreame-activity-config +``` + +`THIRD_PARTY_MODE=mock` is the default because third-party APIs, signing samples, and test orders were not provided. + +## Metaobject Configuration + +Create one Metaobject definition: + +- Type: `dreame_activity` +- Suggested handle: `dreame-activity-config` +- Storefront access: `NONE` + +Fields: + +| Key | Type | Required | Notes | +| --- | --- | --- | --- | +| `activity_id` | `number_integer` | yes | Dreame activity ID | +| `api_app_id` | `single_line_text_field` | yes | Sent as `dreame-api-app-id` | +| `api_secret` | `single_line_text_field` | yes | Backend only; never expose to extension | +| `status_api_url` | `url` | yes | Activity status API | +| `push_api_url` | `url` | yes | Activity push API | +| `redirect_url` | `url` | yes | Button target URL | +| `button_text` | `json` | yes | Locale map | +| `shop` | `single_line_text_field` | no | Overrides `properties.shop`; fallback is myshopify domain | + +Example `button_text`: + +```json +{ + "en": "Claim Your Reward", + "zh-CN": "领取奖励", + "zh-TW": "領取獎勵", + "de": "Belohnung erhalten", + "fr": "Réclamer votre récompense" +} +``` + +Optional setup script: + +```bash +SHOPIFY_SHOP_DOMAIN=function-613wh0ez.myshopify.com \ +SHOPIFY_ADMIN_ACCESS_TOKEN=shpat_xxx \ +npx tsx scripts/setup-metaobject.ts +``` + +The optional script requires write scopes such as `write_metaobjects` and `write_metaobject_definitions`. It is not needed if the Metaobject is created manually in Shopify Admin. + +## API Contract + +Endpoint: + +```text +POST /api/action/activity/check +``` + +Headers: + +```text +Authorization: Bearer +Content-Type: application/json +``` + +Request: + +```json +{ + "orderId": "gid://shopify/Order/1234567890", + "orderNumber": "#1001", + "locale": "zh-CN" +} +``` + +Success response: + +```json +{ + "success": true, + "redirectUrl": "https://activity.example.com", + "buttonText": "领取奖励" +} +``` + +Failure response: + +```json +{ + "success": false +} +``` + +CORS headers are returned for `POST` and `OPTIONS`; source trust relies on Shopify checkout session token validation. + +## Dreame Signing + +All third-party requests include: + +- `dreame-api-app-id` +- `dreame-api-timestamp` +- `dreame-api-sign` + +Signature: + +```text +sign = MD5(bodyString + timestamp + api_secret) +``` + +The implementation serializes the payload once with `JSON.stringify(payload)`. The exact same `bodyString` is used both for signing and as the `fetch` body. + +## Mock Mode + +Default mock behavior: + +- Status API returns `code=0` and `data.status=true`. +- Push API returns `code=0`. + +Useful mock environment variables: + +```env +DREAME_MOCK_STATUS_CODE=0 +DREAME_MOCK_STATUS_ACTIVE=true +DREAME_MOCK_PUSH_CODE=0 +DREAME_MOCK_TIMEOUT=false +``` + +Switch to live calls only after third-party endpoints and credentials are available: + +```env +THIRD_PARTY_MODE=live +``` + +## Database + +Development uses SQLite: + +```env +DATABASE_URL="file:./dev.sqlite" +``` + +Production should use Postgres. The same Prisma schema applies after changing the datasource provider and connection string as part of production hardening. + +Tables added by this scaffold: + +- `ActivityPushLog`: one row per status/push attempt or failure. +- `ActivityPushIdempotency`: unique key on `shop + orderId + activityId`; prevents duplicate push for the same order and activity. + +Logs are sanitized before persistence and must not store `api_secret` or `dreame-api-sign`. + +## Checkout UI Extension + +Location: + +```text +extensions/thank-you-activity +``` + +Target: + +```text +purchase.thank-you.block.render +``` + +Capabilities: + +```toml +[extensions.capabilities] +network_access = true +``` + +Set the extension setting `app_url` to the public HTTPS backend URL. During `shopify app dev`, this is the tunnel URL produced by Shopify CLI. + +## Tests + +After dependencies are installed: + +```bash +npm test +npm run typecheck +``` + +Current test coverage: + +- `test/signature.test.ts`: MD5 signature and single-serialization body contract. +- `test/idempotency.test.ts`: same order/activity does not push twice and still returns button data. +- `test/activity-flow.test.ts`: inactive status, non-zero status code, successful push payload shape, push failure, missing email, missing customer ID. + +## Deployment Steps + +This task does not deploy. For production deployment later: + +1. Provision Postgres. +2. Set production environment variables. +3. Update `shopify.app.toml` URLs. +4. Run Prisma migration against production. +5. Run `shopify app deploy`. +6. Publish the Checkout UI Extension. +7. Configure the extension `app_url` setting. +8. Create the `dreame_activity` Metaobject in the production shop. +9. Switch `THIRD_PARTY_MODE=live`. +10. Run end-to-end validation with real test orders and third-party test endpoints. + +## Security Notes + +- `api_secret` is backend-only. +- The extension never reads Metaobject fields directly. +- Third-party calls are always made by the backend. +- Frontend-provided order data is not trusted; backend queries Shopify Admin GraphQL. +- Logs are sanitized and pruned after `LOG_RETENTION_DAYS` days. + +## Acceptance Coverage + +| Requirement | Coverage | +| --- | --- | +| Thank You Page loads extension | `extensions/thank-you-activity` | +| Page automatically calls backend | `Checkout.tsx` effect | +| Backend reads configuration | `config.server.ts` | +| Status API called | `dreame-client.server.ts`, `activity.server.ts` | +| `status=true` calls push API | `activity.server.ts`, tests | +| Push success returns `success=true` | `activity-flow.test.ts` | +| Button displays | `Checkout.tsx` | +| Button supports localization | `locale.ts`, `activity.server.ts` | +| Button redirects to configured URL | `Checkout.tsx` | +| Same order is not pushed twice | `idempotency.server.ts`, `idempotency.test.ts` | +| Calls are logged | `logger.server.ts` | +| Third-party signing is correct | `signature.server.ts`, `signature.test.ts` | +| Sensitive data is not exposed to frontend | backend-only config and sanitized logs | + +## Out of Scope For This Phase + +- Order Status Page. +- `orders/paid` webhook fallback. +- App admin UI for managing Metaobjects. +- Actual deployment. +- Real third-party API validation. +- Real Shopify test order validation. diff --git a/app/db.server.ts b/app/db.server.ts new file mode 100644 index 0000000..c880753 --- /dev/null +++ b/app/db.server.ts @@ -0,0 +1,14 @@ +import { PrismaClient } from "@prisma/client"; + +declare global { + // eslint-disable-next-line no-var + var __dreamePrisma: PrismaClient | undefined; +} + +const db = global.__dreamePrisma ?? new PrismaClient(); + +if (process.env.NODE_ENV !== "production") { + global.__dreamePrisma = db; +} + +export default db; diff --git a/app/lib/locale.ts b/app/lib/locale.ts new file mode 100644 index 0000000..36b473e --- /dev/null +++ b/app/lib/locale.ts @@ -0,0 +1,26 @@ +export type ButtonTextMap = Record; + +export function selectButtonText(locale: string | undefined, texts: ButtonTextMap): string { + const normalizedLocale = locale?.trim(); + if (normalizedLocale && texts[normalizedLocale]) { + return texts[normalizedLocale]; + } + + const language = normalizedLocale?.split("-")[0]; + if (language) { + if (texts[language]) { + return texts[language]; + } + + const languageMatch = Object.entries(texts).find(([key]) => key.split("-")[0] === language); + if (languageMatch) { + return languageMatch[1]; + } + } + + if (texts.en) { + return texts.en; + } + + return Object.values(texts)[0] ?? ""; +} diff --git a/app/root.tsx b/app/root.tsx new file mode 100644 index 0000000..b2f8e26 --- /dev/null +++ b/app/root.tsx @@ -0,0 +1,19 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react"; + +export default function App() { + return ( + + + + + + + + + + + + + + ); +} diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx new file mode 100644 index 0000000..b94c1a5 --- /dev/null +++ b/app/routes/_index.tsx @@ -0,0 +1,34 @@ +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +import { authenticate } from "../shopify.server"; + +export async function loader({ request }: LoaderFunctionArgs) { + try { + await authenticate.admin(request); + } catch { + // Local mock development can still render this status page before app auth is configured. + } + + return json({ + appName: "Dreame Activity", + mode: process.env.THIRD_PARTY_MODE || "mock", + }); +} + +export default function Index() { + const data = useLoaderData(); + + return ( +
+

{data.appName}

+

Shopify Thank You Page activity app scaffold.

+

+ Third party mode: {data.mode} +

+

+ Configure Metaobject dreame_activity, install the Checkout UI Extension, and use mock mode for + local validation before live integration. +

+
+ ); +} diff --git a/app/routes/api.action.activity.check.tsx b/app/routes/api.action.activity.check.tsx new file mode 100644 index 0000000..6b3e31d --- /dev/null +++ b/app/routes/api.action.activity.check.tsx @@ -0,0 +1,104 @@ +import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; +import { v4 as uuidv4 } from "uuid"; +import { authenticate, unauthenticated } from "../shopify.server"; +import { runActivityCheck } from "../services/activity.server"; +import { getActivityConfig } from "../services/config.server"; +import { callPush, callStatus } from "../services/dreame-client.server"; +import { findSuccessfulPush, recordSuccessfulPush } from "../services/idempotency.server"; +import { logActivityPush } from "../services/logger.server"; +import { getOrderDetails } from "../services/order.server"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +type ActivityRequestBody = { + orderId?: string; + orderNumber?: string; + locale?: string; +}; + +export async function loader({ request }: LoaderFunctionArgs) { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + return json({ success: false }, { status: 405, headers: CORS_HEADERS }); +} + +export async function action({ request }: ActionFunctionArgs) { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + if (request.method !== "POST") { + return json({ success: false }, { status: 405, headers: CORS_HEADERS }); + } + + try { + const checkoutAuth = (authenticate as unknown as { public?: { checkout?: (request: Request) => Promise } }) + .public?.checkout; + if (!checkoutAuth) { + throw new Error("Shopify checkout authentication is unavailable"); + } + + const authResult = (await checkoutAuth(request)) as { + shop?: string; + admin?: unknown; + sessionToken?: { dest?: string; iss?: string }; + session?: { shop?: string }; + }; + const shop = authResult.shop || authResult.session?.shop || shopFromSessionToken(authResult.sessionToken); + const adminResult = authResult.admin ? { admin: authResult.admin } : shop ? await unauthenticated.admin(shop) : null; + const admin = adminResult?.admin as { + graphql: (query: string, options?: { variables?: Record }) => Promise; + }; + if (!shop || !admin) { + throw new Error("Invalid Shopify checkout session"); + } + + const body = (await request.json()) as ActivityRequestBody; + if (!body.orderId) { + return json({ success: false }, { headers: CORS_HEADERS }); + } + + const result = await runActivityCheck( + { + shop, + orderId: body.orderId, + orderNumber: body.orderNumber, + locale: body.locale, + }, + { + nowMs: () => Date.now(), + uuid: uuidv4, + getOrder: (_shop, orderId) => getOrderDetails(admin, orderId), + getConfig: () => getActivityConfig(admin), + findSuccess: findSuccessfulPush, + recordSuccess: recordSuccessfulPush, + log: logActivityPush, + callStatus, + callPush, + }, + ); + + return json(result, { headers: CORS_HEADERS }); + } catch { + return json({ success: false }, { headers: CORS_HEADERS }); + } +} + +function shopFromSessionToken(sessionToken: { dest?: string; iss?: string } | undefined): string | undefined { + const source = sessionToken?.dest || sessionToken?.iss; + if (!source) { + return undefined; + } + + try { + return new URL(source).hostname; + } catch { + return source.replace(/^https?:\/\//, "").split("/")[0]; + } +} diff --git a/app/services/activity.server.ts b/app/services/activity.server.ts new file mode 100644 index 0000000..f1db534 --- /dev/null +++ b/app/services/activity.server.ts @@ -0,0 +1,287 @@ +import { selectButtonText } from "../lib/locale"; +import type { ButtonTextMap } from "../lib/locale"; + +export type ActivityRequest = { + shop: string; + orderId: string; + orderNumber?: string; + locale?: string; +}; + +export type ActivityConfig = { + activityId: number; + apiAppId: string; + apiSecret: string; + statusApiUrl: string; + pushApiUrl: string; + redirectUrl: string; + buttonText: ButtonTextMap; + shop?: string; +}; + +export type OrderDetails = { + id: string; + legacyId: string; + orderNumber?: string; + email?: string; + customerId?: string; + currency: string; + shopDomain: string; +}; + +export type StatusResponse = { + code?: number; + data?: { + status?: boolean; + trigger?: string[]; + }; + msg?: string; +}; + +export type PushResponse = { + code?: number; + data?: unknown; + msg?: string; +}; + +export type ThirdPartyResult = { + httpOk: boolean; + status: number; + body: TBody; + requestBody: string; + responseBody: string; +}; + +export type PushPayload = { + activity_id: number; + event_type: "order_end"; + created_at: number; + user_id: string; + email: string; + event_id: string; + properties: { + shop: string; + currency: string; + order_id: string; + order_number: string; + }; +}; + +export type LogEntry = { + shop: string; + orderId: string; + customerId?: string; + email?: string; + activityId: string; + eventType: string; + eventId: string; + status: "success" | "fail"; + requestBody?: string; + responseBody?: string; + errorMessage?: string; +}; + +export type IdempotencyHit = { + eventId: string; + redirectUrl: string; +}; + +export type ActivityDeps = { + nowMs: () => number; + uuid: () => string; + getOrder: (shop: string, orderId: string) => Promise; + getConfig: (shop: string) => Promise; + findSuccess: (shop: string, orderId: string, activityId: number) => Promise; + recordSuccess: (entry: { + shop: string; + orderId: string; + activityId: number; + eventType: "order_end"; + eventId: string; + redirectUrl: string; + buttonText: string; + }) => Promise; + log: (entry: LogEntry) => Promise; + callStatus: (config: ActivityConfig) => Promise>; + callPush: (config: ActivityConfig, payload: PushPayload) => Promise>; +}; + +export type ActivityResponse = + | { + success: true; + redirectUrl: string; + buttonText: string; + } + | { + success: false; + }; + +const ORDER_END_EVENT = "order_end" as const; + +export async function runActivityCheck(request: ActivityRequest, deps: ActivityDeps): Promise { + const normalizedOrderId = normalizeOrderId(request.orderId); + let order: OrderDetails | undefined; + let config: ActivityConfig | undefined; + let eventId = ""; + + try { + order = await deps.getOrder(request.shop, normalizedOrderId); + config = await deps.getConfig(request.shop); + + const buttonText = selectButtonText(request.locale, config.buttonText); + const idempotency = await deps.findSuccess(request.shop, normalizedOrderId, config.activityId); + if (idempotency) { + return { + success: true, + redirectUrl: config.redirectUrl, + buttonText, + }; + } + + if (!order.email) { + await deps.log(failureLog(request.shop, order, config, eventId, "Order email is missing")); + return { success: false }; + } + + if (!order.customerId) { + await deps.log(failureLog(request.shop, order, config, eventId, "Order customer id is missing")); + return { success: false }; + } + + const statusResult = await deps.callStatus(config); + if (!isActiveStatus(statusResult)) { + await deps.log( + failureLog( + request.shop, + order, + config, + eventId, + "Activity status API did not return active status", + statusResult.requestBody, + statusResult.responseBody, + ), + ); + return { success: false }; + } + + eventId = deps.uuid(); + const pushPayload: PushPayload = { + activity_id: config.activityId, + event_type: ORDER_END_EVENT, + created_at: deps.nowMs(), + user_id: order.customerId, + email: order.email, + event_id: eventId, + properties: { + shop: config.shop || order.shopDomain, + currency: order.currency, + order_id: order.legacyId, + order_number: order.orderNumber || request.orderNumber || order.legacyId, + }, + }; + + const pushResult = await deps.callPush(config, pushPayload); + if (!isSuccessfulPush(pushResult)) { + await deps.log( + failureLog( + request.shop, + order, + config, + eventId, + "Activity push API failed", + pushResult.requestBody, + pushResult.responseBody, + ), + ); + return { success: false }; + } + + await deps.recordSuccess({ + shop: request.shop, + orderId: normalizedOrderId, + activityId: config.activityId, + eventType: ORDER_END_EVENT, + eventId, + redirectUrl: config.redirectUrl, + buttonText, + }); + + await deps.log({ + shop: request.shop, + orderId: normalizedOrderId, + customerId: order.customerId, + email: order.email, + activityId: config.activityId.toString(), + eventType: ORDER_END_EVENT, + eventId, + status: "success", + requestBody: pushResult.requestBody, + responseBody: pushResult.responseBody, + }); + + return { + success: true, + redirectUrl: config.redirectUrl, + buttonText, + }; + } catch (error) { + await safeLog(deps, failureLog(request.shop, order, config, eventId, toErrorMessage(error))); + return { success: false }; + } +} + +export function normalizeOrderId(orderId: string): string { + if (orderId.startsWith("gid://shopify/Order/")) { + return orderId; + } + + return `gid://shopify/Order/${orderId.replace(/[^0-9]/g, "")}`; +} + +export function extractLegacyId(orderId: string): string { + return orderId.split("/").pop() || orderId; +} + +function isActiveStatus(result: ThirdPartyResult): boolean { + return result.httpOk && result.body.code === 0 && result.body.data?.status === true; +} + +function isSuccessfulPush(result: ThirdPartyResult): boolean { + return result.httpOk && result.body.code === 0; +} + +function failureLog( + shop: string, + order: OrderDetails | undefined, + config: ActivityConfig | undefined, + eventId: string, + errorMessage: string, + requestBody?: string, + responseBody?: string, +): LogEntry { + return { + shop, + orderId: order?.id || "", + customerId: order?.customerId, + email: order?.email, + activityId: config?.activityId.toString() || "", + eventType: ORDER_END_EVENT, + eventId, + status: "fail", + requestBody, + responseBody, + errorMessage, + }; +} + +async function safeLog(deps: ActivityDeps, entry: LogEntry): Promise { + try { + await deps.log(entry); + } catch { + // Logging must not break Thank You Page behavior. + } +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/app/services/config.server.ts b/app/services/config.server.ts new file mode 100644 index 0000000..c0dbd56 --- /dev/null +++ b/app/services/config.server.ts @@ -0,0 +1,77 @@ +import type { ActivityConfig } from "./activity.server"; + +type AdminGraphqlClient = { + graphql: (query: string, options?: { variables?: Record }) => Promise; +}; + +type MetaobjectField = { + key: string; + value: string | null; +}; + +const METAOBJECT_QUERY = `#graphql + query DreameActivityConfig($type: String!, $handle: String!) { + metaobjectByHandle(handle: { type: $type, handle: $handle }) { + fields { + key + value + } + } + } +`; + +export async function getActivityConfig(admin: AdminGraphqlClient): Promise { + const type = process.env.METAOBJECT_TYPE || "dreame_activity"; + const handle = process.env.METAOBJECT_HANDLE || "dreame-activity-config"; + const response = await admin.graphql(METAOBJECT_QUERY, { variables: { type, handle } }); + const payload = (await response.json()) as { + data?: { metaobjectByHandle?: { fields?: MetaobjectField[] } | null }; + errors?: unknown; + }; + + const fields = payload.data?.metaobjectByHandle?.fields; + if (!fields?.length) { + throw new Error(`Dreame activity metaobject not found: ${type}/${handle}`); + } + + const values = Object.fromEntries(fields.map((field) => [field.key, field.value])); + const buttonText = parseButtonText(values.button_text); + const activityId = Number(values.activity_id); + + if (!Number.isFinite(activityId)) { + throw new Error("Metaobject field activity_id is required"); + } + + return { + activityId, + apiAppId: requireString(values.api_app_id, "api_app_id"), + apiSecret: requireString(values.api_secret, "api_secret"), + statusApiUrl: requireString(values.status_api_url, "status_api_url"), + pushApiUrl: requireString(values.push_api_url, "push_api_url"), + redirectUrl: requireString(values.redirect_url, "redirect_url"), + buttonText, + shop: values.shop || undefined, + }; +} + +function requireString(value: string | null | undefined, key: string): string { + if (!value) { + throw new Error(`Metaobject field ${key} is required`); + } + + return value; +} + +function parseButtonText(value: string | null | undefined): Record { + if (!value) { + throw new Error("Metaobject field button_text is required"); + } + + const parsed = JSON.parse(value) as Record; + const entries = Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string"); + if (!entries.length) { + throw new Error("Metaobject field button_text must contain at least one locale"); + } + + return Object.fromEntries(entries); +} diff --git a/app/services/dreame-client.server.ts b/app/services/dreame-client.server.ts new file mode 100644 index 0000000..4c662f5 --- /dev/null +++ b/app/services/dreame-client.server.ts @@ -0,0 +1,96 @@ +import { buildSignedRequest } from "./signature.server"; +import type { ActivityConfig, PushPayload, PushResponse, StatusResponse, ThirdPartyResult } from "./activity.server"; + +export async function callStatus(config: ActivityConfig): Promise> { + const payload = { activity_id: config.activityId }; + return callDreame(config.statusApiUrl, config, payload, mockStatusResponse); +} + +export async function callPush(config: ActivityConfig, payload: PushPayload): Promise> { + return callDreame(config.pushApiUrl, config, payload, mockPushResponse); +} + +async function callDreame( + url: string, + config: ActivityConfig, + payload: unknown, + mockFactory: () => TBody, +): Promise> { + const signed = buildSignedRequest(payload, { + apiAppId: config.apiAppId, + apiSecret: config.apiSecret, + }); + + if ((process.env.THIRD_PARTY_MODE || "mock") === "mock") { + if (process.env.DREAME_MOCK_TIMEOUT === "true") { + return { + httpOk: false, + status: 408, + body: { code: 408, msg: "mock timeout" } as TBody, + requestBody: signed.bodyString, + responseBody: '{"code":408,"msg":"mock timeout"}', + }; + } + + const body = mockFactory(); + return { + httpOk: true, + status: 200, + body, + requestBody: signed.bodyString, + responseBody: JSON.stringify(body), + }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Number(process.env.THIRD_PARTY_TIMEOUT_MS || 5000)); + + try { + const response = await fetch(url, { + method: "POST", + headers: signed.headers, + body: signed.bodyString, + signal: controller.signal, + }); + const responseText = await response.text(); + const body = parseJson(responseText) as TBody; + + return { + httpOk: response.ok, + status: response.status, + body, + requestBody: signed.bodyString, + responseBody: responseText, + }; + } finally { + clearTimeout(timeout); + } +} + +function mockStatusResponse(): StatusResponse { + return { + code: Number(process.env.DREAME_MOCK_STATUS_CODE || 0), + data: { + status: process.env.DREAME_MOCK_STATUS_ACTIVE !== "false", + trigger: ["order_end", "card_add"], + }, + }; +} + +function mockPushResponse(): PushResponse { + return { + code: Number(process.env.DREAME_MOCK_PUSH_CODE || 0), + }; +} + +function parseJson(text: string): unknown { + if (!text) { + return {}; + } + + try { + return JSON.parse(text); + } catch { + return { code: -1, msg: text }; + } +} diff --git a/app/services/idempotency.server.ts b/app/services/idempotency.server.ts new file mode 100644 index 0000000..f6a7558 --- /dev/null +++ b/app/services/idempotency.server.ts @@ -0,0 +1,57 @@ +import db from "../db.server"; + +export async function findSuccessfulPush(shop: string, orderId: string, activityId: number) { + const record = await db.activityPushIdempotency.findUnique({ + where: { + shop_orderId_activityId: { + shop, + orderId, + activityId: activityId.toString(), + }, + }, + }); + + if (!record) { + return null; + } + + return { + eventId: record.eventId, + redirectUrl: record.redirectUrl, + }; +} + +export async function recordSuccessfulPush(entry: { + shop: string; + orderId: string; + activityId: number; + eventType: "order_end"; + eventId: string; + redirectUrl: string; + buttonText: string; +}) { + await db.activityPushIdempotency.upsert({ + where: { + shop_orderId_activityId: { + shop: entry.shop, + orderId: entry.orderId, + activityId: entry.activityId.toString(), + }, + }, + update: { + eventId: entry.eventId, + redirectUrl: entry.redirectUrl, + buttonText: entry.buttonText, + pushedAt: new Date(), + }, + create: { + shop: entry.shop, + orderId: entry.orderId, + activityId: entry.activityId.toString(), + eventType: entry.eventType, + eventId: entry.eventId, + redirectUrl: entry.redirectUrl, + buttonText: entry.buttonText, + }, + }); +} diff --git a/app/services/logger.server.ts b/app/services/logger.server.ts new file mode 100644 index 0000000..147c4d4 --- /dev/null +++ b/app/services/logger.server.ts @@ -0,0 +1,66 @@ +import db from "../db.server"; +import type { LogEntry } from "./activity.server"; + +const SENSITIVE_KEYS = new Set(["api_secret", "app_secret", "apiSecret", "dreame-api-sign", "sign", "signature"]); + +export async function logActivityPush(entry: LogEntry): Promise { + await db.activityPushLog.create({ + data: { + shop: entry.shop, + orderId: entry.orderId || "unknown", + customerId: entry.customerId, + email: entry.email, + activityId: entry.activityId || "unknown", + eventType: entry.eventType, + eventId: entry.eventId || "not-generated", + status: entry.status, + requestBody: sanitizeBody(entry.requestBody), + responseBody: sanitizeBody(entry.responseBody), + errorMessage: entry.errorMessage, + }, + }); + + if (Math.random() < 0.05) { + await pruneLogs(); + } +} + +export async function pruneLogs(days = Number(process.env.LOG_RETENTION_DAYS || 7)): Promise { + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + await db.activityPushLog.deleteMany({ + where: { + createdAt: { + lt: cutoff, + }, + }, + }); +} + +export function sanitizeBody(body: string | undefined): string | undefined { + if (!body) { + return undefined; + } + + try { + return JSON.stringify(maskSensitive(JSON.parse(body))); + } catch { + return body.replace(/[a-f0-9]{32}/gi, "[redacted-md5]"); + } +} + +function maskSensitive(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(maskSensitive); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, child]) => [ + key, + SENSITIVE_KEYS.has(key) ? "[redacted]" : maskSensitive(child), + ]), + ); + } + + return value; +} diff --git a/app/services/order.server.ts b/app/services/order.server.ts new file mode 100644 index 0000000..e4e8ea1 --- /dev/null +++ b/app/services/order.server.ts @@ -0,0 +1,59 @@ +import { extractLegacyId, normalizeOrderId } from "./activity.server"; +import type { OrderDetails } from "./activity.server"; + +type AdminGraphqlClient = { + graphql: (query: string, options?: { variables?: Record }) => Promise; +}; + +const ORDER_QUERY = `#graphql + query DreameOrder($id: ID!) { + order: node(id: $id) { + ... on Order { + id + name + email + currencyCode + customer { + id + } + } + } + shop { + myshopifyDomain + } + } +`; + +export async function getOrderDetails(admin: AdminGraphqlClient, orderId: string): Promise { + const normalizedOrderId = normalizeOrderId(orderId); + const response = await admin.graphql(ORDER_QUERY, { variables: { id: normalizedOrderId } }); + const payload = (await response.json()) as { + data?: { + order?: { + id: string; + name?: string; + email?: string; + currencyCode?: string; + customer?: { id?: string } | null; + } | null; + shop?: { + myshopifyDomain?: string; + }; + }; + }; + + const order = payload.data?.order; + if (!order) { + throw new Error(`Order not found: ${normalizedOrderId}`); + } + + return { + id: order.id, + legacyId: extractLegacyId(order.id), + orderNumber: order.name, + email: order.email || undefined, + customerId: order.customer?.id ? extractLegacyId(order.customer.id) : undefined, + currency: order.currencyCode || "", + shopDomain: payload.data?.shop?.myshopifyDomain || "", + }; +} diff --git a/app/services/signature.server.ts b/app/services/signature.server.ts new file mode 100644 index 0000000..4f00088 --- /dev/null +++ b/app/services/signature.server.ts @@ -0,0 +1,35 @@ +import { createHash } from "crypto"; + +export type SignOptions = { + apiAppId: string; + apiSecret: string; + timestamp?: string; +}; + +export type SignedRequest = { + bodyString: string; + headers: Record; +}; + +export function stringifyBody(payload: unknown): string { + return JSON.stringify(payload); +} + +export function dreameSign(bodyString: string, timestamp: string, apiSecret: string): string { + return createHash("md5").update(Buffer.from(bodyString + timestamp + apiSecret, "utf8")).digest("hex"); +} + +export function buildSignedRequest(payload: unknown, options: SignOptions): SignedRequest { + const bodyString = stringifyBody(payload); + const timestamp = options.timestamp ?? Date.now().toString(); + + return { + bodyString, + headers: { + "content-type": "application/json", + "dreame-api-app-id": options.apiAppId, + "dreame-api-timestamp": timestamp, + "dreame-api-sign": dreameSign(bodyString, timestamp, options.apiSecret), + }, + }; +} diff --git a/app/shopify.server.ts b/app/shopify.server.ts new file mode 100644 index 0000000..4d6f7d3 --- /dev/null +++ b/app/shopify.server.ts @@ -0,0 +1,23 @@ +import "@shopify/shopify-app-remix/adapters/node"; +import { AppDistribution, shopifyApp } from "@shopify/shopify-app-remix/server"; +import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma"; +import db from "./db.server"; + +const shopify = shopifyApp({ + apiKey: process.env.SHOPIFY_API_KEY || "", + apiSecretKey: process.env.SHOPIFY_API_SECRET || "", + apiVersion: "2026-01" as never, + scopes: (process.env.SCOPES || "read_orders,read_metaobjects").split(","), + appUrl: process.env.SHOPIFY_APP_URL || "", + authPathPrefix: "/auth", + sessionStorage: new PrismaSessionStorage(db), + distribution: AppDistribution.Custom, + future: { + unstable_newEmbeddedAuthStrategy: true, + }, +}); + +export default shopify; +export const authenticate = shopify.authenticate; +export const unauthenticated = shopify.unauthenticated; +export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders; diff --git a/extensions/thank-you-activity/shopify.extension.toml b/extensions/thank-you-activity/shopify.extension.toml new file mode 100644 index 0000000..b26a7f5 --- /dev/null +++ b/extensions/thank-you-activity/shopify.extension.toml @@ -0,0 +1,20 @@ +api_version = "2026-01" + +[[extensions]] +name = "Dreame Thank You Activity" +handle = "thank-you-activity" +type = "ui_extension" + +[[extensions.targeting]] +module = "./src/Checkout.tsx" +target = "purchase.thank-you.block.render" + +[extensions.capabilities] +network_access = true + +[extensions.settings] +[[extensions.settings.fields]] +key = "app_url" +type = "single_line_text_field" +name = "App URL" +description = "Public HTTPS URL for the Shopify App backend, for example https://app.example.com" diff --git a/extensions/thank-you-activity/src/Checkout.tsx b/extensions/thank-you-activity/src/Checkout.tsx new file mode 100644 index 0000000..ec1318e --- /dev/null +++ b/extensions/thank-you-activity/src/Checkout.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { reactExtension, Button, InlineStack, useApi } from "@shopify/ui-extensions-react/checkout"; + +type ActivityResponse = + | { + success: true; + redirectUrl: string; + buttonText: string; + } + | { + success: false; + }; + +export default reactExtension("purchase.thank-you.block.render", () => ); + +function DreameActivity() { + const api = useApi<"purchase.thank-you.block.render">() as any; + const [response, setResponse] = useState({ success: false }); + + useEffect(() => { + let cancelled = false; + + async function checkActivity() { + const appUrl = String(api.settings?.current?.app_url || "").replace(/\/$/, ""); + const order = api.orderConfirmation?.current?.order || api.orderConfirmation?.value?.order; + const orderId = order?.id; + if (!appUrl || !orderId) { + return; + } + + try { + const token = await api.sessionToken.get(); + const locale = + api.localization?.current?.language?.isoCode || + api.localization?.value?.language?.isoCode || + api.i18n?.locale || + "en"; + const result = (await fetch(`${appUrl}/api/action/activity/check`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + orderId, + orderNumber: order?.name, + locale, + }), + }).then((res) => res.json())) as ActivityResponse; + + if (!cancelled) { + setResponse(result.success ? result : { success: false }); + } + } catch { + if (!cancelled) { + setResponse({ success: false }); + } + } + } + + void checkActivity(); + + return () => { + cancelled = true; + }; + }, [api]); + + if (!response.success) { + return null; + } + + return ( + + + + ); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5fc0d7b --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "dreame-shopify-app", + "private": true, + "scripts": { + "build": "remix vite:build", + "dev": "shopify app dev", + "start": "remix-serve ./build/server/index.js", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:studio": "prisma studio" + }, + "dependencies": { + "@prisma/client": "^6.10.1", + "@remix-run/node": "^2.16.8", + "@remix-run/react": "^2.16.8", + "@remix-run/serve": "^2.16.8", + "@shopify/app-bridge-react": "^4.2.2", + "@shopify/polaris": "^12.27.0", + "@shopify/shopify-app-remix": "^3.8.2", + "@shopify/shopify-app-session-storage-prisma": "^6.0.0", + "@shopify/ui-extensions-react": "^2026.1.0", + "isbot": "^5.1.28", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@remix-run/dev": "^2.16.8", + "@types/node": "^22.15.32", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.5.2", + "prisma": "^6.10.1", + "tsx": "^4.20.3", + "typescript": "^5.8.3", + "vite": "^6.3.5", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..f966a91 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,60 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model Session { + id String @id + shop String + state String + isOnline Boolean @default(false) + scope String? + expires DateTime? + accessToken String + userId BigInt? + firstName String? + lastName String? + email String? + accountOwner Boolean @default(false) + locale String? + collaborator Boolean? @default(false) + emailVerified Boolean? @default(false) +} + +model ActivityPushLog { + id String @id @default(cuid()) + shop String + orderId String + customerId String? + email String? + activityId String + eventType String + eventId String + status String + requestBody String? + responseBody String? + errorMessage String? + createdAt DateTime @default(now()) + + @@index([shop, orderId]) + @@index([createdAt]) +} + +model ActivityPushIdempotency { + id String @id @default(cuid()) + shop String + orderId String + activityId String + eventType String + eventId String + redirectUrl String + buttonText String + pushedAt DateTime @default(now()) + createdAt DateTime @default(now()) + + @@unique([shop, orderId, activityId]) +} diff --git a/scripts/setup-metaobject.ts b/scripts/setup-metaobject.ts new file mode 100644 index 0000000..0d31cb0 --- /dev/null +++ b/scripts/setup-metaobject.ts @@ -0,0 +1,105 @@ +type GraphqlResponse = { + data?: T; + errors?: unknown; +}; + +const shop = requiredEnv("SHOPIFY_SHOP_DOMAIN"); +const token = requiredEnv("SHOPIFY_ADMIN_ACCESS_TOKEN"); +const endpoint = `https://${shop}/admin/api/2026-01/graphql.json`; + +const definitionMutation = `#graphql + mutation DreameMetaobjectDefinitionCreate($definition: MetaobjectDefinitionCreateInput!) { + metaobjectDefinitionCreate(definition: $definition) { + metaobjectDefinition { + id + type + } + userErrors { + field + message + } + } + } +`; + +const metaobjectMutation = `#graphql + mutation DreameMetaobjectCreate($metaobject: MetaobjectCreateInput!) { + metaobjectCreate(metaobject: $metaobject) { + metaobject { + id + handle + } + userErrors { + field + message + } + } + } +`; + +async function main() { + await graphql(definitionMutation, { + definition: { + type: "dreame_activity", + name: "Dreame Activity", + access: { storefront: "NONE" }, + fieldDefinitions: [ + { key: "activity_id", name: "Activity ID", type: "number_integer", required: true }, + { key: "api_app_id", name: "API App ID", type: "single_line_text_field", required: true }, + { key: "api_secret", name: "API Secret", type: "single_line_text_field", required: true }, + { key: "status_api_url", name: "Status API URL", type: "url", required: true }, + { key: "push_api_url", name: "Push API URL", type: "url", required: true }, + { key: "redirect_url", name: "Redirect URL", type: "url", required: true }, + { key: "button_text", name: "Button Text", type: "json", required: true }, + { key: "shop", name: "Shop Override", type: "single_line_text_field", required: false }, + ], + }, + }); + + await graphql(metaobjectMutation, { + metaobject: { + type: "dreame_activity", + handle: "dreame-activity-config", + fields: [ + { key: "activity_id", value: "12" }, + { key: "api_app_id", value: "mock-app-id" }, + { key: "api_secret", value: "mock-secret" }, + { key: "status_api_url", value: "https://third-party.example/status" }, + { key: "push_api_url", value: "https://third-party.example/push" }, + { key: "redirect_url", value: "https://activity.example.com" }, + { key: "button_text", value: JSON.stringify({ en: "Claim Your Reward", "zh-CN": "领取奖励" }) }, + { key: "shop", value: shop }, + ], + }, + }); +} + +async function graphql(query: string, variables: Record): Promise> { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Shopify-Access-Token": token, + }, + body: JSON.stringify({ query, variables }), + }); + const payload = (await response.json()) as GraphqlResponse; + if (!response.ok || payload.errors) { + throw new Error(JSON.stringify(payload.errors || payload)); + } + console.log(JSON.stringify(payload, null, 2)); + return payload; +} + +function requiredEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/shopify.app.toml b/shopify.app.toml new file mode 100644 index 0000000..ba60aa9 --- /dev/null +++ b/shopify.app.toml @@ -0,0 +1,18 @@ +name = "Dreame Activity" +handle = "dreame-activity" +application_url = "https://example.ngrok-free.app" +embedded = true + +[build] +automatically_update_urls_on_dev = true +include_config_on_deploy = true + +[access_scopes] +scopes = "read_orders,read_metaobjects" + +[auth] +redirect_urls = [ + "https://example.ngrok-free.app/auth/callback", + "https://example.ngrok-free.app/auth/shopify/callback", + "https://example.ngrok-free.app/api/auth/callback" +] diff --git a/test/activity-flow.test.ts b/test/activity-flow.test.ts new file mode 100644 index 0000000..23be6c1 --- /dev/null +++ b/test/activity-flow.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from "vitest"; +import { runActivityCheck } from "../app/services/activity.server"; +import type { ActivityDeps, PushPayload } from "../app/services/activity.server"; + +function baseDeps(overrides: Partial = {}): ActivityDeps { + return { + nowMs: () => 1776901800000, + uuid: () => "00000000-0000-4000-8000-000000000000", + getOrder: async () => ({ + id: "gid://shopify/Order/123", + legacyId: "123", + orderNumber: "#1001", + email: "customer@example.com", + customerId: "99999", + currency: "USD", + shopDomain: "demo.myshopify.com", + }), + getConfig: async () => ({ + activityId: 12, + apiAppId: "app-id", + apiSecret: "secret", + statusApiUrl: "https://third.example/status", + pushApiUrl: "https://third.example/push", + redirectUrl: "https://activity.example.com", + buttonText: { en: "Claim Your Reward", "zh-CN": "领取奖励" }, + shop: "configured-shop", + }), + findSuccess: async () => null, + recordSuccess: async () => undefined, + log: async () => undefined, + callStatus: async () => ({ + httpOk: true, + status: 200, + body: { code: 0, data: { status: true, trigger: ["card_add"] } }, + requestBody: '{"activity_id":12}', + responseBody: '{"code":0,"data":{"status":true}}', + }), + callPush: async () => ({ + httpOk: true, + status: 200, + body: { code: 0 }, + requestBody: "{}", + responseBody: '{"code":0}', + }), + ...overrides, + }; +} + +describe("activity flow", () => { + test("returns false when status api says activity is inactive", async () => { + let pushCalls = 0; + const result = await runActivityCheck( + { shop: "demo.myshopify.com", orderId: "123", orderNumber: "#1001", locale: "en" }, + baseDeps({ + callStatus: async () => ({ + httpOk: true, + status: 200, + body: { code: 0, data: { status: false, trigger: ["order_end"] } }, + requestBody: '{"activity_id":12}', + responseBody: '{"code":0,"data":{"status":false}}', + }), + callPush: async () => { + pushCalls += 1; + return { httpOk: true, status: 200, body: { code: 0 }, requestBody: "{}", responseBody: "{}" }; + }, + }), + ); + + expect(result).toEqual({ success: false }); + expect(pushCalls).toBe(0); + }); + + test("returns false when status api code is not zero", async () => { + const result = await runActivityCheck( + { shop: "demo.myshopify.com", orderId: "123", orderNumber: "#1001", locale: "en" }, + baseDeps({ + callStatus: async () => ({ + httpOk: true, + status: 200, + body: { code: 401, data: {}, msg: "auth required" }, + requestBody: '{"activity_id":12}', + responseBody: '{"code":401}', + }), + }), + ); + + expect(result).toEqual({ success: false }); + }); + + test("pushes order_end with configured shop and no line items", async () => { + let captured: PushPayload | null = null; + const result = await runActivityCheck( + { shop: "demo.myshopify.com", orderId: "gid://shopify/Order/123", orderNumber: "#1001", locale: "zh-CN" }, + baseDeps({ + callPush: async (_config, payload) => { + captured = payload; + return { + httpOk: true, + status: 200, + body: { code: 0 }, + requestBody: JSON.stringify(payload), + responseBody: '{"code":0}', + }; + }, + }), + ); + + expect(result).toEqual({ + success: true, + redirectUrl: "https://activity.example.com", + buttonText: "领取奖励", + }); + expect(captured).toMatchObject({ + activity_id: 12, + event_type: "order_end", + created_at: 1776901800000, + user_id: "99999", + email: "customer@example.com", + event_id: "00000000-0000-4000-8000-000000000000", + properties: { + shop: "configured-shop", + currency: "USD", + order_id: "123", + order_number: "#1001", + }, + }); + expect(captured?.properties).not.toHaveProperty("lineItems"); + expect(captured?.event_id).toMatch(/^[0-9a-f-]{36}$/); + }); + + test("returns false when push api fails", async () => { + const result = await runActivityCheck( + { shop: "demo.myshopify.com", orderId: "123", orderNumber: "#1001", locale: "en" }, + baseDeps({ + callPush: async () => ({ + httpOk: true, + status: 200, + body: { code: 500 }, + requestBody: "{}", + responseBody: '{"code":500}', + }), + }), + ); + + expect(result).toEqual({ success: false }); + }); + + test("does not push when email is missing", async () => { + let pushCalls = 0; + const result = await runActivityCheck( + { shop: "demo.myshopify.com", orderId: "123", orderNumber: "#1001", locale: "en" }, + baseDeps({ + getOrder: async () => ({ + id: "gid://shopify/Order/123", + legacyId: "123", + orderNumber: "#1001", + customerId: "99999", + currency: "USD", + shopDomain: "demo.myshopify.com", + }), + callPush: async () => { + pushCalls += 1; + return { httpOk: true, status: 200, body: { code: 0 }, requestBody: "{}", responseBody: "{}" }; + }, + }), + ); + + expect(result).toEqual({ success: false }); + expect(pushCalls).toBe(0); + }); + + test("does not push when customer id is missing", async () => { + let pushCalls = 0; + const result = await runActivityCheck( + { shop: "demo.myshopify.com", orderId: "123", orderNumber: "#1001", locale: "en" }, + baseDeps({ + getOrder: async () => ({ + id: "gid://shopify/Order/123", + legacyId: "123", + orderNumber: "#1001", + email: "customer@example.com", + currency: "USD", + shopDomain: "demo.myshopify.com", + }), + callPush: async () => { + pushCalls += 1; + return { httpOk: true, status: 200, body: { code: 0 }, requestBody: "{}", responseBody: "{}" }; + }, + }), + ); + + expect(result).toEqual({ success: false }); + expect(pushCalls).toBe(0); + }); +}); diff --git a/test/idempotency.test.ts b/test/idempotency.test.ts new file mode 100644 index 0000000..3d65232 --- /dev/null +++ b/test/idempotency.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "vitest"; +import { runActivityCheck } from "../app/services/activity.server"; +import type { ActivityDeps } from "../app/services/activity.server"; + +function makeDeps(overrides: Partial = {}): ActivityDeps { + let pushed = false; + return { + nowMs: () => 1776901800000, + uuid: () => "00000000-0000-4000-8000-000000000000", + getOrder: async () => ({ + id: "gid://shopify/Order/123", + legacyId: "123", + orderNumber: "#1001", + email: "customer@example.com", + customerId: "99999", + currency: "USD", + shopDomain: "demo.myshopify.com", + }), + getConfig: async () => ({ + activityId: 12, + apiAppId: "app-id", + apiSecret: "secret", + statusApiUrl: "https://third.example/status", + pushApiUrl: "https://third.example/push", + redirectUrl: "https://activity.example.com", + buttonText: { en: "Claim Your Reward", "zh-CN": "领取奖励" }, + shop: "configured-shop", + }), + findSuccess: async () => + pushed + ? { + eventId: "00000000-0000-4000-8000-000000000000", + redirectUrl: "https://activity.example.com", + } + : null, + recordSuccess: async () => { + pushed = true; + }, + log: async () => undefined, + callStatus: async () => ({ + httpOk: true, + status: 200, + body: { code: 0, data: { status: true, trigger: ["card_add"] } }, + requestBody: '{"activity_id":12}', + responseBody: '{"code":0,"data":{"status":true}}', + }), + callPush: async () => ({ + httpOk: true, + status: 200, + body: { code: 0 }, + requestBody: "{}", + responseBody: '{"code":0}', + }), + ...overrides, + }; +} + +describe("activity idempotency", () => { + test("does not call push twice for the same order and still returns button data", async () => { + let pushCalls = 0; + const deps = makeDeps({ + callPush: async () => { + pushCalls += 1; + return { + httpOk: true, + status: 200, + body: { code: 0 }, + requestBody: "{}", + responseBody: '{"code":0}', + }; + }, + }); + + const first = await runActivityCheck( + { shop: "demo.myshopify.com", orderId: "gid://shopify/Order/123", orderNumber: "#1001", locale: "zh-CN" }, + deps, + ); + const second = await runActivityCheck( + { shop: "demo.myshopify.com", orderId: "gid://shopify/Order/123", orderNumber: "#1001", locale: "zh-CN" }, + deps, + ); + + expect(first).toEqual({ + success: true, + redirectUrl: "https://activity.example.com", + buttonText: "领取奖励", + }); + expect(second).toEqual({ + success: true, + redirectUrl: "https://activity.example.com", + buttonText: "领取奖励", + }); + expect(pushCalls).toBe(1); + }); +}); diff --git a/test/signature.test.ts b/test/signature.test.ts new file mode 100644 index 0000000..cc82893 --- /dev/null +++ b/test/signature.test.ts @@ -0,0 +1,35 @@ +import { createHash } from "crypto"; +import { describe, expect, test } from "vitest"; +import { buildSignedRequest, dreameSign, stringifyBody } from "../app/services/signature.server"; + +describe("dreame signature", () => { + test("generates stable 32-char lowercase md5", () => { + const bodyString = '{"activity_id":12}'; + const timestamp = "1776901800000"; + const secret = "abc123secret"; + const expected = createHash("md5") + .update(Buffer.from(`${bodyString}${timestamp}${secret}`, "utf8")) + .digest("hex"); + + const sign = dreameSign(bodyString, timestamp, secret); + + expect(sign).toBe(expected); + expect(sign).toMatch(/^[a-f0-9]{32}$/); + }); + + test("uses the same serialized body for signing and fetch", () => { + const payload = { activity_id: 12 }; + const bodyString = stringifyBody(payload); + const signed = buildSignedRequest(payload, { + apiAppId: "app-id", + apiSecret: "secret", + timestamp: "1776901800000", + }); + + expect(signed.bodyString).toBe(bodyString); + expect(signed.bodyString).toBe('{"activity_id":12}'); + expect(signed.headers["dreame-api-sign"]).toBe( + dreameSign(bodyString, "1776901800000", "secret"), + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0a59d18 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["app/**/*.ts", "app/**/*.tsx", "extensions/**/*.ts", "extensions/**/*.tsx", "test/**/*.ts", "vite.config.ts", "vitest.config.ts"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vitest/globals"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": false, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "noEmit": true + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..2110c5a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +import { vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [ + remix({ + ignoredRouteFiles: ["**/.*"], + }), + tsconfigPaths(), + ], +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..adb932c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: "node", + globals: true, + include: ["test/**/*.test.ts"], + }, +});