299 lines
7.3 KiB
TypeScript
299 lines
7.3 KiB
TypeScript
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;
|
|
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)) {
|
|
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)) {
|
|
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;
|
|
}
|
|
|
|
function isSuccessfulPush(result: ThirdPartyResult<PushResponse>, config: ActivityConfig): boolean {
|
|
return result.httpOk && result.body.code === (config.pushSuccessCode ?? 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);
|
|
}
|