syncWebhooksOnOptionChange('aikanen_auto_import_category', [ 'service_group.created', 'service_group.updated', 'service_group.services_attached', 'service_group.services_detached', ]); } public function register(): void { add_action('admin_post_aikanen_fetch_groups', [$this, 'handleFetchGroups']); add_action('admin_post_aikanen_save_category_mapping', [$this, 'handleSaveCategoryMapping']); add_action('admin_post_aikanen_unlink_category', [$this, 'handleUnlinkCategory']); add_action('admin_post_aikanen_cancel_category_mapping', [$this, 'handleCancelCategoryMapping']); add_filter('manage_product_cat_columns', [$this, 'addSyncColumn']); add_action('product_cat_edit_form_fields', [$this, 'renderSyncFields']); add_action('rest_api_init', [$this, 'registerWebhookEndpoint']); // Register endpoint for receiving webhooks } public function registerWebhookEndpoint(): void { register_rest_route('aikanen/v1', 'webhook/' . $this->key(), [ 'methods' => 'POST', 'callback' => [$this, 'handleWebhook'], 'permission_callback' => [$this, 'verifyWebhookSignature'], ]); } public function handleWebhook(\WP_REST_Request $request): \WP_REST_Response { if (!get_option('aikanen_auto_import_category')) { return new \WP_REST_Response(['ok' => true]); } $payload = $request->get_json_params(); $event = $payload['event'] ?? ''; $data = $payload['data'] ?? []; SyncState::$suppressOutbound = true; try { if ($event === 'service_group.created' || $event === 'service_group.updated') { $this->handleGroupUpserted($data); } elseif ($event === 'service_group.services_attached') { $this->handleServicesAttached($data); } elseif ($event === 'service_group.services_detached') { $this->handleServicesDetached($data); } } finally { SyncState::$suppressOutbound = false; // Always release lock after processing } return new \WP_REST_Response(['ok' => true]); } // Save category (Aikanen service group) public function handleGroupUpserted(array $data): void { $group = $data['service_group'] ?? null; if (!$group) return; $this->categoryMapper->upsert($group); } // Attach product to category public function handleServicesAttached(array $data): void { $groupId = $data['service_group_id'] ?? null; $serviceIds = $data['service_ids'] ?? []; if (!$groupId || empty($serviceIds)) return; $termId = $this->categoryMapper->findCategoryByAikanenId($groupId); if (!$termId) return; // Group not mapped - nothing to do foreach ($serviceIds as $serviceId) { $productId = $this->productMapper->findProductByAikanenId($serviceId); if ($productId) { wp_set_object_terms($productId, $termId, 'product_cat', true); // append, don't replace } } } // Detach product from category public function handleServicesDetached(array $data): void { $groupId = $data['service_group_id'] ?? null; $serviceIds = $data['service_ids'] ?? []; if (!$groupId || empty($serviceIds)) return; $termId = $this->categoryMapper->findCategoryByAikanenId($groupId); if (!$termId) return; foreach ($serviceIds as $serviceId) { $productId = $this->productMapper->findProductByAikanenId($serviceId); if ($productId) { wp_remove_object_terms($productId, $termId, 'product_cat'); } } } // Get service groups from Aikanen public function handleFetchGroups(): void { check_admin_referer('aikanen_fetch_groups'); if (!current_user_can('manage_woocommerce')) { wp_die('Unauthorized.'); } $response = $this->api->get('/service_groups'); $groups = array_map(function ($item) { return array_merge(['id' => $item['id']], $item['attributes'] ?? []); }, $response['data'] ?? []); set_transient('aikanen_fetched_groups', $groups, 10 * MINUTE_IN_SECONDS); wp_safe_redirect(admin_url('admin.php?page=aikanen-categories&aikanen_mapping=1')); exit; } // Save category mapping to match categories to selected Aikanen service groups public function handleSaveCategoryMapping(): void { check_admin_referer('aikanen_save_category_mapping'); if (!current_user_can('manage_woocommerce')) { wp_die('Unauthorized.'); } $raw = array_map('sanitize_text_field', (array) wp_unslash($_POST['category_map'] ?? [])); $map = []; foreach ($raw as $k => $v) { $map[sanitize_text_field($k)] = $v; } foreach ($map as $groupId => $termIdOrAction) { $groupId = sanitize_text_field($groupId); if ($termIdOrAction === '__create__') { $groups = get_transient('aikanen_fetched_groups') ?: []; $group = current(array_filter($groups, fn($g) => $g['id'] === $groupId)); if (!$group) continue; $this->categoryMapper->upsert($group); continue; } $termId = (int) $termIdOrAction; if (!$termId) continue; $existing = get_term_meta($termId, CategoryImportMapper::META_AIKANEN_ID, true); if ($existing && $existing !== $groupId) continue; update_term_meta($termId, CategoryImportMapper::META_AIKANEN_ID, $groupId); } delete_transient('aikanen_fetched_groups'); wp_safe_redirect(admin_url('admin.php?page=aikanen-categories&aikanen_mapping_saved=1')); exit; } // Cancel mapping on preview public function handleCancelCategoryMapping(): void { if ( !current_user_can('manage_woocommerce') || !check_admin_referer('aikanen_cancel_category_mapping') ) { wp_die('Unauthorized.'); } delete_transient('aikanen_fetched_groups'); wp_safe_redirect(admin_url('admin.php?page=aikanen-categories')); exit; } // Remove Aikanen linking (aikanen meta ID) from category public function handleUnlinkCategory(): void { $termId = absint(wp_unslash($_POST['term_id'] ?? 0)); if ( !$termId || !current_user_can('manage_woocommerce') || !check_admin_referer('aikanen_unlink_category_' . $termId) ) { wp_die('Unauthorized.'); } delete_term_meta($termId, CategoryImportMapper::META_AIKANEN_ID); wp_safe_redirect(admin_url('admin.php?page=aikanen-categories')); exit; } // Add sync details to category edit page public function addSyncColumn(array $columns): array { $columns['aikanen_sync'] = __('Aikanen', 'aikanen'); return $columns; } public function renderSyncFields(\WP_Term $term): void { $groupId = get_term_meta($term->term_id, CategoryImportMapper::META_AIKANEN_ID, true); ?>