dreame-shopify-app/app/services/activity.server.ts

299 lines
7.3 KiB
TypeScript
Raw Permalink Normal View History

2026-06-19 00:53:13 +08:00
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;
statusSuccessCode?: number;
statusActiveRequired?: boolean;
pushSuccessCode?: number;
2026-06-19 00:53:13 +08:00
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, config)) {
2026-06-19 00:53:13 +08:00
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, config)) {
2026-06-19 00:53:13 +08:00
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>, config: ActivityConfig): boolean {
if (!result.httpOk || result.body.code !== (config.statusSuccessCode ?? 0)) {
return false;
}
if (config.statusActiveRequired === false) {
return true;
}
return result.body.data?.status === true;
2026-06-19 00:53:13 +08:00
}
function isSuccessfulPush(result: ThirdPartyResult<PushResponse>, config: ActivityConfig): boolean {
return result.httpOk && result.body.code === (config.pushSuccessCode ?? 0);
2026-06-19 00:53:13 +08:00
}
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);
}