2026-06-19 00:53:13 +08:00
|
|
|
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";
|
|
|
|
|
|
2026-06-19 19:35:50 +08:00
|
|
|
const CACHE_CONTROL = "no-store, max-age=0";
|
|
|
|
|
const CHECKOUT_CORS_HEADERS = ["Authorization", "Content-Type"];
|
|
|
|
|
const FALLBACK_CORS_HEADERS = {
|
2026-06-19 00:53:13 +08:00
|
|
|
"Access-Control-Allow-Origin": "*",
|
|
|
|
|
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
|
|
|
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
2026-06-19 19:35:50 +08:00
|
|
|
"Cache-Control": CACHE_CONTROL,
|
2026-06-19 00:53:13 +08:00
|
|
|
};
|
|
|
|
|
|
2026-06-19 19:35:50 +08:00
|
|
|
type CheckoutAuthResult = Awaited<ReturnType<typeof authenticate.public.checkout>> & {
|
|
|
|
|
admin?: unknown;
|
|
|
|
|
session?: { shop?: string };
|
|
|
|
|
shop?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type CheckoutCors = CheckoutAuthResult["cors"];
|
|
|
|
|
|
2026-06-19 00:53:13 +08:00
|
|
|
type ActivityRequestBody = {
|
|
|
|
|
orderId?: string;
|
|
|
|
|
orderNumber?: string;
|
|
|
|
|
locale?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
|
|
if (request.method === "OPTIONS") {
|
2026-06-19 19:35:50 +08:00
|
|
|
return handleOptions(request);
|
2026-06-19 00:53:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-19 19:35:50 +08:00
|
|
|
return fallbackCors(
|
|
|
|
|
errorResponse("METHOD_NOT_ALLOWED", "Only POST is supported.", 405, {
|
|
|
|
|
Allow: "POST",
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-06-19 00:53:13 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function action({ request }: ActionFunctionArgs) {
|
|
|
|
|
if (request.method === "OPTIONS") {
|
2026-06-19 19:35:50 +08:00
|
|
|
return handleOptions(request);
|
2026-06-19 00:53:13 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (request.method !== "POST") {
|
2026-06-19 19:35:50 +08:00
|
|
|
return fallbackCors(
|
|
|
|
|
errorResponse("METHOD_NOT_ALLOWED", "Only POST is supported.", 405, {
|
|
|
|
|
Allow: "POST",
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-06-19 00:53:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-19 19:35:50 +08:00
|
|
|
let authResult: CheckoutAuthResult;
|
|
|
|
|
|
2026-06-19 00:53:13 +08:00
|
|
|
try {
|
2026-06-19 19:35:50 +08:00
|
|
|
authResult = (await authenticate.public.checkout(request, {
|
|
|
|
|
corsHeaders: CHECKOUT_CORS_HEADERS,
|
|
|
|
|
})) as CheckoutAuthResult;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return authErrorResponse(error);
|
|
|
|
|
}
|
2026-06-19 00:53:13 +08:00
|
|
|
|
2026-06-19 19:35:50 +08:00
|
|
|
const { cors, sessionToken } = authResult;
|
|
|
|
|
const shop = normalizeShopDomain(authResult.shop || authResult.session?.shop || sessionToken.dest);
|
|
|
|
|
if (!shop) {
|
|
|
|
|
return checkoutCors(
|
|
|
|
|
cors,
|
|
|
|
|
errorResponse("INVALID_SHOP_CONTEXT", "A valid Shopify shop context is required.", 401),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const body = await parseJsonBody(request);
|
|
|
|
|
if (!body) {
|
|
|
|
|
return checkoutCors(cors, errorResponse("INVALID_JSON", "Request body must be valid JSON.", 400));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof body.orderId !== "string" || body.orderId.trim() === "") {
|
|
|
|
|
return checkoutCors(cors, errorResponse("ORDER_ID_REQUIRED", "orderId is required.", 400));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-19 00:53:13 +08:00
|
|
|
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 result = await runActivityCheck(
|
|
|
|
|
{
|
|
|
|
|
shop,
|
2026-06-19 19:35:50 +08:00
|
|
|
orderId: body.orderId.trim(),
|
2026-06-19 00:53:13 +08:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-19 19:35:50 +08:00
|
|
|
return checkoutCors(cors, json(result, { headers: noStoreHeaders() }));
|
|
|
|
|
} catch {
|
|
|
|
|
return checkoutCors(cors, errorResponse("ACTIVITY_CHECK_FAILED", "Activity check failed.", 500));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleOptions(request: Request): Promise<Response> {
|
|
|
|
|
try {
|
|
|
|
|
const { cors } = await authenticate.public.checkout(request, {
|
|
|
|
|
corsHeaders: CHECKOUT_CORS_HEADERS,
|
|
|
|
|
});
|
|
|
|
|
return checkoutCors(cors, new Response(null, { status: 204, headers: noStoreHeaders() }));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return authErrorResponse(error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function parseJsonBody(request: Request): Promise<ActivityRequestBody | null> {
|
|
|
|
|
try {
|
|
|
|
|
return (await request.json()) as ActivityRequestBody;
|
2026-06-19 00:53:13 +08:00
|
|
|
} catch {
|
2026-06-19 19:35:50 +08:00
|
|
|
return null;
|
2026-06-19 00:53:13 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 19:35:50 +08:00
|
|
|
function normalizeShopDomain(value: unknown): string | null {
|
|
|
|
|
if (typeof value !== "string" || value.trim() === "") {
|
|
|
|
|
return null;
|
2026-06-19 00:53:13 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-19 19:35:50 +08:00
|
|
|
const url = value.includes("://") ? value : `https://${value}`;
|
|
|
|
|
const hostname = new URL(url).hostname.toLowerCase();
|
|
|
|
|
return hostname.endsWith(".myshopify.com") ? hostname : null;
|
2026-06-19 00:53:13 +08:00
|
|
|
} catch {
|
2026-06-19 19:35:50 +08:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function errorResponse(code: string, message: string, status: number, headers: HeadersInit = {}): Response {
|
|
|
|
|
return json(
|
|
|
|
|
{
|
|
|
|
|
success: false,
|
|
|
|
|
error: {
|
|
|
|
|
code,
|
|
|
|
|
message,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
status,
|
|
|
|
|
headers: {
|
|
|
|
|
...headers,
|
|
|
|
|
...noStoreHeaders(),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function authErrorResponse(error: unknown): Response {
|
|
|
|
|
if (error instanceof Response) {
|
|
|
|
|
return fallbackCors(withNoStore(error));
|
2026-06-19 00:53:13 +08:00
|
|
|
}
|
2026-06-19 19:35:50 +08:00
|
|
|
|
|
|
|
|
return fallbackCors(errorResponse("CHECKOUT_AUTHENTICATION_FAILED", "Checkout authentication failed.", 401));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function checkoutCors(cors: CheckoutCors, response: Response): Response {
|
|
|
|
|
return cors(withNoStore(response));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fallbackCors(response: Response): Response {
|
|
|
|
|
for (const [key, value] of Object.entries(FALLBACK_CORS_HEADERS)) {
|
|
|
|
|
response.headers.set(key, value);
|
|
|
|
|
}
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function withNoStore(response: Response): Response {
|
|
|
|
|
response.headers.set("Cache-Control", CACHE_CONTROL);
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function noStoreHeaders(): HeadersInit {
|
|
|
|
|
return {
|
|
|
|
|
"Cache-Control": CACHE_CONTROL,
|
|
|
|
|
};
|
2026-06-19 00:53:13 +08:00
|
|
|
}
|