chore: initialize dreame shopify app
This commit is contained in:
commit
807ac75be9
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 <checkout session token>
|
||||
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.
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
export type ButtonTextMap = Record<string, string>;
|
||||
|
||||
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] ?? "";
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<Outlet />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof loader>();
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui, sans-serif", padding: 32, maxWidth: 760 }}>
|
||||
<h1>{data.appName}</h1>
|
||||
<p>Shopify Thank You Page activity app scaffold.</p>
|
||||
<p>
|
||||
Third party mode: <strong>{data.mode}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Configure Metaobject <code>dreame_activity</code>, install the Checkout UI Extension, and use mock mode for
|
||||
local validation before live integration.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<unknown> } })
|
||||
.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<string, unknown> }) => Promise<Response>;
|
||||
};
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TBody> = {
|
||||
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<OrderDetails>;
|
||||
getConfig: (shop: string) => Promise<ActivityConfig>;
|
||||
findSuccess: (shop: string, orderId: string, activityId: number) => Promise<IdempotencyHit | null>;
|
||||
recordSuccess: (entry: {
|
||||
shop: string;
|
||||
orderId: string;
|
||||
activityId: number;
|
||||
eventType: "order_end";
|
||||
eventId: string;
|
||||
redirectUrl: string;
|
||||
buttonText: string;
|
||||
}) => Promise<void>;
|
||||
log: (entry: LogEntry) => Promise<void>;
|
||||
callStatus: (config: ActivityConfig) => Promise<ThirdPartyResult<StatusResponse>>;
|
||||
callPush: (config: ActivityConfig, payload: PushPayload) => Promise<ThirdPartyResult<PushResponse>>;
|
||||
};
|
||||
|
||||
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<ActivityResponse> {
|
||||
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<StatusResponse>): boolean {
|
||||
return result.httpOk && result.body.code === 0 && result.body.data?.status === true;
|
||||
}
|
||||
|
||||
function isSuccessfulPush(result: ThirdPartyResult<PushResponse>): 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<void> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import type { ActivityConfig } from "./activity.server";
|
||||
|
||||
type AdminGraphqlClient = {
|
||||
graphql: (query: string, options?: { variables?: Record<string, unknown> }) => Promise<Response>;
|
||||
};
|
||||
|
||||
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<ActivityConfig> {
|
||||
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<string, string> {
|
||||
if (!value) {
|
||||
throw new Error("Metaobject field button_text is required");
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(value) as Record<string, unknown>;
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<ThirdPartyResult<StatusResponse>> {
|
||||
const payload = { activity_id: config.activityId };
|
||||
return callDreame<StatusResponse>(config.statusApiUrl, config, payload, mockStatusResponse);
|
||||
}
|
||||
|
||||
export async function callPush(config: ActivityConfig, payload: PushPayload): Promise<ThirdPartyResult<PushResponse>> {
|
||||
return callDreame<PushResponse>(config.pushApiUrl, config, payload, mockPushResponse);
|
||||
}
|
||||
|
||||
async function callDreame<TBody>(
|
||||
url: string,
|
||||
config: ActivityConfig,
|
||||
payload: unknown,
|
||||
mockFactory: () => TBody,
|
||||
): Promise<ThirdPartyResult<TBody>> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<string, unknown>).map(([key, child]) => [
|
||||
key,
|
||||
SENSITIVE_KEYS.has(key) ? "[redacted]" : maskSensitive(child),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { extractLegacyId, normalizeOrderId } from "./activity.server";
|
||||
import type { OrderDetails } from "./activity.server";
|
||||
|
||||
type AdminGraphqlClient = {
|
||||
graphql: (query: string, options?: { variables?: Record<string, unknown> }) => Promise<Response>;
|
||||
};
|
||||
|
||||
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<OrderDetails> {
|
||||
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 || "",
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { createHash } from "crypto";
|
||||
|
||||
export type SignOptions = {
|
||||
apiAppId: string;
|
||||
apiSecret: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
export type SignedRequest = {
|
||||
bodyString: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
|
|
@ -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", () => <DreameActivity />);
|
||||
|
||||
function DreameActivity() {
|
||||
const api = useApi<"purchase.thank-you.block.render">() as any;
|
||||
const [response, setResponse] = useState<ActivityResponse>({ 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 (
|
||||
<InlineStack>
|
||||
<Button to={response.redirectUrl}>{response.buttonText}</Button>
|
||||
</InlineStack>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
type GraphqlResponse<T> = {
|
||||
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<T>(query: string, variables: Record<string, unknown>): Promise<GraphqlResponse<T>> {
|
||||
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<T>;
|
||||
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);
|
||||
});
|
||||
|
|
@ -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"
|
||||
]
|
||||
|
|
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
],
|
||||
});
|
||||
|
|
@ -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"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue