From 6e98eb0a98156b932fbb2dff4b2043bcb2ee9bf8 Mon Sep 17 00:00:00 2001 From: FredericoAndrade Date: Sun, 19 Apr 2026 21:32:12 +0200 Subject: [PATCH] fix(gateway): resolve download_event_id timing bug by fetching URL before intake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS now fetches the download URL via ?format=json before submitting intake, so the redirect event is logged server-side before intake is enqueued. The PHP download endpoint returns the event_id in its JSON response, which the JS passes to the intake POST as download_event_id. - DownloadController: add last_event_id property; expose event_id in JSON response - IntakeController: accept download_event_id from request instead of DB lookup; removes the always-null find_redirect_id() call - WebhookDispatcher: add ORDER BY id ASC so download webhook is always delivered to Make.com before intake (they share the same next_attempt_at) - gateway-modal.js: handleIntakeSubmit() now fetches download URL first, then posts intake with event_id, then redirects — fixes the ordering - Bump version to 0.1.13 to bust JS cache Co-Authored-By: Claude Sonnet 4.6 --- .../assets/js/gateway-modal.js | 73 +++++++++++++++++-- .../download-gateway/download-gateway.php | 4 +- .../includes/class-download-controller.php | 20 ++++- .../includes/class-intake-controller.php | 20 +++-- .../includes/class-webhook-dispatcher.php | 1 + 5 files changed, 99 insertions(+), 19 deletions(-) diff --git a/wp-content/plugins/download-gateway/assets/js/gateway-modal.js b/wp-content/plugins/download-gateway/assets/js/gateway-modal.js index 25b5fd3d..d9b7f608 100644 --- a/wp-content/plugins/download-gateway/assets/js/gateway-modal.js +++ b/wp-content/plugins/download-gateway/assets/js/gateway-modal.js @@ -628,6 +628,7 @@ var personCookie = pendingPersonCookie; var postId = pendingPostId; var directUrl = pendingDirectUrl; + var isExternal = pendingIsExternal; // Collect responses from intake form fields. var responses = {}; @@ -641,7 +642,45 @@ intakeSubmitBtn.disabled = true; intakeErrorMsg.textContent = ''; + showLoadingStep(); + // External downloads have no token to redeem — submit intake then redirect directly. + if ( isExternal || ! token ) { + doIntakeAndRedirect( postId, personCookie, responses, null, directUrl ); + return; + } + + // Step 1: Fetch the download URL via the JSON endpoint. + // This logs the redirect event server-side and returns {url, event_id}. + // Intake is submitted next so it can reference the correct download event. + fetch( gatewaySettings.downloadBase + '/' + token + '?format=json', { + credentials: 'same-origin', + headers: { 'X-WP-Nonce': gatewaySettings.restNonce }, + } ) + .then( function ( res ) { return res.json().then( function ( d ) { return { ok: res.ok, data: d }; } ); } ) + .then( function ( result ) { + var fileUrl = ( result.ok && result.data.url ) ? result.data.url : null; + var eventId = ( result.ok && result.data.event_id ) ? result.data.event_id : null; + // Fall back to direct token redirect if JSON response was not OK. + doIntakeAndRedirect( postId, personCookie, responses, eventId, fileUrl || ( gatewaySettings.downloadBase + '/' + token ) ); + } ) + .catch( function () { + // Network error — fall back to direct token redirect (server will 302). + doIntakeAndRedirect( postId, personCookie, responses, null, gatewaySettings.downloadBase + '/' + token ); + } ); + } + + /** + * Submit the intake form then redirect to the file. + * Intake is supplementary: redirect always happens regardless of outcome. + * + * @param {number} postId WP post ID. + * @param {string} personCookie Signed gateway_gated cookie value. + * @param {Object} responses Key-value intake field responses. + * @param {number|null} downloadEventId ID of the just-logged redirect event, or null. + * @param {string} fileUrl File URL (resolved) or token URL (fallback). + */ + function doIntakeAndRedirect( postId, personCookie, responses, downloadEventId, fileUrl ) { fetch( gatewaySettings.intakeUrl, { method: 'POST', credentials: 'same-origin', @@ -650,21 +689,41 @@ 'X-WP-Nonce': gatewaySettings.restNonce, }, body: JSON.stringify( { - post_id: postId, - person_cookie: personCookie, - nonce: gatewaySettings.nonce, - responses: responses, + post_id: postId, + person_cookie: personCookie, + nonce: gatewaySettings.nonce, + responses: responses, + download_event_id: downloadEventId, } ), } ) .then( function () { - // Intake is supplementary — proceed regardless of server response. - finishDownload( token, directUrl ); + triggerDownloadRedirect( fileUrl ); } ) .catch( function () { - finishDownload( token, directUrl ); + // Intake is supplementary — proceed regardless. + triggerDownloadRedirect( fileUrl ); } ); } + /** + * Push the GA4 redirect event, close the modal, and navigate to the file. + * Used by the intake path after intake has been submitted. + * + * @param {string} fileUrl Resolved file URL or fallback token URL. + */ + function triggerDownloadRedirect( fileUrl ) { + pushEvent( { + event: 'resource_download_redirect', + post_id: pendingPostId || currentPostId, + post_type: pendingPostType || currentPostType, + policy: currentPolicy, + language_slug: currentLanguageSlug, + download_source: currentDownloadSource, + } ); + closeModal(); + redirect( fileUrl ); + } + /** * Close the modal and trigger the download. Called after intake submit. */ diff --git a/wp-content/plugins/download-gateway/download-gateway.php b/wp-content/plugins/download-gateway/download-gateway.php index c9b4aaa4..562bf6a2 100644 --- a/wp-content/plugins/download-gateway/download-gateway.php +++ b/wp-content/plugins/download-gateway/download-gateway.php @@ -3,7 +3,7 @@ * Plugin Name: Wikitongues Download Gateway * Plugin URI: https://github.com/wikitongues/wikitongues.org * Description: Signed download tokens, optional email gate, download event logging, and GA4 forwarding for all downloadable resources (document files, videos, captions, and future types). - * Version: 0.1.0 + * Version: 0.1.13 * Requires at least: 6.0 * Requires PHP: 8.2 * Author: Wikitongues @@ -17,7 +17,7 @@ exit; } -define( 'GATEWAY_VERSION', '0.1.12' ); +define( 'GATEWAY_VERSION', '0.1.13' ); define( 'GATEWAY_FILE', __FILE__ ); define( 'GATEWAY_DIR', plugin_dir_path( __FILE__ ) ); define( 'GATEWAY_REST_NAMESPACE', 'gateway/v1' ); diff --git a/wp-content/plugins/download-gateway/includes/class-download-controller.php b/wp-content/plugins/download-gateway/includes/class-download-controller.php index 8960cb77..e6e6754f 100644 --- a/wp-content/plugins/download-gateway/includes/class-download-controller.php +++ b/wp-content/plugins/download-gateway/includes/class-download-controller.php @@ -20,6 +20,13 @@ class DownloadController { + /** + * Stores the redirect event ID logged by the most recent get_file_url() call. + * Set as a side effect so handle() can include it in the ?format=json response + * without changing the testable resolve() return type. + */ + private ?int $last_event_id = null; + public function register_routes(): void { register_rest_route( GATEWAY_REST_NAMESPACE, @@ -68,7 +75,13 @@ public function handle( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error VisitorId::set_cookie( $visitor_id ); if ( 'json' === $format ) { - return new \WP_REST_Response( array( 'url' => $result ), 200 ); + return new \WP_REST_Response( + array( + 'url' => $result, + 'event_id' => $this->last_event_id, + ), + 200 + ); } header( 'Cache-Control: no-store, no-cache, must-revalidate' ); @@ -182,8 +195,8 @@ private function get_file_url( return new \WP_Error( 'file_not_found', 'File could not be resolved.', array( 'status' => 404 ) ); } - $post_type = (string) get_post_type( $post_id ); - $event_id = DownloadEventRepository::log( + $post_type = (string) get_post_type( $post_id ); + $event_id = DownloadEventRepository::log( array( 'post_id' => $post_id, 'post_type' => $post_type, @@ -194,6 +207,7 @@ private function get_file_url( 'ip_hash' => IpHasher::hash_from_server( $server ), ) ); + $this->last_event_id = is_int( $event_id ) ? $event_id : null; // Enqueue download webhook if an endpoint is configured. $endpoint = SettingsRepository::get_webhook_endpoint(); diff --git a/wp-content/plugins/download-gateway/includes/class-intake-controller.php b/wp-content/plugins/download-gateway/includes/class-intake-controller.php index 7cbcfe4a..edab6b35 100644 --- a/wp-content/plugins/download-gateway/includes/class-intake-controller.php +++ b/wp-content/plugins/download-gateway/includes/class-intake-controller.php @@ -44,11 +44,15 @@ public function handle( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error $responses = array(); } + $raw_event_id = $request->get_param( 'download_event_id' ); + $download_event_id = is_numeric( $raw_event_id ) ? (int) $raw_event_id : null; + $result = $this->submit( (int) ( $request->get_param( 'post_id' ) ?? 0 ), (string) ( $request->get_param( 'person_cookie' ) ?? '' ), (string) ( $request->get_param( 'nonce' ) ?? '' ), - $responses + $responses, + $download_event_id ); if ( is_wp_error( $result ) ) { @@ -61,13 +65,15 @@ public function handle( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error /** * Process an intake submission. Returns true on success, WP_Error on failure. * - * @param int $post_id Post ID of the downloaded resource. - * @param string $person_cookie HMAC-signed cookie value from gateway_gated. - * @param string $nonce WP nonce value. - * @param array $responses Key-value map of field responses. + * @param int $post_id Post ID of the downloaded resource. + * @param string $person_cookie HMAC-signed cookie value from gateway_gated. + * @param string $nonce WP nonce value. + * @param array $responses Key-value map of field responses. + * @param int|null $download_event_id ID of the redirect event from wp_gateway_download_events. + * Passed by the JS after fetching the download URL. * @return true|\WP_Error */ - public function submit( int $post_id, string $person_cookie, string $nonce, array $responses ): bool|\WP_Error { + public function submit( int $post_id, string $person_cookie, string $nonce, array $responses, ?int $download_event_id = null ): bool|\WP_Error { if ( ! wp_verify_nonce( $nonce, 'gateway_gate' ) ) { return new \WP_Error( 'invalid_nonce', 'Request could not be verified.', array( 'status' => 403 ) ); } @@ -103,7 +109,7 @@ public function submit( int $post_id, string $person_cookie, string $nonce, arra array( 'type' => 'intake', 'person_id' => $person_id, - 'download_event_id' => DownloadEventRepository::find_redirect_id( $person_id, $post_id ), + 'download_event_id' => $download_event_id, 'post_id' => $post_id, 'post_type' => $post_type, 'airtable_record_id' => get_post_meta( $post_id, '_airtable_record_id', true ) ?: null, diff --git a/wp-content/plugins/download-gateway/includes/class-webhook-dispatcher.php b/wp-content/plugins/download-gateway/includes/class-webhook-dispatcher.php index ab9d6283..eaf1cd68 100644 --- a/wp-content/plugins/download-gateway/includes/class-webhook-dispatcher.php +++ b/wp-content/plugins/download-gateway/includes/class-webhook-dispatcher.php @@ -69,6 +69,7 @@ public static function dispatch_pending(): void { "SELECT * FROM {$wpdb->prefix}gateway_webhook_delivery WHERE status IN ('pending','failed') AND next_attempt_at <= %s + ORDER BY id ASC LIMIT 50", current_time( 'mysql' ) )