Skip to content

Commit 879388d

Browse files
committed
feat: add unread messages backend — last_read_at column, count_unread, mark-as-read endpoint
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
1 parent 3437b0a commit 879388d

6 files changed

Lines changed: 294 additions & 4 deletions

File tree

data-machine.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ function datamachine_allow_json_upload( $mimes ) {
377377
function () {
378378
\DataMachine\Core\Database\Chat\Chat::ensure_context_column();
379379
\DataMachine\Core\Database\Chat\Chat::ensure_agent_id_column();
380+
\DataMachine\Core\Database\Chat\Chat::ensure_last_read_at_column();
380381
},
381382
6
382383
);
@@ -555,6 +556,7 @@ function datamachine_activate_for_site() {
555556
\DataMachine\Core\Database\Chat\Chat::create_table();
556557
\DataMachine\Core\Database\Chat\Chat::ensure_context_column();
557558
\DataMachine\Core\Database\Chat\Chat::ensure_agent_id_column();
559+
\DataMachine\Core\Database\Chat\Chat::ensure_last_read_at_column();
558560

559561
// Ensure default agent memory files exist.
560562
// During activation the Abilities API is unavailable (init already fired before

inc/Abilities/Chat/GetChatSessionAbility.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ public function execute( array $input ): array {
127127
'session_id' => $session['session_id'],
128128
'conversation' => $session['messages'],
129129
'metadata' => $session['metadata'],
130+
'last_read_at' => $session['last_read_at'] ?? null,
130131
);
131132
}
132133
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
/**
3+
* Mark Session Read Ability
4+
*
5+
* Sets last_read_at on a chat session to track unread messages.
6+
*
7+
* @package DataMachine\Abilities\Chat
8+
* @since 0.62.0
9+
*/
10+
11+
namespace DataMachine\Abilities\Chat;
12+
13+
use DataMachine\Core\Admin\DateFormatter;
14+
15+
defined( 'ABSPATH' ) || exit;
16+
17+
class MarkSessionReadAbility {
18+
19+
use ChatSessionHelpers;
20+
21+
public function __construct() {
22+
$this->initDatabase();
23+
24+
if ( ! class_exists( 'WP_Ability' ) ) {
25+
return;
26+
}
27+
28+
$this->registerAbility();
29+
}
30+
31+
/**
32+
* Register the datamachine/mark-session-read ability.
33+
*/
34+
private function registerAbility(): void {
35+
$register_callback = function () {
36+
wp_register_ability(
37+
'datamachine/mark-session-read',
38+
array(
39+
'label' => __( 'Mark Session Read', 'data-machine' ),
40+
'description' => __( 'Mark a chat session as read up to the current timestamp.', 'data-machine' ),
41+
'category' => 'datamachine',
42+
'input_schema' => array(
43+
'type' => 'object',
44+
'properties' => array(
45+
'session_id' => array(
46+
'type' => 'string',
47+
'description' => __( 'Session ID to mark as read.', 'data-machine' ),
48+
),
49+
'user_id' => array(
50+
'type' => 'integer',
51+
'description' => __( 'User ID for ownership verification.', 'data-machine' ),
52+
),
53+
),
54+
'required' => array( 'session_id' ),
55+
),
56+
'output_schema' => array(
57+
'type' => 'object',
58+
'properties' => array(
59+
'success' => array( 'type' => 'boolean' ),
60+
'last_read_at' => array( 'type' => 'string' ),
61+
'error' => array( 'type' => 'string' ),
62+
),
63+
),
64+
'execute_callback' => array( $this, 'execute' ),
65+
'permission_callback' => array( $this, 'checkPermission' ),
66+
'meta' => array(
67+
'show_in_rest' => true,
68+
),
69+
)
70+
);
71+
};
72+
73+
if ( doing_action( 'wp_abilities_api_init' ) ) {
74+
$register_callback();
75+
} elseif ( ! did_action( 'wp_abilities_api_init' ) ) {
76+
add_action( 'wp_abilities_api_init', $register_callback );
77+
}
78+
}
79+
80+
/**
81+
* Execute mark-session-read ability.
82+
*
83+
* @param array $input Input parameters with session_id and optional user_id.
84+
* @return array Result with last_read_at timestamp.
85+
*/
86+
public function execute( array $input ): array {
87+
if ( empty( $input['session_id'] ) ) {
88+
return array(
89+
'success' => false,
90+
'error' => 'session_id is required.',
91+
);
92+
}
93+
94+
$session_id = sanitize_text_field( $input['session_id'] );
95+
$user_id = ! empty( $input['user_id'] ) ? (int) $input['user_id'] : get_current_user_id();
96+
97+
if ( $user_id <= 0 ) {
98+
return array(
99+
'success' => false,
100+
'error' => 'user_id is required and must be a positive integer.',
101+
);
102+
}
103+
104+
if ( ! $this->can_access_user_sessions( $user_id ) ) {
105+
return array(
106+
'success' => false,
107+
'error' => 'session_access_denied',
108+
);
109+
}
110+
111+
$session = $this->verifySessionOwnership( $session_id, $user_id );
112+
113+
if ( isset( $session['error'] ) ) {
114+
return array(
115+
'success' => false,
116+
'error' => $session['error'],
117+
);
118+
}
119+
120+
$last_read_at = $this->chat_db->mark_session_read( $session_id, $user_id );
121+
122+
if ( false === $last_read_at ) {
123+
return array(
124+
'success' => false,
125+
'error' => 'Failed to mark session as read.',
126+
);
127+
}
128+
129+
return array(
130+
'success' => true,
131+
'last_read_at' => DateFormatter::format_for_api( $last_read_at ),
132+
);
133+
}
134+
}

