invite the integration by name), * or every request fails with a 404 `object_not_found` even though the * token and database id are both correct. `friendly_notion_error()` surfaces * that specific case with the actual fix, not a generic "not found". * * Field mapping, kept honest rather than pretending full property-type * awareness: the database's title property is always required by Notion, so * this always sets it (from the form's own submitted "Name"/first text * field, falling back to the form title). Every other submitted field is * written as a `rich_text` property using the FIELD'S OWN LABEL as the * property name — this only works if the target Notion database already has * a matching rich_text property for that label, which the settings UI says * plainly. Real per-field-type-to-Notion-property-type mapping (select, * checkbox, number, etc.) is real, additional work — flagged, not faked. */ class Notion_Integration extends Abstract_Integration { private static $pending_form_id = null; private static $pending_context = null; const RETRY_HOOK = 'chargeforms_retry_notion'; const MAX_ATTEMPTS = 3; const RETRY_DELAY_SECONDS = 300; const API_VERSION = '2022-06-28'; public static function boot() { add_action( self::RETRY_HOOK, [ __CLASS__, 'handle_retry' ], 10, 5 ); } public function key(): string { return 'notion'; } public function label(): string { return __( 'Notion', 'chargeforms' ); } public function is_configured( array $settings ): bool { return ! empty( $settings['enabled'] ) && '' !== $this->resolve_token( $settings ) && '' !== $this->resolve_database_id( $settings ); } public function process( array $values, array $form, int $entry_id, array $settings, $attempt = 1 ) { $token = $this->resolve_token( $settings ); $database_id = $this->resolve_database_id( $settings ); $url = 'https://api.notion.com/v1/pages'; self::$pending_form_id = $form['id']; self::$pending_context = [ 'values' => $values, 'form' => $form, 'entry_id' => $entry_id, 'settings' => $settings, 'attempt' => $attempt, 'url' => $url, ]; add_action( 'http_api_debug', [ __CLASS__, 'log_response' ], 10, 5 ); wp_remote_post( $url, [ 'timeout' => 10, 'blocking' => false, 'headers' => [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $token, 'Notion-Version' => self::API_VERSION, ], 'body' => wp_json_encode( [ 'parent' => [ 'database_id' => $database_id ], 'properties' => $this->build_properties( $form, $values, $settings ), ] ), ] ); remove_action( 'http_api_debug', [ __CLASS__, 'log_response' ], 10 ); self::$pending_form_id = null; self::$pending_context = null; } /** WP-Cron callback — same shape as the other integrations' handle_retry(). */ public static function handle_retry( $values, $form, $entry_id, $settings, $attempt ) { ( new self() )->process( $values, $form, $entry_id, $settings, $attempt ); } /** Fire-and-forget response logging + retry — identical mechanics to Slack_Integration::log_response(). */ public static function log_response( $response, $context, $class, $args, $url ) { if ( 'response' !== $context || null === self::$pending_form_id || ! self::$pending_context || $url !== self::$pending_context['url'] ) { return; } $failed = false; if ( is_wp_error( $response ) ) { $message = $response->get_error_message() ?: __( 'The request failed with no further detail.', 'chargeforms' ); Api_Log::log( 'notion', self::$pending_form_id, $url, null, $message ); $failed = true; } else { $code = wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); Api_Log::log( 'notion', self::$pending_form_id, $url, $code ?: null, $body ?: __( 'No response received.', 'chargeforms' ) ); $failed = ! $code || $code >= 400; } if ( $failed && self::$pending_context['attempt'] < self::MAX_ATTEMPTS ) { $ctx = self::$pending_context; wp_schedule_single_event( time() + self::RETRY_DELAY_SECONDS, self::RETRY_HOOK, [ $ctx['values'], $ctx['form'], $ctx['entry_id'], $ctx['settings'], $ctx['attempt'] + 1 ] ); } } private function resolve_token( array $settings ) { $token = $settings['token'] ?? Settings::get( 'notion_default_token' ); return is_string( $token ) ? trim( $token ) : ''; } private function resolve_database_id( array $settings ) { $id = $settings['database_id'] ?? Settings::get( 'notion_default_database_id' ); return is_string( $id ) ? trim( $id ) : ''; } /** * Builds Notion's typed `properties` object. The title property name * varies per database (Notion databases don't all call it "Name"), so * this reads which property is actually of type `title` isn't possible * without a schema lookup this integration doesn't make (kept simple, * on purpose) — instead it writes to whatever title-property name the * form owner configured (defaulting to "Name", Notion's own default for * a new database), which the settings UI explains. */ private function build_properties( array $form, array $values, array $settings ) { $title_property = trim( (string) ( $settings['title_property'] ?? '' ) ) ?: 'Name'; $title_value = ''; foreach ( Field_Sanitizer::flatten_submittable_fields( $form['fields'] ) as $field ) { if ( in_array( $field['type'], [ 'name', 'text', 'email' ], true ) && ! empty( $values[ $field['id'] ] ) ) { $title_value = is_array( $values[ $field['id'] ] ) ? implode( ' ', $values[ $field['id'] ] ) : (string) $values[ $field['id'] ]; break; } } if ( '' === $title_value ) { $title_value = sprintf( /* translators: %s: form title */ __( 'Submission — %s', 'chargeforms' ), $form['title'] ?? '' ); } $properties = [ $title_property => [ 'title' => [ [ 'text' => [ 'content' => mb_substr( $title_value, 0, 2000 ) ] ] ], ], ]; foreach ( Field_Sanitizer::flatten_submittable_fields( $form['fields'] ) as $field ) { if ( in_array( $field['type'], Field_Sanitizer::NON_INPUT_TYPES, true ) || empty( $values[ $field['id'] ] ) ) { continue; } $label = $field['label'] ?? $field['id']; if ( $label === $title_property ) { continue; // Already used as the title above. } $value = $values[ $field['id'] ]; $properties[ $label ] = [ 'rich_text' => [ [ 'text' => [ 'content' => mb_substr( is_array( $value ) ? wp_json_encode( $value ) : (string) $value, 0, 2000 ) ] ] ], ]; } return $properties; } }