dreame-action/app/routes/api.action.activity.check.tsx

203 lines
5.7 KiB
TypeScript

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 CACHE_CONTROL = "no-store, max-age=0";
const CHECKOUT_CORS_HEADERS = ["Authorization", "Content-Type"];
const FALLBACK_CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Cache-Control": CACHE_CONTROL,
};
type CheckoutAuthResult = Awaited<ReturnType<typeof authenticate.public.checkout>> & {
admin?: unknown;
session?: { shop?: string };
shop?: string;
};
type CheckoutCors = CheckoutAuthResult["cors"];
type ActivityRequestBody = {
orderId?: string;
orderNumber?: string;
locale?: string;
};
export async function loader({ request }: LoaderFunctionArgs) {
if (request.method === "OPTIONS") {
return handleOptions(request);
}
return fallbackCors(
errorResponse("METHOD_NOT_ALLOWED", "Only POST is supported.", 405, {
Allow: "POST",
}),
);
}
export async function action({ request }: ActionFunctionArgs) {
if (request.method === "OPTIONS") {
return handleOptions(request);
}
if (request.method !== "POST") {
return fallbackCors(
errorResponse("METHOD_NOT_ALLOWED", "Only POST is supported.", 405, {
Allow: "POST",
}),
);
}
let authResult: CheckoutAuthResult;
try {
authResult = (await authenticate.public.checkout(request, {
corsHeaders: CHECKOUT_CORS_HEADERS,
})) as CheckoutAuthResult;
} catch (error) {
return authErrorResponse(error);
}
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 {
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,
orderId: body.orderId.trim(),
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 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;
} catch {
return null;
}
}
function normalizeShopDomain(value: unknown): string | null {
if (typeof value !== "string" || value.trim() === "") {
return null;
}
try {
const url = value.includes("://") ? value : `https://${value}`;
const hostname = new URL(url).hostname.toLowerCase();
return hostname.endsWith(".myshopify.com") ? hostname : null;
} catch {
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));
}
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,
};
}