syncWebhooksOnOptionChange('aikanen_auto_import_product', [ 'service.created', 'service.updated', ]); } public function register(): void { add_action('rest_api_init', [$this, 'registerWebhookEndpoint']); // Register endpoint for receiving webhooks add_action('admin_post_aikanen_preview_services', [$this, 'handlePreview']); add_action('admin_post_aikanen_import_services', [$this, 'handleImport']); add_action('admin_post_aikanen_match_by_sku', [$this, 'handleMatchBySku']); add_action('admin_post_aikanen_cancel_import', [$this, 'handleCancelImport']); add_action('admin_post_aikanen_exclude_service', [$this, 'handleExcludeService']); add_action('admin_post_aikanen_include_service', [$this, 'handleIncludeService']); } // Register webhook endpoint for receiving product update events from Aikanen public function registerWebhookEndpoint(): void { register_rest_route('aikanen/v1', 'webhook/' . $this->key(), [ 'methods' => 'POST', 'callback' => [$this, 'handleWebhook'], 'permission_callback' => [$this, 'verifyWebhookSignature'], ]); } // Handle product update webhook from Aikanen public function handleWebhook(\WP_REST_Request $request): \WP_REST_Response { if (!get_option('aikanen_auto_import_product')) { return new \WP_REST_Response(['ok' => true]); } $payload = $request->get_json_params(); $service = $payload['data']['service'] ?? null; if (!$service || $this->isExcluded((string) ($service['id'] ?? ''))) { // No service in payload, or service is deliberately excluded from imports return new \WP_REST_Response(['ok' => true]); } SyncState::$suppressOutbound = true; try { $this->upsertWithCategories($service); } finally { SyncState::$suppressOutbound = false; // Always release lock after processing } return new \WP_REST_Response(['ok' => true]); } // Get products that are selected to be excluded from import public function getExcludedIds(): array { return get_option(self::OPTION_EXCLUDED, []); } // Assert if product is excluded from import or not private function isExcluded(string $serviceId): bool { return in_array($serviceId, $this->getExcludedIds(), true); } // API - Get all services from Aikanen private function fetchAllServices(): array { $page = 1; $all = []; do { // Process paginated results from Aikanen API $result = $this->api->get('/services', ['include' => 'serviceGroups', 'page' => $page]); $members = $result['data'] ?? []; // Flatten JSON:API structure to match Webhook structure: merge attributes + id foreach ($members as $member) { $service = $member['attributes'] ?? []; $service['id'] = $member['id']; $service['serviceGroups'] = array_map( // Service groups won't be present on Webhook data (as webhooks are sent on created/updated at which point there are no attached groups) but add them for API import data fn($r) => ['id' => $r['id']], $member['relationships']['serviceGroups']['data'] ?? [] ); $all[] = $service; } $hasNext = !empty($result['links']['next']); $page++; } while ($hasNext); return $all; } // Get only products from result private function filterProducts(array $services): array { return array_values(array_filter( $services, fn (array $s) => ($s['type'] ?? '') === 'product' )); } // Check if product already exists in WooCommerce (new or to be updated) private function resolveStatuses(array $services): array { $result = []; foreach ($services as $service) { $aikanenId = (string) ($service['id'] ?? ''); $name = $service['name'] ?? ''; // No name or ID returned for product from Aikanen - Skip if ($aikanenId === '' || !$name) { $service['_status'] = 'skip'; $service['_reason'] = 'Missing ID or name'; $result[] = $service; continue; } // Flagged by user as excluded in imports if ($this->isExcluded($aikanenId)) { $service['_status'] = 'skip'; $service['_reason'] = __('Excluded from sync', 'aikanen'); $service['_excluded'] = true; $result[] = $service; continue; } // Check if product with matching Aikanen ID is already found $productId = $this->productMapper->findProductByAikanenId($aikanenId); if (!$productId) { $sku = $service['sku'] ?? ''; if (!empty($sku)) { $skuProductId = wc_get_product_id_by_sku($sku); if ($skuProductId) { $service['_status'] = 'match_sku'; $service['_wc_product_id'] = $skuProductId; $result[] = $service; continue; } } } $service['_status'] = $productId ? 'update' : 'new'; $result[] = $service; } return $result; } // Preview import public function handlePreview(): void { if ( !current_user_can('manage_woocommerce') || !check_admin_referer('aikanen_preview_services') ) { wp_die('Unauthorized.'); } try { $services = $this->fetchAllServices(); } catch (\RuntimeException $e) { set_transient('aikanen_notice', [ 'type' => 'error', 'message' => $e->getMessage(), ], 30); wp_safe_redirect(admin_url('admin.php?page=aikanen-products')); // Show error exit; } $products = $this->filterProducts($services); $products = $this->resolveStatuses($products); set_transient('aikanen_preview', $products, 300); wp_safe_redirect(admin_url('admin.php?page=aikanen-products')); // Show import preview exit; } // Execute import after preview public function handleImport(): void { if ( !current_user_can('manage_woocommerce') || !check_admin_referer('aikanen_import_services') ) { wp_die('Unauthorized.'); } $services = get_transient('aikanen_preview'); if (!$services) { // Transient expired set_transient('aikanen_notice', [ 'type' => 'error', 'message' => 'Preview expired. Please preview again before importing.', ], 30); wp_safe_redirect(admin_url('admin.php?page=aikanen-products')); exit; } delete_transient('aikanen_preview'); $selected_ids = array_map('sanitize_text_field', wp_unslash($_POST['services'] ?? [])); $created = 0; $updated = 0; $skipped = 0; SyncState::$suppressOutbound = true; try { foreach ($services as $service) { // Process each product $aikanenId = (string) ($service['id'] ?? ''); if (!in_array($aikanenId, $selected_ids, true)) { // Skip non-selected and invalid products $skipped++; continue; } // Import product if selected and valid $result = $this->upsertWithCategories($service); match ($result) { 'created' => $created++, 'updated' => $updated++, default => $skipped++, }; } } finally { SyncState::$suppressOutbound = false; // Always release lock after processing } set_transient('aikanen_notice', [ 'type' => 'success', 'message' => sprintf( 'Import complete — %d created, %d updated, %d skipped.', $created, $updated, $skipped ), ], 30); wp_safe_redirect(admin_url('admin.php?page=aikanen-products')); // Show import result exit; } private function upsertWithCategories(array $service): string { $result = $this->productMapper->upsert($service); if (in_array($result, ['created', 'updated'], true)) { $this->syncCategories($service); } return $result; } private function syncCategories(array $service): void { $groups = $service['serviceGroups'] ?? []; $productId = $this->productMapper->findProductByAikanenId((string) ($service['id'] ?? '')); if (!$productId || empty($groups)) { return; } $termIds = []; foreach ($groups as $group) { $groupId = (string) ($group['id'] ?? ''); if ($groupId === '') continue; $termId = $this->categoryMapper->findCategoryByAikanenId($groupId); if ($termId) { $termIds[] = $termId; } } if (!empty($termIds)) { wp_set_object_terms($productId, $termIds, 'product_cat', true); } } // If existing unlinked WooCommerce products have matching SKUs with Aikanen products, link them by adding Aikanen ID public function handleMatchBySku(): void { if ( !current_user_can('manage_woocommerce') || !check_admin_referer('aikanen_match_by_sku') ) { wp_die('Unauthorized.'); } $services = get_transient('aikanen_preview'); if (!$services) { // Transient expired set_transient('aikanen_notice', [ 'type' => 'error', 'message' => 'Preview expired. Please preview again.', ], 30); wp_safe_redirect(admin_url('admin.php?page=aikanen-products')); exit; } $matched = array_filter($services, fn ($s) => ($s['_status'] ?? '') === 'match_sku'); $count = 0; foreach ($matched as $service) { $wcProductId = $service['_wc_product_id'] ?? null; $aikanenId = $service['id'] ?? null; if ($wcProductId && $aikanenId) { update_post_meta($wcProductId, self::META_AIKANEN_ID, $aikanenId); $count++; } } // Clear preview so user gets a fresh fetch reflecting the new links delete_transient('aikanen_preview'); set_transient('aikanen_notice', [ 'type' => 'success', 'message' => sprintf('%d product(s) linked. Fetch & Preview again to import them.', $count), ], 30); wp_safe_redirect(admin_url('admin.php?page=aikanen-products')); exit; } // Add product to list of Aikanen-services which are always excluded from imports (never update from Aikanen) public function handleExcludeService(): void { if ( !current_user_can('manage_woocommerce') || !check_admin_referer('aikanen_exclude_service') ) { wp_die('Unauthorized.'); } $serviceId = sanitize_text_field(wp_unslash($_GET['service_id'] ?? '')); $excluded = $this->getExcludedIds(); if ($serviceId !== '' && !in_array($serviceId, $excluded, true)) { $excluded[] = $serviceId; update_option(self::OPTION_EXCLUDED, array_values($excluded)); } $this->refreshPreview(); wp_safe_redirect(admin_url('admin.php?page=aikanen-products')); exit; } // Remove product from list of Aikanen-services which are always excluded from imports public function handleIncludeService(): void { if ( !current_user_can('manage_woocommerce') || !check_admin_referer('aikanen_include_service') ) { wp_die('Unauthorized.'); } $serviceId = sanitize_text_field(wp_unslash($_GET['service_id'] ?? '')); update_option(self::OPTION_EXCLUDED, array_values( array_diff($this->getExcludedIds(), [$serviceId]) )); $this->refreshPreview(); wp_safe_redirect(admin_url('admin.php?page=aikanen-products')); exit; } private function refreshPreview(): void { $services = get_transient('aikanen_preview'); if ($services) { set_transient('aikanen_preview', $this->resolveStatuses($services), 300); } } // Cancel import on preview public function handleCancelImport(): void { if ( !current_user_can('manage_woocommerce') || !check_admin_referer('aikanen_cancel_import') ) { wp_die('Unauthorized.'); } delete_transient('aikanen_preview'); wp_safe_redirect(admin_url('admin.php?page=aikanen-products')); exit; } // Public accessors for settings page rendering public function getPreviewData(): array|false { return get_transient('aikanen_preview'); } // Show count of all WooCommerce products with Aikanen ID public function getImportedCount(): int { return (new \WP_Query([ 'post_type' => 'product', 'meta_key' => self::META_AIKANEN_ID, 'fields' => 'ids', 'nopaging' => true, ]))->found_posts; } }