From 566ff825164ac0351c386dc38c03ef5b5b922afc Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 30 Mar 2026 16:04:35 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20unread=20messages=20backend=20?= =?UTF-8?q?=E2=80=94=20last=5Fread=5Fat=20column,=20count=5Funread,=20mark?= =?UTF-8?q?-as-read=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unread message tracking infrastructure for chat sessions: - Add last_read_at DATETIME NULL column to datamachine_chat_sessions (dbDelta + ensure migration) - Add count_unread() helper — counts visible assistant messages with metadata.timestamp > last_read_at - Add mark_session_read() method — sets last_read_at = NOW() where session_id and user_id match - Add unread_count to get_user_sessions() return shape - Add last_read_at to single session GET response (via GetChatSessionAbility) - Register datamachine/mark-session-read ability - Add POST /datamachine/v1/chat/sessions/{session_id}/read REST endpoint (uses execute_ability pattern) Closes #997 --- data-machine.php | 2 + inc/Abilities/Chat/GetChatSessionAbility.php | 1 + inc/Abilities/Chat/MarkSessionReadAbility.php | 134 ++++++++++++++++++ inc/Abilities/ChatAbilities.php | 11 +- inc/Api/Chat/Chat.php | 36 +++++ inc/Core/Database/Chat/Chat.php | 114 +++++++++++++++ 6 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 inc/Abilities/Chat/MarkSessionReadAbility.php diff --git a/data-machine.php b/data-machine.php index e39ac50c6..c08345987 100644 --- a/data-machine.php +++ b/data-machine.php @@ -377,6 +377,7 @@ function datamachine_allow_json_upload( $mimes ) { function () { \DataMachine\Core\Database\Chat\Chat::ensure_context_column(); \DataMachine\Core\Database\Chat\Chat::ensure_agent_id_column(); + \DataMachine\Core\Database\Chat\Chat::ensure_last_read_at_column(); }, 6 ); @@ -555,6 +556,7 @@ function datamachine_activate_for_site() { \DataMachine\Core\Database\Chat\Chat::create_table(); \DataMachine\Core\Database\Chat\Chat::ensure_context_column(); \DataMachine\Core\Database\Chat\Chat::ensure_agent_id_column(); + \DataMachine\Core\Database\Chat\Chat::ensure_last_read_at_column(); // Ensure default agent memory files exist. // During activation the Abilities API is unavailable (init already fired before diff --git a/inc/Abilities/Chat/GetChatSessionAbility.php b/inc/Abilities/Chat/GetChatSessionAbility.php index 5c407ac39..d1476b168 100644 --- a/inc/Abilities/Chat/GetChatSessionAbility.php +++ b/inc/Abilities/Chat/GetChatSessionAbility.php @@ -127,6 +127,7 @@ public function execute( array $input ): array { 'session_id' => $session['session_id'], 'conversation' => $session['messages'], 'metadata' => $session['metadata'], + 'last_read_at' => $session['last_read_at'] ?? null, ); } } diff --git a/inc/Abilities/Chat/MarkSessionReadAbility.php b/inc/Abilities/Chat/MarkSessionReadAbility.php new file mode 100644 index 000000000..81beb28f0 --- /dev/null +++ b/inc/Abilities/Chat/MarkSessionReadAbility.php @@ -0,0 +1,134 @@ +initDatabase(); + + if ( ! class_exists( 'WP_Ability' ) ) { + return; + } + + $this->registerAbility(); + } + + /** + * Register the datamachine/mark-session-read ability. + */ + private function registerAbility(): void { + $register_callback = function () { + wp_register_ability( + 'datamachine/mark-session-read', + array( + 'label' => __( 'Mark Session Read', 'data-machine' ), + 'description' => __( 'Mark a chat session as read up to the current timestamp.', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'session_id' => array( + 'type' => 'string', + 'description' => __( 'Session ID to mark as read.', 'data-machine' ), + ), + 'user_id' => array( + 'type' => 'integer', + 'description' => __( 'User ID for ownership verification.', 'data-machine' ), + ), + ), + 'required' => array( 'session_id' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'last_read_at' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'execute' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + }; + + if ( doing_action( 'wp_abilities_api_init' ) ) { + $register_callback(); + } elseif ( ! did_action( 'wp_abilities_api_init' ) ) { + add_action( 'wp_abilities_api_init', $register_callback ); + } + } + + /** + * Execute mark-session-read ability. + * + * @param array $input Input parameters with session_id and optional user_id. + * @return array Result with last_read_at timestamp. + */ + public function execute( array $input ): array { + if ( empty( $input['session_id'] ) ) { + return array( + 'success' => false, + 'error' => 'session_id is required.', + ); + } + + $session_id = sanitize_text_field( $input['session_id'] ); + $user_id = ! empty( $input['user_id'] ) ? (int) $input['user_id'] : get_current_user_id(); + + if ( $user_id <= 0 ) { + return array( + 'success' => false, + 'error' => 'user_id is required and must be a positive integer.', + ); + } + + if ( ! $this->can_access_user_sessions( $user_id ) ) { + return array( + 'success' => false, + 'error' => 'session_access_denied', + ); + } + + $session = $this->verifySessionOwnership( $session_id, $user_id ); + + if ( isset( $session['error'] ) ) { + return array( + 'success' => false, + 'error' => $session['error'], + ); + } + + $last_read_at = $this->chat_db->mark_session_read( $session_id, $user_id ); + + if ( false === $last_read_at ) { + return array( + 'success' => false, + 'error' => 'Failed to mark session as read.', + ); + } + + return array( + 'success' => true, + 'last_read_at' => DateFormatter::format_for_api( $last_read_at ), + ); + } +} diff --git a/inc/Abilities/ChatAbilities.php b/inc/Abilities/ChatAbilities.php index 82d47d785..5624b6771 100644 --- a/inc/Abilities/ChatAbilities.php +++ b/inc/Abilities/ChatAbilities.php @@ -14,6 +14,7 @@ use DataMachine\Abilities\Chat\GetChatSessionAbility; use DataMachine\Abilities\Chat\DeleteChatSessionAbility; use DataMachine\Abilities\Chat\CreateChatSessionAbility; +use DataMachine\Abilities\Chat\MarkSessionReadAbility; defined( 'ABSPATH' ) || exit; @@ -25,16 +26,18 @@ class ChatAbilities { private GetChatSessionAbility $get_session; private DeleteChatSessionAbility $delete_session; private CreateChatSessionAbility $create_session; + private MarkSessionReadAbility $mark_session_read; public function __construct() { if ( ! class_exists( 'WP_Ability' ) || self::$registered ) { return; } - $this->list_sessions = new ListChatSessionsAbility(); - $this->get_session = new GetChatSessionAbility(); - $this->delete_session = new DeleteChatSessionAbility(); - $this->create_session = new CreateChatSessionAbility(); + $this->list_sessions = new ListChatSessionsAbility(); + $this->get_session = new GetChatSessionAbility(); + $this->delete_session = new DeleteChatSessionAbility(); + $this->create_session = new CreateChatSessionAbility(); + $this->mark_session_read = new MarkSessionReadAbility(); self::$registered = true; } diff --git a/inc/Api/Chat/Chat.php b/inc/Api/Chat/Chat.php index c3df49ccc..99b0394cf 100644 --- a/inc/Api/Chat/Chat.php +++ b/inc/Api/Chat/Chat.php @@ -199,6 +199,24 @@ public static function register_routes() { ) ); + register_rest_route( + 'datamachine/v1', + '/chat/sessions/(?P[a-f0-9-]+)/read', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( self::class, 'mark_session_read' ), + 'permission_callback' => $chat_permission_callback, + 'args' => array( + 'session_id' => array( + 'type' => 'string', + 'required' => true, + 'description' => __( 'Session ID to mark as read', 'data-machine' ), + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ) + ); + register_rest_route( 'datamachine/v1', '/chat/sessions', @@ -355,6 +373,24 @@ public static function list_sessions( WP_REST_Request $request ) { ); } + /** + * Mark a chat session as read. + * + * @since 0.62.0 + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error Response data or error. + */ + public static function mark_session_read( WP_REST_Request $request ) { + return self::execute_ability( + 'datamachine/mark-session-read', + array( + 'session_id' => sanitize_text_field( $request->get_param( 'session_id' ) ), + 'user_id' => get_current_user_id(), + ) + ); + } + /** * Delete a chat session. * diff --git a/inc/Core/Database/Chat/Chat.php b/inc/Core/Database/Chat/Chat.php index 0f4adba4c..26b8f3ca4 100644 --- a/inc/Core/Database/Chat/Chat.php +++ b/inc/Core/Database/Chat/Chat.php @@ -53,6 +53,7 @@ public static function create_table(): void { context VARCHAR(20) NOT NULL DEFAULT 'chat' COMMENT 'Execution context: chat, pipeline, system', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + last_read_at DATETIME NULL COMMENT 'When the user last read this session', expires_at DATETIME NULL COMMENT 'Auto-cleanup timestamp', PRIMARY KEY (session_id), KEY user_id (user_id), @@ -145,6 +146,31 @@ public static function ensure_context_column(): void { } } + /** + * Ensure last_read_at column exists for unread message tracking. + * + * dbDelta can miss edge cases on existing installs, so we perform an explicit + * column check and ALTER as a safety net. + * + * @since 0.62.0 + * @return void + */ + public static function ensure_last_read_at_column(): void { + global $wpdb; + + $table_name = self::get_prefixed_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared + $column = $wpdb->get_var( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $table_name, 'last_read_at' ) ); + + if ( $column ) { + return; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD COLUMN last_read_at DATETIME NULL AFTER updated_at', $table_name ) ); + } + /** * Check if table exists * @@ -514,12 +540,15 @@ public function get_user_sessions( } } + $last_read_at = $session['last_read_at'] ?? null; + $result[] = array( 'session_id' => $session['session_id'], 'title' => $session['title'] ?? null, 'context' => $session['context'] ?? 'chat', 'first_message' => mb_substr( $first_message, 0, 100 ), 'message_count' => count( $messages ), + 'unread_count' => $this->count_unread( $messages, $last_read_at ), 'created_at' => DateFormatter::format_for_api( $session['created_at'] ?? null ), 'updated_at' => DateFormatter::format_for_api( $session['updated_at'] ?? $session['created_at'] ?? null ), ); @@ -689,6 +718,91 @@ public function update_title( string $session_id, string $title ): bool { return true; } + /** + * Count unread assistant messages in a session. + * + * Counts assistant messages whose metadata.timestamp is newer than + * the given last_read_at value. If last_read_at is NULL, all assistant + * messages are considered unread. + * + * @since 0.62.0 + * + * @param array $messages Decoded messages array from the session. + * @param string|null $last_read_at ISO 8601 or MySQL datetime string, or null if never read. + * @return int Number of unread assistant messages. + */ + public function count_unread( array $messages, ?string $last_read_at ): int { + $count = 0; + + foreach ( $messages as $msg ) { + if ( ( $msg['role'] ?? '' ) !== 'assistant' ) { + continue; + } + + // Skip tool call/result messages — only count visible assistant responses. + $type = $msg['metadata']['type'] ?? 'text'; + if ( 'tool_call' === $type || 'tool_result' === $type ) { + continue; + } + + if ( null === $last_read_at ) { + ++$count; + continue; + } + + $timestamp = $msg['metadata']['timestamp'] ?? null; + if ( $timestamp && strtotime( $timestamp ) > strtotime( $last_read_at ) ) { + ++$count; + } + } + + return $count; + } + + /** + * Mark a session as read by setting last_read_at to the current time. + * + * @since 0.62.0 + * + * @param string $session_id Session UUID. + * @param int $user_id User ID for ownership verification. + * @return string|false The new last_read_at value on success, false on failure. + */ + public function mark_session_read( string $session_id, int $user_id ) { + global $wpdb; + + $table_name = self::get_prefixed_table_name(); + $last_read_at = current_time( 'mysql', true ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->update( + $table_name, + array( 'last_read_at' => $last_read_at ), + array( + 'session_id' => $session_id, + 'user_id' => $user_id, + ), + array( '%s' ), + array( '%s', '%d' ) + ); + + if ( false === $result ) { + do_action( + 'datamachine_log', + 'error', + 'Failed to mark chat session as read', + array( + 'session_id' => $session_id, + 'user_id' => $user_id, + 'error' => $wpdb->last_error, + ) + ); + return false; + } + + return $last_read_at; + } + /** * Cleanup old sessions based on retention period *