* @author Maximum Software Inc * @license GPL-3.0-only * @link https://agenistrator.com/ * * SPDX-FileCopyrightText: 2025-2026 Alex Ruban * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc * SPDX-License-Identifier: GPL-3.0-only */ namespace Agenistrator; // Direct access prevention if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Thread_Manager class. */ class Thread_Manager extends Manager { /** * Constructor - initializes the manager * @since 1.0.0 */ protected function __construct() { parent::__construct( 'thread_settings' ); } /** * Get default configuration * * @return array Default configuration * @since 1.0.0 */ protected function get_default_config() { return array(); } /** * Plugin activation tasks * @since 1.0.0 */ public function activate_plugin() { parent::activate_plugin(); $this->create_database_tables(); } /** * Uninstall plugin - removes all thread data * @since 1.0.0 */ public function uninstall_plugin() { parent::uninstall_plugin(); $this->drop_database_tables(); } /** * Get the table name for threads * * @return string The full table name with WordPress prefix * @since 1.0.0 */ public function get_table() { global $wpdb; return $wpdb->prefix . 'agenistrator_threads'; } /** * Create threads database table */ private function create_database_tables() { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); $table_name = $this->get_table(); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is a hardcoded string literal with a trusted prefix, not user input. $sql = "CREATE TABLE {$table_name} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, user_id bigint(20) unsigned NOT NULL, title varchar(64) NOT NULL DEFAULT 'default', imported_tools JSON, content_guard varchar(16) DEFAULT NULL, loop_state JSON, PRIMARY KEY (id), UNIQUE KEY user_title (user_id, title) ) {$charset_collate};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); } /** * Drop threads database table */ private function drop_database_tables() { global $wpdb; $table_name = $this->get_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table DDL; table name is a hardcoded string literal with a trusted prefix, not user input; caching not applicable. $wpdb->query( "DROP TABLE IF EXISTS {$table_name}" ); } /** * Get or create a thread for a user (race-safe) * * Uses SELECT first to avoid unnecessary INSERT operations. Falls back to * INSERT ... ON DUPLICATE KEY UPDATE for new threads to handle race conditions. * * @param int $user_id The user ID. * @param string $title The thread title (default: 'default'). * @return Thread The thread instance. * @since 1.0.0 */ public function get_or_create_thread( $user_id, $title = 'default' ) { global $wpdb; $table_name = $this->get_table(); $user_id = (int) $user_id; // Try to get existing thread first (common case) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; caching not applicable; table name is a hardcoded string literal with a trusted prefix, not user input. $row = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded. "SELECT id, user_id, title FROM {$table_name} WHERE user_id = %d AND title = %s", $user_id, $title ), ARRAY_A ); if ( $row ) { return new Thread( (int) $row['id'], (int) $row['user_id'], $row['title'] ); } // Thread doesn't exist, create it with atomic upsert: INSERT if not exists, otherwise set LAST_INSERT_ID to existing id. // This may increment AUTO_INCREMENT but that's okay since this will probably be rare. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; caching not applicable; table name is a hardcoded string literal with a trusted prefix, not user input. $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded. "INSERT INTO {$table_name} (user_id, title) VALUES (%d, %s) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)", $user_id, $title ) ); return new Thread( (int) $wpdb->insert_id, $user_id, $title ); } /** * Get thread by thread ID * * @param int $thread_id The thread ID. * @return Thread|null Thread instance or null if not found. * @since 1.0.0 */ public function get_thread( $thread_id ) { global $wpdb; $table_name = $this->get_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; table name from get_table() is hardcoded; caching not applicable. $row = $wpdb->get_row( $wpdb->prepare( "SELECT id, user_id, title FROM {$table_name} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded $thread_id ), ARRAY_A ); if ( ! $row ) { return null; } return new Thread( (int) $row['id'], (int) $row['user_id'], $row['title'] ); } /** * Register hooks for cascade deletes * @since 1.0.0 */ public function register_hooks() { add_action( 'delete_user', array( $this, 'deleted_user' ) ); } /** * Handle user deletion - cascade delete all user's threads * * @param int $user_id The user ID being deleted. * @since 1.0.0 */ public function deleted_user( $user_id ) { try { $this->delete_threads_by_user( (int) $user_id ); } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Silently fail - cascade deletion should not block user deletion } } /** * Get all thread IDs for a user * * @param int $user_id The user ID. * @return int[] Array of thread IDs. * @since 1.0.0 */ public function get_thread_ids_by_user( $user_id ) { global $wpdb; $table_name = $this->get_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; table name from get_table() is hardcoded; caching not applicable. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$table_name} WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded $user_id ) ); return array_map( 'intval', $ids ); } /** * Delete all threads for a user and cascade to dependent data * * @param int $user_id The user ID. * @since 1.0.0 */ public function delete_threads_by_user( $user_id ) { $thread_ids = $this->get_thread_ids_by_user( $user_id ); // Cascade delete dependent data first foreach ( $thread_ids as $thread_id ) { Chat_Log_Manager::get_instance()->delete_messages_by_thread_id( $thread_id ); Event_Queue::get_instance()->delete_events_by_thread( $thread_id ); } // Delete the threads global $wpdb; $table_name = $this->get_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; caching not applicable. $wpdb->delete( $table_name, array( 'user_id' => $user_id ), array( '%d' ) ); } }