File: /home/dfwparty/dfwchat.net/wp-content/plugins/buddyboss-tools/auto-delete/bb-tools-auto-delete.php
<?php
/**
* BuddyBoss Tools — Auto-delete engine.
*
* Stateless batched deletion functions for the notification auto-delete rules,
* plus the daily cron that enqueues them into Platform's notifications
* background updater. Each batch deletes one chunk (~500 rows) of notifications
* matching an enabled rule, along with their bp_notifications_meta rows.
*
* @package BuddyBoss\Tools
* @since 1.0.0
*/
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* Batch-delete a chunk of notifications older than the configured threshold.
*
* Shared engine for the read and unread rules. Reads the rule options on every
* call (stateless — required because the background updater re-queues only the
* bare callback string, dropping any args). Returns true when more matching rows
* remain so the updater re-queues; false when the rule is disabled or no matching
* rows remain.
*
* @since 1.0.0
*
* @param int $is_new Notification is_new value to match (0 = read, 1 = unread).
* @param string $enabled_key Option key holding the rule's enabled flag.
* @param string $value_key Option key holding the age-threshold value.
* @param string $unit_key Option key holding the age-threshold unit.
* @param int $default_value Default threshold value when the option is unset.
*
* @return bool True if more rows remain; false when done or rule is disabled.
*/
function bb_tools_auto_delete_batch_notifications( $is_new, $enabled_key, $value_key, $unit_key, $default_value ) {
global $wpdb;
if ( ! bp_is_active( 'notifications' ) ) {
return false;
}
$bp = buddypress();
if ( ! (int) bp_get_option( $enabled_key, 0 ) ) {
return false;
}
$value = max( 1, (int) bp_get_option( $value_key, $default_value ) );
$unit = bp_get_option( $unit_key, 'month' );
$cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$value} {$unit}s" ) );
$batch = 500;
// Select id + user_id: the user_id is needed to invalidate Platform's per-user
// notification caches after the raw-SQL delete (which bypasses the delete hooks).
$query = $wpdb->prepare(
"SELECT id, user_id FROM {$bp->notifications->table_name} WHERE is_new = %d AND date_notified < %s ORDER BY id ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is a trusted BuddyPress internal, not user input.
$is_new,
$cutoff,
$batch
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results( $query, ARRAY_A );
if ( empty( $rows ) ) {
return false;
}
$ids = wp_list_pluck( $rows, 'id' );
$user_ids = array_unique( wp_list_pluck( $rows, 'user_id' ) );
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
// Delete the notification rows first, then their metadata. If the second query fails,
// the leftover meta is inert (and swept by the orphan-meta batch) — the reverse order
// could instead leave a live notification stripped of the meta it needs to render.
$delete_notifications = $wpdb->prepare(
"DELETE FROM {$bp->notifications->table_name} WHERE id IN ($placeholders)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name is a trusted BuddyPress internal; $placeholders is a controlled list of %d built above and the ids are passed to prepare().
...$ids
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query( $delete_notifications );
$delete_meta = $wpdb->prepare(
"DELETE FROM {$bp->notifications->table_name_meta} WHERE notification_id IN ($placeholders)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name is a trusted BuddyPress internal; $placeholders is a controlled list of %d built above and the ids are passed to prepare().
...$ids
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query( $delete_meta );
// The raw-SQL delete bypasses Platform's `bp_notification_before_delete` hook, so replicate
// its cache clearing here. This mirrors bp_notifications_clear_all_for_user_cache_before_delete()
// in buddyboss-platform: src/bp-notifications/bp-notifications-cache.php (verified against
// Platform 3.1.1). The user-facing list + read/unread/grouped caches — the ones that would
// otherwise keep a deleted notification "showing" from a warm object cache — are cleared via
// the public bp_notifications_clear_all_for_user_cache() (stable API since BP 2.3.0). The
// per-row wp_cache_delete() keys below mirror that same file's internal format; if a future
// Platform version changes them, re-check that file.
if ( function_exists( 'bp_notifications_clear_all_for_user_cache' ) ) {
foreach ( $user_ids as $user_id ) {
bp_notifications_clear_all_for_user_cache( (int) $user_id );
}
}
foreach ( $rows as $row ) {
$id = (int) $row['id'];
$user_id = (int) $row['user_id'];
wp_cache_delete( $id, 'bp_notifications' );
wp_cache_delete( 'bp_notifications_check_access_' . $user_id . '_' . $id, 'bp_notifications' );
wp_cache_delete( $id, 'notification_meta' );
}
// Row totals changed — drop the cached counts so the settings panel reflects reality.
delete_transient( 'bb_tools_auto_delete_counts' );
return true;
}
/**
* Batch-delete a chunk of read notifications older than the configured threshold.
*
* Thin wrapper over bb_tools_auto_delete_batch_notifications(). The function name
* is persisted verbatim as the background-updater callback, so it must not change.
*
* @since 1.0.0
*
* @return bool True if more rows remain; false when done or rule is disabled.
*/
function bb_tools_auto_delete_batch_read() {
return bb_tools_auto_delete_batch_notifications(
0,
'bb_tools_auto_delete_read_enabled',
'bb_tools_auto_delete_read_value',
'bb_tools_auto_delete_read_unit',
3
);
}
/**
* Batch-delete a chunk of unread notifications older than the configured threshold.
*
* Thin wrapper over bb_tools_auto_delete_batch_notifications(). The function name
* is persisted verbatim as the background-updater callback, so it must not change.
*
* @since 1.0.0
*
* @return bool True if more rows remain; false when done or rule is disabled.
*/
function bb_tools_auto_delete_batch_unread() {
return bb_tools_auto_delete_batch_notifications(
1,
'bb_tools_auto_delete_unread_enabled',
'bb_tools_auto_delete_unread_value',
'bb_tools_auto_delete_unread_unit',
6
);
}
/**
* Count read vs unread notification rows currently in the table.
*
* Total counts across bp_notifications regardless of age — used by the
* Auto-delete settings panel to show admins how many rows each rule targets.
*
* @since 1.0.0
*
* @return array{read:int,unread:int} Row counts keyed 'read' / 'unread'.
*/
function bb_tools_auto_delete_get_counts() {
$counts = array(
'read' => 0,
'unread' => 0,
);
if ( ! bp_is_active( 'notifications' ) ) {
return $counts;
}
// These counts are display-only on the settings panel, so a short-lived cache is fine
// and avoids re-scanning a potentially huge bp_notifications table on every admin load.
$cached = get_transient( 'bb_tools_auto_delete_counts' );
if ( is_array( $cached ) && isset( $cached['read'], $cached['unread'] ) ) {
return $cached;
}
global $wpdb;
$bp = buddypress();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
"SELECT is_new, COUNT(*) AS total FROM {$bp->notifications->table_name} GROUP BY is_new", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is a trusted BuddyPress internal; this static query has no user input.
ARRAY_A
);
foreach ( (array) $rows as $row ) {
if ( 1 === (int) $row['is_new'] ) {
$counts['unread'] = (int) $row['total'];
} else {
$counts['read'] = (int) $row['total'];
}
}
set_transient( 'bb_tools_auto_delete_counts', $counts, 5 * MINUTE_IN_SECONDS );
return $counts;
}
/**
* Batch-delete a chunk of orphaned notification meta rows.
*
* Removes bp_notifications_meta rows whose parent notification no longer exists.
* Runs as part of the daily job so leftover meta left by other delete paths
* never accumulates. Returns true when more orphaned rows may remain; false when
* none are found.
*
* @since 1.0.0
*
* @return bool True if more orphaned rows may remain; false when none found.
*/
function bb_tools_auto_delete_batch_orphan_meta() {
global $wpdb;
if ( ! bp_is_active( 'notifications' ) ) {
return false;
}
$bp = buddypress();
$batch = 500;
$orphan_query = $wpdb->prepare(
"SELECT m.id FROM {$bp->notifications->table_name_meta} m LEFT JOIN {$bp->notifications->table_name} n ON m.notification_id = n.id WHERE n.id IS NULL LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are trusted BuddyPress internals, not user input.
$batch
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$ids = $wpdb->get_col( $orphan_query );
if ( empty( $ids ) ) {
return false;
}
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
$delete_orphan_meta = $wpdb->prepare(
"DELETE FROM {$bp->notifications->table_name_meta} WHERE id IN ($placeholders)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name is a trusted BuddyPress internal; $placeholders is a controlled list of %d built above and the ids are passed to prepare().
...$ids
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query( $delete_orphan_meta );
return true;
}
/**
* Cron callback: enqueue enabled auto-delete rules into the background updater.
*
* Fires only when WP-Cron triggers bb_tools_auto_delete_cron. Never runs on a
* page load. The orphaned-meta sweep piggybacks on any scheduled run.
*
* @since 1.0.0
*/
function bb_tools_auto_delete_run() {
if ( ! bp_is_active( 'notifications' ) ) {
return;
}
global $bb_notifications_background_updater;
// Platform instantiates this global on bp_init @ 52 (and only when notifications
// is active). Bail if it is not ready so an unattended cron run can't fatal.
if ( ! isset( $bb_notifications_background_updater ) || ! is_object( $bb_notifications_background_updater ) ) {
return;
}
$queued = false;
if ( (int) bp_get_option( 'bb_tools_auto_delete_read_enabled', 0 ) ) {
$bb_notifications_background_updater->push_to_queue(
array( 'callback' => 'bb_tools_auto_delete_batch_read' )
);
$queued = true;
}
if ( (int) bp_get_option( 'bb_tools_auto_delete_unread_enabled', 0 ) ) {
$bb_notifications_background_updater->push_to_queue(
array( 'callback' => 'bb_tools_auto_delete_batch_unread' )
);
$queued = true;
}
if ( $queued ) {
$bb_notifications_background_updater->push_to_queue(
array( 'callback' => 'bb_tools_auto_delete_batch_orphan_meta' )
);
$bb_notifications_background_updater->save()->dispatch();
}
}
add_action( 'bb_tools_auto_delete_cron', 'bb_tools_auto_delete_run' );
/**
* Schedule or unschedule the daily auto-delete cron.
*
* Called from the settings AJAX save after the rule options are written.
*
* @since 1.0.0
*
* @return void
*/
function bb_tools_auto_delete_update_schedule() {
$read_on = (bool) bp_get_option( 'bb_tools_auto_delete_read_enabled', 0 );
$unread_on = (bool) bp_get_option( 'bb_tools_auto_delete_unread_enabled', 0 );
if ( $read_on || $unread_on ) {
if ( ! wp_next_scheduled( 'bb_tools_auto_delete_cron' ) ) {
// Prefer Platform's 24-hour interval, but fall back to WordPress core's built-in
// 'daily' (same cadence, always registered) when it isn't available — otherwise
// wp_schedule_event() would fail silently and a rule could look enabled in the UI
// while no cron ever fires.
$schedules = wp_get_schedules();
$recurrence = isset( $schedules['bb_schedule_24hours'] ) ? 'bb_schedule_24hours' : 'daily';
wp_schedule_event( time(), $recurrence, 'bb_tools_auto_delete_cron' );
}
} else {
wp_clear_scheduled_hook( 'bb_tools_auto_delete_cron' );
}
}
/**
* Sanitize an auto-delete unit value.
*
* Accepts 'day', 'week', 'month', or 'year' only; anything else falls back to 'month'.
*
* @since 1.0.0
*
* @param mixed $value The value to sanitize.
*
* @return string Sanitized unit value.
*/
function bb_tools_auto_delete_sanitize_unit( $value ) {
$value = sanitize_text_field( $value );
$allowed = array( 'day', 'week', 'month', 'year' );
if ( ! in_array( $value, $allowed, true ) ) {
return 'month';
}
return $value;
}
/**
* Maximum threshold value allowed for a given unit.
*
* The threshold never needs to exceed roughly a few years, so each unit caps at
* its own sensible ceiling. Kept in sync with the UNIT_MAX map in the AutoDelete
* React component.
*
* @since 1.0.0
*
* @param string $unit Unit key ('day' | 'week' | 'month' | 'year').
*
* @return int Maximum allowed value for that unit.
*/
function bb_tools_auto_delete_unit_max( $unit ) {
$max = array(
'day' => 365,
'week' => 53,
'month' => 12,
'year' => 5,
);
return isset( $max[ $unit ] ) ? $max[ $unit ] : $max['month'];
}
/**
* Clamp a threshold value into the valid range for its unit.
*
* Server-side authority for the per-unit maximums — never trust the posted value
* (client validation is bypassable).
*
* @since 1.0.0
*
* @param int $value The raw value.
* @param string $unit Sanitized unit key.
*
* @return int Value clamped to [1, bb_tools_auto_delete_unit_max( $unit )].
*/
function bb_tools_auto_delete_clamp_value( $value, $unit ) {
$value = max( 1, (int) $value );
return min( bb_tools_auto_delete_unit_max( $unit ), $value );
}