// eslint-disable perfectionist/sort-imports import './sentry.ts'; // eslint-enable perfectionist/sort-imports // eslint-disable-next-line perfectionist/sort-imports import type { API_ROOT_URL } from '@self/utils/env'; import { create_api } from '@self/api/client'; import { assert } from '@self/utils/assert'; import { GeneralException } from '@self/utils/error'; import * as Sentry from '@sentry/browser'; import { z } from 'zod'; import { get_address_parser, type ParsedOrderFields, type ParserName, type SourceAddressKey } from './address-parsers.ts'; import { type WCOrder, WCOrderSchema } from './types/order.ts'; // Fail fast if WORDPRESS_SITE_URL is not provided by WordPress const WORDPRESS_SITE_URL = window.WORDPRESS_SITE_URL; assert(typeof WORDPRESS_SITE_URL === 'string' && WORDPRESS_SITE_URL.length > 0, 'WORDPRESS_SITE_URL is not defined'); const WORDPRESS_SITE_URL_HOSTNAME = new URL(WORDPRESS_SITE_URL).hostname; // Fail fast if JAAK_WORDPRESS_REST_ROOT is not provided by WordPress const JAAK_WORDPRESS_REST_ROOT = window.JAAK_WORDPRESS_REST_ROOT; assert(typeof JAAK_WORDPRESS_REST_ROOT === 'string' && JAAK_WORDPRESS_REST_ROOT.length > 0, 'JAAK_WORDPRESS_REST_ROOT is not defined'); // Fail fast if JAAK_SITE_BASE_URL is not provided by WordPress export const SITE_BASE_URL = window.JAAK_SITE_BASE_URL; assert(typeof SITE_BASE_URL === 'string' && SITE_BASE_URL.length > 0, 'SITE_BASE_URL is not defined'); const api = create_api(JAAK_WORDPRESS_REST_ROOT as API_ROOT_URL, { headers: { // Include WordPress nonce. Otherwise, requests to the proxy will be // rejected. 'X-WP-Nonce': typeof window.wp_api_settings?.nonce === 'string' ? window.wp_api_settings.nonce : '', }, }); /** * Fetches existing tasks from Jaak API by WooCommerce order keys */ export async function get_tasks(order_keys: Array) { console.debug(`Getting tasks for order keys: ${order_keys.join(', ')}`); const res = await api.tasks.$get({ query: { external_id: order_keys } }); // @ts-expect-error The generated types do not capture the 401, 403 status codes, but we want to handle them gracefully here. if (res.status === 401 || res.status === 403) { throw new GeneralException({ type: 'unauthorized', title: 'Authentication Failed', detail: 'Please check your API key configuration.', }); } if (res.status === 400) { const data = await res.json(); console.error('Bad request response data:', data); throw new GeneralException({ type: 'bad_request', title: 'Bad Request', detail: JSON.stringify(data, null, 2), }); } const data = await res.json(); const tasks = data.data; console.debug(`Got ${tasks.length} task(s)`); if (tasks[0]) { return tasks; } else if (tasks.length === 0) { // Don't throw an error if the task does not exist yet as this is an expected state return null; } else { throw new Error('No tasks found'); } } /** * Creates tasks in Jaak API from WooCommerce orders */ export async function create_tasks(order_ids: Array) { console.debug(`Creating tasks for order IDs: ${order_ids.join(', ')}`); // Fetch fresh order data from WordPress const orders = await get_orders(order_ids); console.debug(`Submitting tasks for ${orders.length} order(s)`); const tasks = orders.map(order => transform_order_to_task(order)); const res = await api.tasks.$post({ json: tasks }); // @ts-expect-error The generated types do not capture the 401, 403 status codes, but we want to handle them gracefully here. if (res.status === 401 || res.status === 403) { throw new GeneralException({ type: 'unauthorized', title: 'Authentication Failed', detail: 'Please check your API key configuration.', }); } if (!res.ok) { let detail = 'Failed to create tasks'; try { const body = await res.json(); detail = JSON.stringify(body, null, 2); } catch (e) { console.error('Failed to parse error response:', e); } throw new GeneralException({ type: 'bad_request', title: 'Failed to create tasks', detail, }); } } /** * Fetches order data from WordPress REST API endpoint */ export async function get_orders(order_ids: Array): Promise> { console.debug(`Fetching WooCommerce orders: ${order_ids.join(', ')}`); const url = `${JAAK_WORDPRESS_REST_ROOT}/orders`; const nonce = window.wp_api_settings?.nonce; assert(typeof nonce === 'string' && nonce.length > 0, 'WP Nonce is not defined'); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce, }, credentials: 'include', body: JSON.stringify({ order_ids }), }); if (!res.ok) { throw new GeneralException({ type: 'api_error', title: 'Failed to fetch orders from WordPress', detail: `${res.status} ${res.statusText}`, }); } const data: unknown = await res.json(); // Capture the *raw* response (before zod parsing strips unknown fields) so // we can inspect arbitrary checkout-customizer meta keys per merchant. // Used to design merchant-specific address parsers without guessing. Sentry.captureEvent({ level: 'debug', message: '[jaak-for-woocommerce] Raw order data from WordPress REST API', extra: { requested_order_ids: order_ids, raw_response: data, }, }); const orders = z.array(WCOrderSchema).parse(data); console.debug(`Fetched ${orders.length} order(s)`); return orders; } /** * Transforms WooCommerce order data to Jaak API task format */ function transform_order_to_task(order: WCOrder) { const source_address_key: SourceAddressKey = order.shipping.address_1.length > 0 ? 'shipping' : 'billing'; const source_address = order[source_address_key]; /** * Kuwait-only validation. Done before parser dispatch so parsers can hardcode * `country: 'Kuwait'` in their output. */ if (source_address.country !== 'KW') { throw new Error('The shipping address is not in Kuwait. Only Kuwaiti shipping addresses are supported.'); } const is_force_default_address_parser = window.JAAK_IS_FORCE_DEFAULT_ADDRESS_PARSER === true; const { parser, parser_name } = get_address_parser(WORDPRESS_SITE_URL_HOSTNAME, { force_default_address_parser: is_force_default_address_parser }); const parsed_order = parser(order, source_address_key); log_parse_event(order, { parser_name, source_address_key, parsed_order, is_force_default_address_parser }); return { external_id: order.order_key, // order_key is unique and non-sequential (e.g. `wc_order_qlLYzVi5Rw45l`) external_ref: order.id.toString(), external_url: `${WORDPRESS_SITE_URL}/wp-admin/post.php?post=${order.id}&action=edit`, woocommerce_shop_domain: WORDPRESS_SITE_URL, destination: parsed_order.destination, email: order.billing.email, phone: order.shipping.phone || order.billing.phone, name: parsed_order.name, }; } function log_parse_event( order: WCOrder, data: { parser_name: ParserName; source_address_key: SourceAddressKey; parsed_order: ParsedOrderFields; is_force_default_address_parser: boolean; }, ) { Sentry.captureEvent({ level: 'debug', message: '[jaak-for-woocommerce] Order fields parsed', extra: { order_id: order.id, parser_name: data.parser_name, is_force_default_address_parser: data.is_force_default_address_parser, source_address_key: data.source_address_key, parsed_destination: data.parsed_order.destination, parsed_name: data.parsed_order.name, }, }); }