inc/Abilities/ChatAbilities.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use DataMachine\Abilities\Chat\GetChatSessionAbility;
1515
use DataMachine\Abilities\Chat\DeleteChatSessionAbility;
1616
use DataMachine\Abilities\Chat\CreateChatSessionAbility;
17+
use DataMachine\Abilities\Chat\MarkSessionReadAbility;
1718

1819
defined( 'ABSPATH' ) || exit;
1920

@@ -25,16 +26,18 @@ class ChatAbilities {
2526
private GetChatSessionAbility $get_session;
2627
private DeleteChatSessionAbility $delete_session;
2728
private CreateChatSessionAbility $create_session;
29+
private MarkSessionReadAbility $mark_session_read;
2830

2931
public function __construct() {
3032
if ( ! class_exists( 'WP_Ability' ) || self::$registered ) {
3133
return;
3234
}
3335

34-
$this->list_sessions = new ListChatSessionsAbility();
35-
$this->get_session = new GetChatSessionAbility();
36-
$this->delete_session = new DeleteChatSessionAbility();
37-
$this->create_session = new CreateChatSessionAbility();
36+
$this->list_sessions = new ListChatSessionsAbility();
37+
$this->get_session = new GetChatSessionAbility();
38+
$this->delete_session = new DeleteChatSessionAbility();
39+
$this->create_session = new CreateChatSessionAbility();
40+
$this->mark_session_read = new MarkSessionReadAbility();
3841

3942
self::$registered = true;
4043
}

inc/Api/Chat/Chat.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,24 @@ public static function register_routes() {
199199
)
200200
);
201201

202+
register_rest_route(
203+
'datamachine/v1',
204+
'/chat/sessions/(?P<session_id>[a-f0-9-]+)/read',
205+
array(
206+
'methods' => WP_REST_Server::CREATABLE,
207+
'callback' => array( self::class, 'mark_session_read' ),
208+
'permission_callback' => $chat_permission_callback,
209+
'args' => array(
210+
'session_id' => array(
211+
'type' => 'string',
212+
'required' => true,
213+
'description' => __( 'Session ID to mark as read', 'data-machine' ),
214+
'sanitize_callback' => 'sanitize_text_field',
215+
),
216+
),
217+
)
218+
);
219+
202220
register_rest_route(
203221
'datamachine/v1',
204222
'/chat/sessions',
@@ -355,6 +373,24 @@ public static function list_sessions( WP_REST_Request $request ) {
355373
);
356374
}
357375

