Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions cleverpush-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@ public static function send_notification($title, $body, $url, $options = array()
return CleverPush_Api::request('/notification/send', $params);
}

public static function delete_notification($notification_id)
{
$channel_id = get_option('cleverpush_channel_id');
$api_key_private = get_option('cleverpush_apikey_private');

if (empty($channel_id) || empty($notification_id) || empty($api_key_private)) {
return null;
}

return wp_remote_request(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: delete_notification() returns transport and API failures without giving the caller a success/failure contract. delete_draft_notification() then removes both local identifiers unconditionally, so a failed DELETE leaves the remote draft orphaned and prevents later retry; propagate the deletion result and clear metadata only after a successful response.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cleverpush-api.php, line 97:

<comment>`delete_notification()` returns transport and API failures without giving the caller a success/failure contract. `delete_draft_notification()` then removes both local identifiers unconditionally, so a failed DELETE leaves the remote draft orphaned and prevents later retry; propagate the deletion result and clear metadata only after a successful response.</comment>

<file context>
@@ -85,6 +85,27 @@ public static function send_notification($title, $body, $url, $options = array()
+            return null;
+        }
+
+        return wp_remote_request(
+            CLEVERPUSH_API_ENDPOINT . '/channel/' . rawurlencode($channel_id) . '/notification/' . rawurlencode($notification_id),
+            array(
</file context>

CLEVERPUSH_API_ENDPOINT . '/channel/' . rawurlencode($channel_id) . '/notification/' . rawurlencode($notification_id),
array(
'method' => 'DELETE',
'timeout' => 20, // phpcs:ignore
'headers' => array(
'authorization' => $api_key_private
)
)
);
}

public static function update_channel($channel_id, $params = array())
{
if (empty($channel_id)) {
Expand Down
180 changes: 153 additions & 27 deletions cleverpush.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
Plugin URI: https://cleverpush.com
Description: Send push notifications to your users right through your website. Visit <a href="https://cleverpush.com">CleverPush</a> for more details.
Author: CleverPush
Version: 1.9.8
Version: 1.9.9
Author URI: https://cleverpush.com
Text Domain: cleverpush
Domain Path: /languages
Expand Down Expand Up @@ -763,11 +763,46 @@ public function metabox($post)
cpCheckbox.addEventListener('change', function (e) {
cpContent.style.display = e.target.checked ? 'block' : 'none';

if (!e.target.checked && cpScheduledAtPicker) {
cpScheduledAtPicker.value = '';
if (!e.target.checked) {
if (cpScheduledAtPicker) {
cpScheduledAtPicker.value = '';
}
if (cpScheduledAtInput) {
cpScheduledAtInput.value = '';
}
}
});

var syncScheduledAt = function () {};
if (cpScheduledAtInput && cpScheduledAtPicker) {
var padDatePart = function (value) {
return (value < 10 ? '0' : '') + value;
};
var getLocalDateString = function (date) {
return date.getFullYear() + '-' + padDatePart(date.getMonth() + 1) + '-' + padDatePart(date.getDate()) + 'T' + padDatePart(date.getHours()) + ':' + padDatePart(date.getMinutes());
};
syncScheduledAt = function () {
if (!cpScheduledAtPicker.value) {
cpScheduledAtInput.value = '';
return;
}
var parsed = new Date(cpScheduledAtPicker.value);
if (!isNaN(parsed.getTime())) {
cpScheduledAtInput.value = parsed.toISOString();
}
};
cpScheduledAtPicker.min = getLocalDateString(new Date());
if (cpScheduledAtInput.value && new Date(cpScheduledAtInput.value) > new Date()) {
cpScheduledAtPicker.value = getLocalDateString(new Date(cpScheduledAtInput.value));
}
cpScheduledAtPicker.addEventListener('input', syncScheduledAt);
cpScheduledAtPicker.addEventListener('change', syncScheduledAt);
var postForm = document.getElementById('post');
if (postForm) {
postForm.addEventListener('submit', syncScheduledAt);
}
}

var initCleverPush = function () {
if (typeof wp !== 'undefined' && wp.data && wp.data.subscribe && wp.data.select) {
var hasNotice = false;
Expand All @@ -790,6 +825,10 @@ public function metabox($post)
var isPreviewingPost = coreEditor.isPreviewingPost();
var postStatus = coreEditor.getEditedPostAttribute('status');

if (isSavingPost && !wasSavingPost) {
syncScheduledAt();
}

// Save metaboxes on save completion, except for autosaves that are not a post preview.
var shouldTriggerTemplateNotice = (
(wasSavingPost && !isSavingPost && !wasAutosavingPost) ||
Expand Down Expand Up @@ -971,23 +1010,6 @@ public function metabox($post)
}
}

if (cpScheduledAtInput && cpScheduledAtPicker) {
var getLocalDateString = function(date) {
return date.getFullYear() + '-' + ((date.getMonth() + 1) + '').padStart(2, '0') + '-' + (date.getDate() + '') + "T" + (date.getHours() + '').padStart(2, '0') + ":" + (date.getMinutes() + '').padStart(2, '0')
};
var date = new Date();
cpScheduledAtPicker.min = getLocalDateString(date);
if (cpScheduledAtInput.value && new Date(cpScheduledAtInput.value) > new Date()) {
cpScheduledAtPicker.value = getLocalDateString(new Date(cpScheduledAtInput.value));
}
cpScheduledAtPicker.addEventListener('change', function() {
if (!cpScheduledAtPicker.value) {
cpScheduledAtInput.value = '';
return;
}
cpScheduledAtInput.value = new Date(cpScheduledAtPicker.value).toISOString();
});
}
}
}
};
Expand Down Expand Up @@ -1126,16 +1148,18 @@ public function publish_post($post_id)
return;
}

if (!current_user_can('cleverpush_send')) {
$saved_send = get_post_meta($post_id, 'cleverpush_send_notification', true);
$publishing_via_cron = (function_exists('wp_doing_cron') && wp_doing_cron()) || (defined('DOING_CRON') && DOING_CRON);
if (!current_user_can('cleverpush_send') && !($publishing_via_cron && $saved_send)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Scheduled cron publishes now send to all subscriptions instead of the segments, topics, or tags selected in the metabox. Persist those selections and load them from post metadata when $_POST is unavailable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cleverpush.php, line 1153:

<comment>Scheduled cron publishes now send to all subscriptions instead of the segments, topics, or tags selected in the metabox. Persist those selections and load them from post metadata when `$_POST` is unavailable.</comment>

<file context>
@@ -1126,16 +1148,18 @@ public function publish_post($post_id)
-            if (!current_user_can('cleverpush_send')) {
+            $saved_send = get_post_meta($post_id, 'cleverpush_send_notification', true);
+            $publishing_via_cron = (function_exists('wp_doing_cron') && wp_doing_cron()) || (defined('DOING_CRON') && DOING_CRON);
+            if (!current_user_can('cleverpush_send') && !($publishing_via_cron && $saved_send)) {
                 return;
             }
</file context>

return;
}

if (isset($_POST['cleverpush_metabox_form_data_available']) ? !isset($_POST['cleverpush_send_notification']) : !get_post_meta($post_id, 'cleverpush_send_notification', true)) {
if (isset($_POST['cleverpush_metabox_form_data_available']) ? !isset($_POST['cleverpush_send_notification']) : !$saved_send) {
return;
}

if (get_post_meta($post_id, 'cleverpush_notification_sent', true)) {
$notification_sent_at = get_post_meta(get_the_ID(), 'cleverpush_notification_sent_at', true);
$notification_sent_at = get_post_meta($post_id, 'cleverpush_notification_sent_at', true);
if (!empty($notification_sent_at) && (time() - $notification_sent_at) < 60) {
return;
}
Expand All @@ -1145,15 +1169,28 @@ public function publish_post($post_id)
if (get_option('cleverpush_notification_title_required') == 'on') {
$title = null;
}
$text = !empty(get_the_excerpt()) ? html_entity_decode(get_the_excerpt()) : '';
$text = get_the_excerpt($post_id);
$text = !empty($text) ? html_entity_decode($text) : '';
$url = get_permalink($post_id);
$metabox_submitted = isset($_POST['cleverpush_metabox_form_data_available']);

if (!empty($_POST['cleverpush_title'])) {
$title = sanitize_text_field(wp_unslash($_POST['cleverpush_title']));
$text = '';
} elseif (!$metabox_submitted) {
$saved_title = get_post_meta($post_id, 'cleverpush_title', true);
if (!empty($saved_title)) {
$title = $saved_title;
$text = '';
}
}
if (!empty($_POST['cleverpush_text'])) {
$text = sanitize_text_field(wp_unslash($_POST['cleverpush_text']));
} elseif (!$metabox_submitted) {
$saved_text = get_post_meta($post_id, 'cleverpush_text', true);
if (!empty($saved_text)) {
$text = $saved_text;
}
}

if (empty($title)) {
Expand All @@ -1170,13 +1207,19 @@ public function publish_post($post_id)
if (isset($_POST['cleverpush_use_tags']) && $_POST['cleverpush_use_tags'] == '1' && !empty($_POST['cleverpush_tags'])) {
$options['tags'] = array_map('sanitize_text_field', $_POST['cleverpush_tags']);
}
$thumbnail_url = get_the_post_thumbnail_url();
$thumbnail_url = get_the_post_thumbnail_url($post_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Targeting lost on scheduled publish

Medium Severity

The new cron publish path sends without segments, topics, or tags. Those targeting fields are only read from $_POST and are never persisted, so a scheduled WordPress post is delivered to all subscribers instead of the selected audience.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f288555. Configure here.

if (!empty($thumbnail_url)) {
$options['mediaUrl'] = $thumbnail_url;
}

if (!empty($_POST['cleverpush_scheduled_at'])) {
$options['scheduledAt'] = sanitize_text_field(wp_unslash($_POST['cleverpush_scheduled_at']));
} else {
$saved_scheduled_at = get_post_meta($post_id, 'cleverpush_scheduled_at', true);
$saved_timestamp = !empty($saved_scheduled_at) ? strtotime($saved_scheduled_at) : false;
if ($saved_timestamp && $saved_timestamp > time()) {
$options['scheduledAt'] = $saved_scheduled_at;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale schedule used on publish

Medium Severity

The new scheduledAt fallback always reads saved post meta when $_POST['cleverpush_scheduled_at'] is empty, even if the metabox was submitted with a cleared date. publish_post runs before save_post, so a previously stored time can still be attached and the push is delayed instead of sending immediately.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f288555. Configure here.

}

delete_post_meta($post_id, 'cleverpush_send_notification');
Expand All @@ -1187,6 +1230,7 @@ public function publish_post($post_id)
update_option('cleverpush_notification_error', null);
update_post_meta($post_id, 'cleverpush_notification_sent', true);
update_post_meta($post_id, 'cleverpush_notification_sent_at', time());
$this->delete_draft_notification($post_id);

} catch (Exception $ex) {
update_option('cleverpush_notification_result', array('status' => 'error', 'message' => $ex->getMessage() ));
Expand Down Expand Up @@ -1215,10 +1259,12 @@ public function save_post($post_id, $post)
if (isset($_POST['cleverpush_text'])) {
update_post_meta($post_id, 'cleverpush_text', sanitize_text_field($_POST['cleverpush_text']));
}
if (isset($_POST['cleverpush_scheduled_at'])) {
update_post_meta($post_id, 'cleverpush_scheduled_at', sanitize_text_field($_POST['cleverpush_scheduled_at']));
if (isset($_POST['cleverpush_metabox_form_data_available']) && isset($_POST['cleverpush_scheduled_at'])) {
update_post_meta($post_id, 'cleverpush_scheduled_at', sanitize_text_field(wp_unslash($_POST['cleverpush_scheduled_at'])));
}

$this->sync_draft_notification($post_id);

if (!empty($_POST['cleverpush_story_id'])) {
if (isset($_POST['clear_cache']) && !empty($_POST['cleverpush_story_id'])) {
delete_transient('cleverpush_story_' . sanitize_text_field($_POST['cleverpush_story_id']) . '_content');
Expand All @@ -1245,6 +1291,86 @@ public function save_post($post_id, $post)
}
}

public function sync_draft_notification($post_id)
{
if ((defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) || wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
return;
}

if (!isset($_POST['cleverpush_metabox_form_data_available'])) {
return;
}

$status = get_post_status($post_id);
if (!in_array($status, array('draft', 'future', 'pending'), true)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Delete the remote draft when the post leaves these statuses and when the post is deleted; this early return skips cleanup, leaving the CleverPush draft behind.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cleverpush.php, line 1305:

<comment>Delete the remote draft when the post leaves these statuses and when the post is deleted; this early return skips cleanup, leaving the CleverPush draft behind.</comment>

<file context>
@@ -1245,6 +1291,86 @@ public function save_post($post_id, $post)
+            }
+
+            $status = get_post_status($post_id);
+            if (!in_array($status, array('draft', 'future', 'pending'), true)) {
+                return;
+            }
</file context>

return;
}

if (!isset($_POST['cleverpush_send_notification'])) {
$this->delete_draft_notification($post_id);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drafts linger after post removal

Low Severity

sync_draft_notification creates a CleverPush draft but only removes it when the send checkbox is cleared or the post later publishes. Trashing, deleting, or otherwise leaving draft, future, or pending leaves that remote draft in place.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f288555. Configure here.

}

$title = html_entity_decode(get_the_title($post_id));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Draft synchronization ignores the cleverpush_notification_title_required setting and creates a draft from the post title. Apply the same required-headline rule before sending the draft.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cleverpush.php, line 1314:

<comment>Draft synchronization ignores the `cleverpush_notification_title_required` setting and creates a draft from the post title. Apply the same required-headline rule before sending the draft.</comment>

<file context>
@@ -1245,6 +1291,86 @@ public function save_post($post_id, $post)
+                return;
+            }
+
+            $title = html_entity_decode(get_the_title($post_id));
+            if (!empty($_POST['cleverpush_title'])) {
+                $title = sanitize_text_field(wp_unslash($_POST['cleverpush_title']));
</file context>
Suggested change
$title = html_entity_decode(get_the_title($post_id));
$title = get_option('cleverpush_notification_title_required') == 'on' ? '' : html_entity_decode(get_the_title($post_id));

if (!empty($_POST['cleverpush_title'])) {
$title = sanitize_text_field(wp_unslash($_POST['cleverpush_title']));
}
$text = '';
if (!empty($_POST['cleverpush_text'])) {
$text = sanitize_text_field(wp_unslash($_POST['cleverpush_text']));
}
if (empty($title)) {
return;
}
Comment on lines +1322 to +1324

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Clearing a draft’s title leaves its previously synchronized CleverPush draft orphaned because this early return skips cleanup. Delete the tracked draft before returning when no valid title remains.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cleverpush.php, line 1322:

<comment>Clearing a draft’s title leaves its previously synchronized CleverPush draft orphaned because this early return skips cleanup. Delete the tracked draft before returning when no valid title remains.</comment>

<file context>
@@ -1245,6 +1291,86 @@ public function save_post($post_id, $post)
+            if (!empty($_POST['cleverpush_text'])) {
+                $text = sanitize_text_field(wp_unslash($_POST['cleverpush_text']));
+            }
+            if (empty($title)) {
+                return;
+            }
</file context>
Suggested change
if (empty($title)) {
return;
}
if (empty($title)) {
$this->delete_draft_notification($post_id);
return;
}


$scheduled_at = get_post_meta($post_id, 'cleverpush_scheduled_at', true);
$scheduled_timestamp = !empty($scheduled_at) ? strtotime($scheduled_at) : false;
if (!$scheduled_timestamp || $scheduled_timestamp <= time()) {
$scheduled_at = '';
}

$fingerprint = md5($title . '|' . $text . '|' . $scheduled_at);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The draft deduplication hash omits the notification URL, so permalink changes leave the remote draft linking to the previous post URL. Include the current permalink in the fingerprint.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cleverpush.php, line 1332:

<comment>The draft deduplication hash omits the notification URL, so permalink changes leave the remote draft linking to the previous post URL. Include the current permalink in the fingerprint.</comment>

<file context>
@@ -1245,6 +1291,86 @@ public function save_post($post_id, $post)
+                $scheduled_at = '';
+            }
+
+            $fingerprint = md5($title . '|' . $text . '|' . $scheduled_at);
+            $existing_id = get_post_meta($post_id, 'cleverpush_draft_notification_id', true);
+            $existing_fingerprint = get_post_meta($post_id, 'cleverpush_draft_notification_hash', true);
</file context>
Suggested change
$fingerprint = md5($title . '|' . $text . '|' . $scheduled_at);
$fingerprint = md5($title . '|' . $text . '|' . $scheduled_at . '|' . get_permalink($post_id));

$existing_id = get_post_meta($post_id, 'cleverpush_draft_notification_id', true);
$existing_fingerprint = get_post_meta($post_id, 'cleverpush_draft_notification_hash', true);
if (!empty($existing_id) && $existing_fingerprint === $fingerprint) {
return;
}

if (!empty($existing_id)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Changing a draft deletes the existing CleverPush draft before creating its replacement. If the replacement request fails, the old draft is already gone; create the replacement first and delete the old notification only after the new ID and hash are stored.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cleverpush.php, line 1339:

<comment>Changing a draft deletes the existing CleverPush draft before creating its replacement. If the replacement request fails, the old draft is already gone; create the replacement first and delete the old notification only after the new ID and hash are stored.</comment>

<file context>
@@ -1245,6 +1291,86 @@ public function save_post($post_id, $post)
+                return;
+            }
+
+            if (!empty($existing_id)) {
+                $this->delete_draft_notification($post_id);
+            }
</file context>

$this->delete_draft_notification($post_id);
}

$options = array('status' => 'draft');
if (!empty($scheduled_at)) {
$options['scheduledAt'] = $scheduled_at;
}

try {
$response = CleverPush_Api::send_notification($title, $text, get_permalink($post_id), $options);
if (empty($response) || is_wp_error($response)) {
return;
}
$body = json_decode(wp_remote_retrieve_body($response));
if (empty($body->notificationId)) {
return;
}
update_post_meta($post_id, 'cleverpush_draft_notification_id', sanitize_text_field($body->notificationId));
update_post_meta($post_id, 'cleverpush_draft_notification_hash', $fingerprint);
} catch (Exception $ex) {
update_option('cleverpush_notification_error', $ex->getMessage());
}
}

public function delete_draft_notification($post_id)
{
$notification_id = get_post_meta($post_id, 'cleverpush_draft_notification_id', true);
if (!empty($notification_id)) {
CleverPush_Api::delete_notification($notification_id);
}
delete_post_meta($post_id, 'cleverpush_draft_notification_id');
delete_post_meta($post_id, 'cleverpush_draft_notification_hash');
}

public function show_public_preview( $query )
{
if ($query->is_main_query()
Expand Down
7 changes: 6 additions & 1 deletion readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Plugin URI: https://cleverpush.com
Tags: push notifications, web push, browser notifications, woocommerce
Requires at least: 2.7
Tested up to: 6.7.2
Stable tag: 1.9.8
Stable tag: 1.9.9
License: GPLv2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html

Expand Down Expand Up @@ -34,6 +34,11 @@ If you are using any cache plugin, also be sure, to empty your cache.

== ChangeLog ==

= 1.9.9 =
* Keep the optional notification schedule after saving a post
* Send that scheduled push when WordPress publishes the post, including a scheduled post
* Show an unpublished scheduled post as a draft in CleverPush

= 1.9.8 =
* Fixed issue with capabilities_version

Expand Down
Loading