HEX
Server: LiteSpeed
System: Linux node612.namehero.net 4.18.0-553.121.1.lve.el8.x86_64 #1 SMP Thu Apr 30 16:40:41 UTC 2026 x86_64
User: dfwparty (1186)
PHP: 8.3.31
Disabled: NONE
Upload Files
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 );
}