376+
/**
377+
* Mark a chat session as read.
378+
*
379+
* @since 0.62.0
380+
*
381+
* @param WP_REST_Request $request Request object.
382+
* @return WP_REST_Response|WP_Error Response data or error.
383+
*/
384+
public static function mark_session_read( WP_REST_Request $request ) {
385+
return self::execute_ability(
386+
'datamachine/mark-session-read',
387+
array(
388+
'session_id' => sanitize_text_field( $request->get_param( 'session_id' ) ),
389+
'user_id' => get_current_user_id(),
390+
)
391+
);
392+
}
393+
358394
/**
359395
* Delete a chat session.
360396
*

inc/Core/Database/Chat/Chat.php

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public static function create_table(): void {
5353
context VARCHAR(20) NOT NULL DEFAULT 'chat' COMMENT 'Execution context: chat, pipeline, system',
5454
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5555
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
56+
last_read_at DATETIME NULL COMMENT 'When the user last read this session',
5657
expires_at DATETIME NULL COMMENT 'Auto-cleanup timestamp',
5758
PRIMARY KEY (session_id),
5859
KEY user_id (user_id),
@@ -145,6 +146,31 @@ public static function ensure_context_column(): void {
145146
}
146147
}
147148

149+
/**
150+
* Ensure last_read_at column exists for unread message tracking.
151+
*
152+
* dbDelta can miss edge cases on existing installs, so we perform an explicit
153+
* column check and ALTER as a safety net.
154+
*
155+
* @since 0.62.0
156+
* @return void
157+
*/
158+
public static function ensure_last_read_at_column(): void {
159+
global $wpdb;
160+
161+
$table_name = self::get_prefixed_table_name();
162+
163+
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared
164+
$column = $wpdb->get_var( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $table_name, 'last_read_at' ) );
165+
166+
if ( $column ) {
167+
return;
168+
}
169+
170+
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared
171+
$wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD COLUMN last_read_at DATETIME NULL AFTER updated_at', $table_name ) );
172+
}
173+
148174
/**
149175
* Check if table exists
150176
*
@@ -514,12 +540,15 @@ public function get_user_sessions(
514540
}
515541
}
516542

543+
$last_read_at = $session['last_read_at'] ?? null;
544+
517545
$result[] = array(
518546
'session_id' => $session['session_id'],
519547
'title' => $session['title'] ?? null,
520548
'context' => $session['context'] ?? 'chat',
521549
'first_message' => mb_substr( $first_message, 0, 100 ),
522550
'message_count' => count( $messages ),
551+
'unread_count' => $this->count_unread( $messages, $last_read_at ),
523552
'created_at' => DateFormatter::format_for_api( $session['created_at'] ?? null ),
524553
'updated_at' => DateFormatter::format_for_api( $session['updated_at'] ?? $session['created_at'] ?? null ),
525554
);
@@ -689,6 +718,91 @@ public function update_title( string $session_id, string $title ): bool {
689718
return true;
690719
}
691720

721+
/**
722+
* Count unread assistant messages in a session.
723+
*
724+
* Counts assistant messages whose metadata.timestamp is newer than
725+
* the given last_read_at value. If last_read_at is NULL, all assistant
726+
* messages are considered unread.
727+
*
728+
* @since 0.62.0
729+
*
730+
* @param array $messages Decoded messages array from the session.
731+
* @param string|null $last_read_at ISO 8601 or MySQL datetime string, or null if never read.
732+
* @return int Number of unread assistant messages.
733+
*/
734+
public function count_unread( array $messages, ?string $last_read_at ): int {
735+
$count = 0;
736+
737+
foreach ( $messages as $msg ) {
738+
if ( ( $msg['role'] ?? '' ) !== 'assistant' ) {
739+
continue;
740+
}
741+
742+
// Skip tool call/result messages — only count visible assistant responses.
743+
$type = $msg['metadata']['type'] ?? 'text';
744+
if ( 'tool_call' === $type || 'tool_result' === $type ) {
745+
continue;
746+
}
747+
748+
if ( null === $last_read_at ) {
749+
++$count;
750+
continue;
751+
}
752+
753+
$timestamp = $msg['metadata']['timestamp'] ?? null;
754+
if ( $timestamp && strtotime( $timestamp ) > strtotime( $last_read_at ) ) {
755+
++$count;
756+
}
757+
}
758+
759+
return $count;
760+
}
761+
762+
/**
763+
* Mark a session as read by setting last_read_at to the current time.
764+
*
765+
* @since 0.62.0
766+
*
767+
* @param string $session_id Session UUID.
768+
* @param int $user_id User ID for ownership verification.
769+
* @return string|false The new last_read_at value on success, false on failure.
770+
*/
771+
public function mark_session_read( string $session_id, int $user_id ) {
772+
global $wpdb;
773+
774+
$table_name = self::get_prefixed_table_name();
775+
$last_read_at = current_time( 'mysql', true );
776+
777+
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
778+
$result = $wpdb->update(
779+
$table_name,
780+
array( 'last_read_at' => $last_read_at ),
781+
array(
782+
'session_id' => $session_id,
783+
'user_id' => $user_id,
784+
),
785+
array( '%s' ),
786+
array( '%s', '%d' )
787+
);
788+
789+
if ( false === $result ) {
790+
do_action(
791+
'datamachine_log',
792+
'error',
793+
'Failed to mark chat session as read',
794+
array(
795+
'session_id' => $session_id,
796+
'user_id' => $user_id,
797+
'error' => $wpdb->last_error,
798+
)
799+
);
800+
return false;
801+
}
802+
803+
return $last_read_at;
804+
}
805+
692806
/**
693807
* Cleanup old sessions based on retention period
694808
*

0 commit comments

Comments
 (0)