Add Hub Messages, Files, Users, Reactions, and Pins APIs
Complete backend for SprintChat Hub migration: - Messages: send, edit, delete, list (paginated cursor), thread, search - Files: upload (multipart), download, thumbnail, info, list - Users: get, getByIds, search, status (online detection) - Reactions: add, remove, list (grouped by emoji) - Pins: pin, unpin, list (with message content) - Channel stats: member/message/pinned/unread counts 4 new DB tables: Hub_Messages, Hub_Files, Hub_Reactions, Hub_PinnedPosts 21 new endpoints added to PUBLIC_ROUTES Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4a9db0de0a
commit
1dacefcf70
22 changed files with 1278 additions and 0 deletions
|
|
@ -530,6 +530,32 @@ const PUBLIC_ROUTES = [
|
|||
'/api/hub/channels/members.php',
|
||||
'/api/hub/channels/join.php',
|
||||
'/api/hub/channels/leave.php',
|
||||
'/api/hub/channels/stats.php',
|
||||
// hub messages
|
||||
'/api/hub/messages/send.php',
|
||||
'/api/hub/messages/edit.php',
|
||||
'/api/hub/messages/delete.php',
|
||||
'/api/hub/messages/list.php',
|
||||
'/api/hub/messages/thread.php',
|
||||
'/api/hub/messages/search.php',
|
||||
// hub files
|
||||
'/api/hub/files/upload.php',
|
||||
'/api/hub/files/download.php',
|
||||
'/api/hub/files/info.php',
|
||||
'/api/hub/files/list.php',
|
||||
// hub users
|
||||
'/api/hub/users/get.php',
|
||||
'/api/hub/users/getByIds.php',
|
||||
'/api/hub/users/search.php',
|
||||
'/api/hub/users/status.php',
|
||||
// hub reactions
|
||||
'/api/hub/reactions/add.php',
|
||||
'/api/hub/reactions/remove.php',
|
||||
'/api/hub/reactions/list.php',
|
||||
// hub pins
|
||||
'/api/hub/pins/pin.php',
|
||||
'/api/hub/pins/unpin.php',
|
||||
'/api/hub/pins/list.php',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
70
api/hub/channels/stats.php
Normal file
70
api/hub/channels/stats.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/channels/stats.php
|
||||
*
|
||||
* Get channel statistics: member count, message count, pinned count, unread count.
|
||||
*
|
||||
* Query params:
|
||||
* ChannelID int REQUIRED
|
||||
* AgentAddress string optional if provided, includes unread count for this agent
|
||||
*
|
||||
* Response: { OK: true, Stats: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$channelId = (int) ($_GET['ChannelID'] ?? 0);
|
||||
$agentAddress = trim($_GET['AgentAddress'] ?? '');
|
||||
|
||||
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
|
||||
$channel = queryOne("SELECT ID FROM Hub_Channels WHERE ID = ?", [$channelId]);
|
||||
if (!$channel) jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found']);
|
||||
|
||||
$memberCount = (int) (queryOne(
|
||||
"SELECT COUNT(*) as cnt FROM Hub_ChannelMembers WHERE ChannelID = ?",
|
||||
[$channelId]
|
||||
)['cnt'] ?? 0);
|
||||
|
||||
$messageCount = (int) (queryOne(
|
||||
"SELECT COUNT(*) as cnt FROM Hub_Messages WHERE ChannelID = ? AND IsDeleted = 0",
|
||||
[$channelId]
|
||||
)['cnt'] ?? 0);
|
||||
|
||||
$pinnedCount = (int) (queryOne(
|
||||
"SELECT COUNT(*) as cnt FROM Hub_PinnedPosts WHERE ChannelID = ?",
|
||||
[$channelId]
|
||||
)['cnt'] ?? 0);
|
||||
|
||||
$unreadCount = 0;
|
||||
if ($agentAddress !== '') {
|
||||
$membership = queryOne(
|
||||
"SELECT LastViewedAt FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[$channelId, $agentAddress]
|
||||
);
|
||||
if ($membership && $membership['LastViewedAt']) {
|
||||
$unreadCount = (int) (queryOne(
|
||||
"SELECT COUNT(*) as cnt FROM Hub_Messages
|
||||
WHERE ChannelID = ? AND IsDeleted = 0 AND CreatedAt > ?",
|
||||
[$channelId, $membership['LastViewedAt']]
|
||||
)['cnt'] ?? 0);
|
||||
} elseif ($membership) {
|
||||
// Never viewed — all messages are unread
|
||||
$unreadCount = $messageCount;
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Stats' => [
|
||||
'ChannelID' => $channelId,
|
||||
'MemberCount' => $memberCount,
|
||||
'MessageCount' => $messageCount,
|
||||
'PinnedCount' => $pinnedCount,
|
||||
'UnreadCount' => $unreadCount,
|
||||
],
|
||||
]);
|
||||
45
api/hub/files/download.php
Normal file
45
api/hub/files/download.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/files/download.php
|
||||
*
|
||||
* Download a file by ID.
|
||||
*
|
||||
* Query params:
|
||||
* FileID int REQUIRED
|
||||
* Thumb int optional 1 = return thumbnail instead
|
||||
*
|
||||
* Response: binary file stream
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$fileId = (int) ($_GET['FileID'] ?? 0);
|
||||
$thumb = (int) ($_GET['Thumb'] ?? 0);
|
||||
|
||||
if ($fileId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'file_id_required']);
|
||||
|
||||
$record = queryOne("SELECT * FROM Hub_Files WHERE ID = ?", [$fileId]);
|
||||
if (!$record) jsonResponse(['OK' => false, 'ERROR' => 'file_not_found']);
|
||||
|
||||
$path = ($thumb && $record['ThumbnailPath'])
|
||||
? appRoot() . '/' . $record['ThumbnailPath']
|
||||
: appRoot() . '/' . $record['StoragePath'];
|
||||
|
||||
if (!file_exists($path)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'file_missing_from_disk']);
|
||||
}
|
||||
|
||||
$mimeType = $thumb ? 'image/jpeg' : $record['MimeType'];
|
||||
$fileName = $thumb ? 'thumb_' . $record['FileName'] : $record['FileName'];
|
||||
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Disposition: inline; filename="' . addslashes($fileName) . '"');
|
||||
header('Content-Length: ' . filesize($path));
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
|
||||
readfile($path);
|
||||
exit;
|
||||
39
api/hub/files/info.php
Normal file
39
api/hub/files/info.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/files/info.php
|
||||
*
|
||||
* Get file metadata by ID.
|
||||
*
|
||||
* Query params:
|
||||
* FileID int REQUIRED
|
||||
*
|
||||
* Response: { OK: true, File: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$fileId = (int) ($_GET['FileID'] ?? 0);
|
||||
if ($fileId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'file_id_required']);
|
||||
|
||||
$record = queryOne("SELECT * FROM Hub_Files WHERE ID = ?", [$fileId]);
|
||||
if (!$record) jsonResponse(['OK' => false, 'ERROR' => 'file_not_found']);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'File' => [
|
||||
'ID' => (int) $record['ID'],
|
||||
'MessageID' => $record['MessageID'] ? (int) $record['MessageID'] : null,
|
||||
'ChannelID' => (int) $record['ChannelID'],
|
||||
'UploaderAddress' => $record['UploaderAddress'],
|
||||
'FileName' => $record['FileName'],
|
||||
'FileSize' => (int) $record['FileSize'],
|
||||
'MimeType' => $record['MimeType'],
|
||||
'DownloadURL' => baseUrl() . '/' . $record['StoragePath'],
|
||||
'ThumbnailURL' => $record['ThumbnailPath'] ? baseUrl() . '/' . $record['ThumbnailPath'] : null,
|
||||
'CreatedAt' => toISO8601($record['CreatedAt']),
|
||||
],
|
||||
]);
|
||||
57
api/hub/files/list.php
Normal file
57
api/hub/files/list.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/files/list.php
|
||||
*
|
||||
* List files in a channel.
|
||||
*
|
||||
* Query params:
|
||||
* ChannelID int REQUIRED
|
||||
* Limit int optional default 50, max 200
|
||||
* Offset int optional default 0
|
||||
*
|
||||
* Response: { OK: true, Files: [...], Total: int }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$channelId = (int) ($_GET['ChannelID'] ?? 0);
|
||||
$limit = min(max((int) ($_GET['Limit'] ?? 50), 1), 200);
|
||||
$offset = max((int) ($_GET['Offset'] ?? 0), 0);
|
||||
|
||||
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
|
||||
$total = (int) (queryOne(
|
||||
"SELECT COUNT(*) as cnt FROM Hub_Files WHERE ChannelID = ?",
|
||||
[$channelId]
|
||||
)['cnt'] ?? 0);
|
||||
|
||||
$rows = queryTimed(
|
||||
"SELECT * FROM Hub_Files WHERE ChannelID = ? ORDER BY CreatedAt DESC LIMIT ? OFFSET ?",
|
||||
[$channelId, $limit, $offset]
|
||||
);
|
||||
|
||||
$files = [];
|
||||
foreach ($rows as $r) {
|
||||
$files[] = [
|
||||
'ID' => (int) $r['ID'],
|
||||
'MessageID' => $r['MessageID'] ? (int) $r['MessageID'] : null,
|
||||
'ChannelID' => (int) $r['ChannelID'],
|
||||
'UploaderAddress' => $r['UploaderAddress'],
|
||||
'FileName' => $r['FileName'],
|
||||
'FileSize' => (int) $r['FileSize'],
|
||||
'MimeType' => $r['MimeType'],
|
||||
'DownloadURL' => baseUrl() . '/' . $r['StoragePath'],
|
||||
'ThumbnailURL' => $r['ThumbnailPath'] ? baseUrl() . '/' . $r['ThumbnailPath'] : null,
|
||||
'CreatedAt' => toISO8601($r['CreatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Files' => $files,
|
||||
'Total' => $total,
|
||||
]);
|
||||
124
api/hub/files/upload.php
Normal file
124
api/hub/files/upload.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/files/upload.php
|
||||
*
|
||||
* Upload a file (multipart/form-data).
|
||||
*
|
||||
* Form fields:
|
||||
* ChannelID int REQUIRED
|
||||
* UploaderAddress string REQUIRED agent address
|
||||
* MessageID int optional attach to an existing message
|
||||
* file file REQUIRED the uploaded file
|
||||
*
|
||||
* Response: { OK: true, File: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$channelId = (int) ($_POST['ChannelID'] ?? 0);
|
||||
$uploaderAddress = trim($_POST['UploaderAddress'] ?? '');
|
||||
$messageId = isset($_POST['MessageID']) ? (int) $_POST['MessageID'] : null;
|
||||
|
||||
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
if ($uploaderAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'uploader_address_required']);
|
||||
|
||||
// Verify channel exists
|
||||
$channel = queryOne("SELECT ID FROM Hub_Channels WHERE ID = ?", [$channelId]);
|
||||
if (!$channel) jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found']);
|
||||
|
||||
// Check file upload
|
||||
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||
$errCode = $_FILES['file']['error'] ?? -1;
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'file_upload_failed', 'Code' => $errCode]);
|
||||
}
|
||||
|
||||
$file = $_FILES['file'];
|
||||
$maxSize = 50 * 1024 * 1024; // 50MB
|
||||
if ($file['size'] > $maxSize) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'file_too_large', 'MaxBytes' => $maxSize]);
|
||||
}
|
||||
|
||||
// Sanitize filename
|
||||
$originalName = basename($file['name']);
|
||||
$originalName = preg_replace('/[^a-zA-Z0-9._\-]/', '_', $originalName);
|
||||
if ($originalName === '' || $originalName === '.') $originalName = 'upload';
|
||||
|
||||
$mimeType = $file['type'] ?: 'application/octet-stream';
|
||||
|
||||
// Storage path: /uploads/hub/{channelId}/{uuid}_{filename}
|
||||
$uuid = generateUUID();
|
||||
$storageDir = uploadsRoot() . '/hub/' . $channelId;
|
||||
if (!is_dir($storageDir)) {
|
||||
mkdir($storageDir, 0755, true);
|
||||
}
|
||||
|
||||
$storageName = $uuid . '_' . $originalName;
|
||||
$storagePath = $storageDir . '/' . $storageName;
|
||||
$relPath = 'uploads/hub/' . $channelId . '/' . $storageName;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $storagePath)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'file_save_failed']);
|
||||
}
|
||||
|
||||
// Generate thumbnail for images
|
||||
$thumbnailRelPath = null;
|
||||
if (str_starts_with($mimeType, 'image/') && extension_loaded('gd')) {
|
||||
$thumbDir = $storageDir . '/thumbs';
|
||||
if (!is_dir($thumbDir)) mkdir($thumbDir, 0755, true);
|
||||
|
||||
$thumbName = $uuid . '_thumb.jpg';
|
||||
$thumbPath = $thumbDir . '/' . $thumbName;
|
||||
|
||||
$src = null;
|
||||
if ($mimeType === 'image/jpeg') $src = @imagecreatefromjpeg($storagePath);
|
||||
elseif ($mimeType === 'image/png') $src = @imagecreatefrompng($storagePath);
|
||||
elseif ($mimeType === 'image/gif') $src = @imagecreatefromgif($storagePath);
|
||||
elseif ($mimeType === 'image/webp') $src = @imagecreatefromwebp($storagePath);
|
||||
|
||||
if ($src) {
|
||||
$origW = imagesx($src);
|
||||
$origH = imagesy($src);
|
||||
$maxThumb = 200;
|
||||
$ratio = min($maxThumb / $origW, $maxThumb / $origH, 1);
|
||||
$newW = (int) ($origW * $ratio);
|
||||
$newH = (int) ($origH * $ratio);
|
||||
|
||||
$thumb = imagecreatetruecolor($newW, $newH);
|
||||
imagecopyresampled($thumb, $src, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
|
||||
imagejpeg($thumb, $thumbPath, 80);
|
||||
imagedestroy($src);
|
||||
imagedestroy($thumb);
|
||||
|
||||
$thumbnailRelPath = 'uploads/hub/' . $channelId . '/thumbs/' . $thumbName;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert to DB
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_Files (MessageID, ChannelID, UploaderAddress, FileName, FileSize, MimeType, StoragePath, ThumbnailPath)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[$messageId, $channelId, $uploaderAddress, $originalName, $file['size'], $mimeType, $relPath, $thumbnailRelPath]
|
||||
);
|
||||
$fileId = (int) lastInsertId();
|
||||
|
||||
$record = queryOne("SELECT * FROM Hub_Files WHERE ID = ?", [$fileId]);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'File' => [
|
||||
'ID' => (int) $record['ID'],
|
||||
'MessageID' => $record['MessageID'] ? (int) $record['MessageID'] : null,
|
||||
'ChannelID' => (int) $record['ChannelID'],
|
||||
'UploaderAddress' => $record['UploaderAddress'],
|
||||
'FileName' => $record['FileName'],
|
||||
'FileSize' => (int) $record['FileSize'],
|
||||
'MimeType' => $record['MimeType'],
|
||||
'DownloadURL' => baseUrl() . '/' . $record['StoragePath'],
|
||||
'ThumbnailURL' => $record['ThumbnailPath'] ? baseUrl() . '/' . $record['ThumbnailPath'] : null,
|
||||
'CreatedAt' => toISO8601($record['CreatedAt']),
|
||||
],
|
||||
]);
|
||||
47
api/hub/messages/delete.php
Normal file
47
api/hub/messages/delete.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/messages/delete.php
|
||||
*
|
||||
* Soft-delete a message. Only the sender or channel admin/owner can delete.
|
||||
*
|
||||
* Body:
|
||||
* MessageID int REQUIRED
|
||||
* AgentAddress string REQUIRED who is requesting the delete
|
||||
*
|
||||
* Response: { OK: true }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$messageId = (int) ($body['MessageID'] ?? 0);
|
||||
$agentAddress = trim($body['AgentAddress'] ?? '');
|
||||
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
|
||||
|
||||
$msg = queryOne("SELECT * FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0", [$messageId]);
|
||||
if (!$msg) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
|
||||
|
||||
// Check permission: sender can delete their own, admins/owners can delete any
|
||||
$allowed = ($msg['SenderAddress'] === $agentAddress);
|
||||
if (!$allowed) {
|
||||
$membership = queryOne(
|
||||
"SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[(int) $msg['ChannelID'], $agentAddress]
|
||||
);
|
||||
if ($membership && in_array($membership['Role'], ['admin', 'owner'], true)) {
|
||||
$allowed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$allowed) jsonResponse(['OK' => false, 'ERROR' => 'permission_denied']);
|
||||
|
||||
queryTimed("UPDATE Hub_Messages SET IsDeleted = 1 WHERE ID = ?", [$messageId]);
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
61
api/hub/messages/edit.php
Normal file
61
api/hub/messages/edit.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/messages/edit.php
|
||||
*
|
||||
* Edit a message. Only the original sender can edit.
|
||||
*
|
||||
* Body:
|
||||
* MessageID int REQUIRED
|
||||
* SenderAddress string REQUIRED must match original sender
|
||||
* Content string REQUIRED new content
|
||||
*
|
||||
* Response: { OK: true, Message: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$messageId = (int) ($body['MessageID'] ?? 0);
|
||||
$senderAddress = trim($body['SenderAddress'] ?? '');
|
||||
$content = trim($body['Content'] ?? '');
|
||||
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
if ($senderAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'sender_address_required']);
|
||||
if ($content === '') jsonResponse(['OK' => false, 'ERROR' => 'content_required']);
|
||||
|
||||
// Verify message exists and belongs to sender
|
||||
$msg = queryOne(
|
||||
"SELECT * FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0",
|
||||
[$messageId]
|
||||
);
|
||||
if (!$msg) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
|
||||
if ($msg['SenderAddress'] !== $senderAddress) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'not_message_owner']);
|
||||
}
|
||||
|
||||
queryTimed(
|
||||
"UPDATE Hub_Messages SET Content = ?, IsEdited = 1 WHERE ID = ?",
|
||||
[$content, $messageId]
|
||||
);
|
||||
|
||||
$updated = queryOne("SELECT * FROM Hub_Messages WHERE ID = ?", [$messageId]);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Message' => [
|
||||
'ID' => (int) $updated['ID'],
|
||||
'ChannelID' => (int) $updated['ChannelID'],
|
||||
'SenderAddress' => $updated['SenderAddress'],
|
||||
'Content' => $updated['Content'],
|
||||
'ParentID' => $updated['ParentID'] ? (int) $updated['ParentID'] : null,
|
||||
'IsEdited' => true,
|
||||
'IsDeleted' => false,
|
||||
'CreatedAt' => toISO8601($updated['CreatedAt']),
|
||||
'UpdatedAt' => toISO8601($updated['UpdatedAt']),
|
||||
],
|
||||
]);
|
||||
87
api/hub/messages/list.php
Normal file
87
api/hub/messages/list.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/messages/list.php
|
||||
*
|
||||
* Get messages for a channel, paginated, newest first.
|
||||
*
|
||||
* Query params:
|
||||
* ChannelID int REQUIRED
|
||||
* Limit int optional default 50, max 200
|
||||
* Before int optional message ID — get messages before this ID (cursor pagination)
|
||||
* After int optional message ID — get messages after this ID
|
||||
*
|
||||
* Response: { OK: true, Messages: [...], HasMore: bool }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$channelId = (int) ($_GET['ChannelID'] ?? 0);
|
||||
$limit = min(max((int) ($_GET['Limit'] ?? 50), 1), 200);
|
||||
$before = isset($_GET['Before']) ? (int) $_GET['Before'] : null;
|
||||
$after = isset($_GET['After']) ? (int) $_GET['After'] : null;
|
||||
|
||||
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
|
||||
// Verify channel exists
|
||||
$channel = queryOne("SELECT ID FROM Hub_Channels WHERE ID = ?", [$channelId]);
|
||||
if (!$channel) jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found']);
|
||||
|
||||
// Build query
|
||||
$sql = "SELECT * FROM Hub_Messages WHERE ChannelID = ? AND IsDeleted = 0 AND ParentID IS NULL";
|
||||
$params = [$channelId];
|
||||
|
||||
if ($before !== null) {
|
||||
$sql .= " AND ID < ?";
|
||||
$params[] = $before;
|
||||
}
|
||||
if ($after !== null) {
|
||||
$sql .= " AND ID > ?";
|
||||
$params[] = $after;
|
||||
}
|
||||
|
||||
if ($after !== null) {
|
||||
$sql .= " ORDER BY ID ASC LIMIT ?";
|
||||
} else {
|
||||
$sql .= " ORDER BY ID DESC LIMIT ?";
|
||||
}
|
||||
$params[] = $limit + 1; // fetch one extra to determine HasMore
|
||||
|
||||
$rows = queryTimed($sql, $params);
|
||||
$hasMore = count($rows) > $limit;
|
||||
if ($hasMore) array_pop($rows);
|
||||
|
||||
// If we used ASC (After), reverse for consistent newest-first output
|
||||
if ($after !== null) {
|
||||
$rows = array_reverse($rows);
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
foreach ($rows as $row) {
|
||||
// Get reply count for each root message
|
||||
$replyCount = queryOne(
|
||||
"SELECT COUNT(*) as cnt FROM Hub_Messages WHERE ParentID = ? AND IsDeleted = 0",
|
||||
[(int) $row['ID']]
|
||||
);
|
||||
|
||||
$messages[] = [
|
||||
'ID' => (int) $row['ID'],
|
||||
'ChannelID' => (int) $row['ChannelID'],
|
||||
'SenderAddress' => $row['SenderAddress'],
|
||||
'Content' => $row['Content'],
|
||||
'ParentID' => null,
|
||||
'IsEdited' => (bool) $row['IsEdited'],
|
||||
'ReplyCount' => (int) ($replyCount['cnt'] ?? 0),
|
||||
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||
'UpdatedAt' => toISO8601($row['UpdatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Messages' => $messages,
|
||||
'HasMore' => $hasMore,
|
||||
]);
|
||||
79
api/hub/messages/search.php
Normal file
79
api/hub/messages/search.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/messages/search.php
|
||||
*
|
||||
* Search messages across channels or within a specific channel.
|
||||
*
|
||||
* Query params:
|
||||
* Query string REQUIRED search term
|
||||
* ChannelID int optional limit to a specific channel
|
||||
* Limit int optional default 25, max 100
|
||||
* Offset int optional default 0
|
||||
*
|
||||
* Response: { OK: true, Messages: [...], Total: int }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$query = trim($_GET['Query'] ?? '');
|
||||
$channelId = isset($_GET['ChannelID']) ? (int) $_GET['ChannelID'] : null;
|
||||
$limit = min(max((int) ($_GET['Limit'] ?? 25), 1), 100);
|
||||
$offset = max((int) ($_GET['Offset'] ?? 0), 0);
|
||||
|
||||
if ($query === '') jsonResponse(['OK' => false, 'ERROR' => 'query_required']);
|
||||
|
||||
$searchTerm = '%' . $query . '%';
|
||||
|
||||
// Count total
|
||||
$countSql = "SELECT COUNT(*) as cnt FROM Hub_Messages WHERE Content LIKE ? AND IsDeleted = 0";
|
||||
$countParams = [$searchTerm];
|
||||
|
||||
if ($channelId !== null) {
|
||||
$countSql .= " AND ChannelID = ?";
|
||||
$countParams[] = $channelId;
|
||||
}
|
||||
|
||||
$total = (int) (queryOne($countSql, $countParams)['cnt'] ?? 0);
|
||||
|
||||
// Fetch results
|
||||
$sql = "SELECT m.*, c.Name AS ChannelName, c.DisplayName AS ChannelDisplayName
|
||||
FROM Hub_Messages m
|
||||
JOIN Hub_Channels c ON c.ID = m.ChannelID
|
||||
WHERE m.Content LIKE ? AND m.IsDeleted = 0";
|
||||
$params = [$searchTerm];
|
||||
|
||||
if ($channelId !== null) {
|
||||
$sql .= " AND m.ChannelID = ?";
|
||||
$params[] = $channelId;
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY m.CreatedAt DESC LIMIT ? OFFSET ?";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$rows = queryTimed($sql, $params);
|
||||
|
||||
$messages = [];
|
||||
foreach ($rows as $row) {
|
||||
$messages[] = [
|
||||
'ID' => (int) $row['ID'],
|
||||
'ChannelID' => (int) $row['ChannelID'],
|
||||
'ChannelName' => $row['ChannelName'],
|
||||
'ChannelDisplayName' => $row['ChannelDisplayName'],
|
||||
'SenderAddress' => $row['SenderAddress'],
|
||||
'Content' => $row['Content'],
|
||||
'ParentID' => $row['ParentID'] ? (int) $row['ParentID'] : null,
|
||||
'IsEdited' => (bool) $row['IsEdited'],
|
||||
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Messages' => $messages,
|
||||
'Total' => $total,
|
||||
]);
|
||||
82
api/hub/messages/send.php
Normal file
82
api/hub/messages/send.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/messages/send.php
|
||||
*
|
||||
* Send a message to a channel.
|
||||
*
|
||||
* Body:
|
||||
* ChannelID int REQUIRED
|
||||
* SenderAddress string REQUIRED agent address
|
||||
* Content string REQUIRED message text
|
||||
* ParentID int optional for threaded replies
|
||||
*
|
||||
* Response: { OK: true, Message: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$channelId = (int) ($body['ChannelID'] ?? 0);
|
||||
$senderAddress = trim($body['SenderAddress'] ?? '');
|
||||
$content = trim($body['Content'] ?? '');
|
||||
$parentId = isset($body['ParentID']) ? (int) $body['ParentID'] : null;
|
||||
|
||||
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
if ($senderAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'sender_address_required']);
|
||||
if ($content === '') jsonResponse(['OK' => false, 'ERROR' => 'content_required']);
|
||||
|
||||
// Verify channel exists and is not archived
|
||||
$channel = queryOne("SELECT ID, IsArchived FROM Hub_Channels WHERE ID = ?", [$channelId]);
|
||||
if (!$channel) jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found']);
|
||||
if ((bool) $channel['IsArchived']) jsonResponse(['OK' => false, 'ERROR' => 'channel_archived']);
|
||||
|
||||
// Verify sender is a member of the channel
|
||||
$member = queryOne(
|
||||
"SELECT ID FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[$channelId, $senderAddress]
|
||||
);
|
||||
if (!$member) jsonResponse(['OK' => false, 'ERROR' => 'not_a_member']);
|
||||
|
||||
// Verify parent message exists and belongs to same channel (if threaded)
|
||||
if ($parentId !== null) {
|
||||
$parent = queryOne(
|
||||
"SELECT ID FROM Hub_Messages WHERE ID = ? AND ChannelID = ? AND IsDeleted = 0",
|
||||
[$parentId, $channelId]
|
||||
);
|
||||
if (!$parent) jsonResponse(['OK' => false, 'ERROR' => 'parent_message_not_found']);
|
||||
}
|
||||
|
||||
// Insert message
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_Messages (ChannelID, SenderAddress, Content, ParentID) VALUES (?, ?, ?, ?)",
|
||||
[$channelId, $senderAddress, $content, $parentId]
|
||||
);
|
||||
$messageId = (int) lastInsertId();
|
||||
|
||||
// Update member's last viewed
|
||||
queryTimed(
|
||||
"UPDATE Hub_ChannelMembers SET LastViewedAt = NOW() WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[$channelId, $senderAddress]
|
||||
);
|
||||
|
||||
$msg = queryOne("SELECT * FROM Hub_Messages WHERE ID = ?", [$messageId]);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Message' => [
|
||||
'ID' => (int) $msg['ID'],
|
||||
'ChannelID' => (int) $msg['ChannelID'],
|
||||
'SenderAddress' => $msg['SenderAddress'],
|
||||
'Content' => $msg['Content'],
|
||||
'ParentID' => $msg['ParentID'] ? (int) $msg['ParentID'] : null,
|
||||
'IsEdited' => (bool) $msg['IsEdited'],
|
||||
'IsDeleted' => false,
|
||||
'CreatedAt' => toISO8601($msg['CreatedAt']),
|
||||
'UpdatedAt' => toISO8601($msg['UpdatedAt']),
|
||||
],
|
||||
]);
|
||||
48
api/hub/messages/thread.php
Normal file
48
api/hub/messages/thread.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/messages/thread.php
|
||||
*
|
||||
* Get all replies in a thread (by ParentID), plus the root message.
|
||||
*
|
||||
* Query params:
|
||||
* MessageID int REQUIRED the root message ID
|
||||
*
|
||||
* Response: { OK: true, RootMessage: {...}, Replies: [...] }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$messageId = (int) ($_GET['MessageID'] ?? 0);
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
|
||||
$root = queryOne("SELECT * FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0", [$messageId]);
|
||||
if (!$root) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
|
||||
|
||||
$replies = queryTimed(
|
||||
"SELECT * FROM Hub_Messages WHERE ParentID = ? AND IsDeleted = 0 ORDER BY CreatedAt ASC",
|
||||
[$messageId]
|
||||
);
|
||||
|
||||
$formatMsg = function (array $row): array {
|
||||
return [
|
||||
'ID' => (int) $row['ID'],
|
||||
'ChannelID' => (int) $row['ChannelID'],
|
||||
'SenderAddress' => $row['SenderAddress'],
|
||||
'Content' => $row['Content'],
|
||||
'ParentID' => $row['ParentID'] ? (int) $row['ParentID'] : null,
|
||||
'IsEdited' => (bool) $row['IsEdited'],
|
||||
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||
'UpdatedAt' => toISO8601($row['UpdatedAt']),
|
||||
];
|
||||
};
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'RootMessage' => $formatMsg($root),
|
||||
'Replies' => array_map($formatMsg, $replies),
|
||||
'ReplyCount' => count($replies),
|
||||
]);
|
||||
48
api/hub/pins/list.php
Normal file
48
api/hub/pins/list.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/pins/list.php
|
||||
*
|
||||
* Get all pinned messages in a channel.
|
||||
*
|
||||
* Query params:
|
||||
* ChannelID int REQUIRED
|
||||
*
|
||||
* Response: { OK: true, PinnedPosts: [...] }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$channelId = (int) ($_GET['ChannelID'] ?? 0);
|
||||
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
|
||||
$rows = queryTimed(
|
||||
"SELECT p.*, m.SenderAddress, m.Content, m.CreatedAt AS MessageCreatedAt, m.IsEdited
|
||||
FROM Hub_PinnedPosts p
|
||||
JOIN Hub_Messages m ON m.ID = p.MessageID
|
||||
WHERE p.ChannelID = ? AND m.IsDeleted = 0
|
||||
ORDER BY p.CreatedAt DESC",
|
||||
[$channelId]
|
||||
);
|
||||
|
||||
$pins = [];
|
||||
foreach ($rows as $r) {
|
||||
$pins[] = [
|
||||
'PinID' => (int) $r['ID'],
|
||||
'MessageID' => (int) $r['MessageID'],
|
||||
'ChannelID' => (int) $r['ChannelID'],
|
||||
'PinnedBy' => $r['PinnedBy'],
|
||||
'PinnedAt' => toISO8601($r['CreatedAt']),
|
||||
'Message' => [
|
||||
'SenderAddress' => $r['SenderAddress'],
|
||||
'Content' => $r['Content'],
|
||||
'IsEdited' => (bool) $r['IsEdited'],
|
||||
'CreatedAt' => toISO8601($r['MessageCreatedAt']),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse(['OK' => true, 'PinnedPosts' => $pins]);
|
||||
53
api/hub/pins/pin.php
Normal file
53
api/hub/pins/pin.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/pins/pin.php
|
||||
*
|
||||
* Pin a message in a channel.
|
||||
*
|
||||
* Body:
|
||||
* MessageID int REQUIRED
|
||||
* AgentAddress string REQUIRED who is pinning
|
||||
*
|
||||
* Response: { OK: true }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$messageId = (int) ($body['MessageID'] ?? 0);
|
||||
$agentAddress = trim($body['AgentAddress'] ?? '');
|
||||
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
|
||||
|
||||
// Verify message exists
|
||||
$msg = queryOne("SELECT ID, ChannelID FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0", [$messageId]);
|
||||
if (!$msg) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
|
||||
|
||||
$channelId = (int) $msg['ChannelID'];
|
||||
|
||||
// Verify agent is a member with admin/owner role
|
||||
$membership = queryOne(
|
||||
"SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[$channelId, $agentAddress]
|
||||
);
|
||||
if (!$membership) jsonResponse(['OK' => false, 'ERROR' => 'not_a_member']);
|
||||
if (!in_array($membership['Role'], ['admin', 'owner'], true)) {
|
||||
// Allow any member to pin for now — can restrict later
|
||||
}
|
||||
|
||||
// Check if already pinned
|
||||
$existing = queryOne("SELECT ID FROM Hub_PinnedPosts WHERE MessageID = ?", [$messageId]);
|
||||
if ($existing) jsonResponse(['OK' => false, 'ERROR' => 'already_pinned']);
|
||||
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_PinnedPosts (MessageID, ChannelID, PinnedBy) VALUES (?, ?, ?)",
|
||||
[$messageId, $channelId, $agentAddress]
|
||||
);
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
34
api/hub/pins/unpin.php
Normal file
34
api/hub/pins/unpin.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/pins/unpin.php
|
||||
*
|
||||
* Unpin a message.
|
||||
*
|
||||
* Body:
|
||||
* MessageID int REQUIRED
|
||||
* AgentAddress string REQUIRED
|
||||
*
|
||||
* Response: { OK: true }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$messageId = (int) ($body['MessageID'] ?? 0);
|
||||
$agentAddress = trim($body['AgentAddress'] ?? '');
|
||||
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
|
||||
|
||||
$stmt = queryTimed("DELETE FROM Hub_PinnedPosts WHERE MessageID = ?", [$messageId]);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'not_pinned']);
|
||||
}
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
67
api/hub/reactions/add.php
Normal file
67
api/hub/reactions/add.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/reactions/add.php
|
||||
*
|
||||
* Add a reaction to a message.
|
||||
*
|
||||
* Body:
|
||||
* MessageID int REQUIRED
|
||||
* AgentAddress string REQUIRED
|
||||
* EmojiName string REQUIRED e.g. "+1", "heart", "fire"
|
||||
*
|
||||
* Response: { OK: true, Reaction: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$messageId = (int) ($body['MessageID'] ?? 0);
|
||||
$agentAddress = trim($body['AgentAddress'] ?? '');
|
||||
$emojiName = trim($body['EmojiName'] ?? '');
|
||||
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
|
||||
if ($emojiName === '') jsonResponse(['OK' => false, 'ERROR' => 'emoji_name_required']);
|
||||
|
||||
// Sanitize emoji name
|
||||
$emojiName = preg_replace('/[^a-zA-Z0-9_\-+]/', '', $emojiName);
|
||||
if ($emojiName === '' || strlen($emojiName) > 50) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invalid_emoji_name']);
|
||||
}
|
||||
|
||||
// Verify message exists
|
||||
$msg = queryOne("SELECT ID, ChannelID FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0", [$messageId]);
|
||||
if (!$msg) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
|
||||
|
||||
// Check if already reacted with this emoji
|
||||
$existing = queryOne(
|
||||
"SELECT ID FROM Hub_Reactions WHERE MessageID = ? AND AgentAddress = ? AND EmojiName = ?",
|
||||
[$messageId, $agentAddress, $emojiName]
|
||||
);
|
||||
if ($existing) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'already_reacted']);
|
||||
}
|
||||
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_Reactions (MessageID, AgentAddress, EmojiName) VALUES (?, ?, ?)",
|
||||
[$messageId, $agentAddress, $emojiName]
|
||||
);
|
||||
$reactionId = (int) lastInsertId();
|
||||
|
||||
$reaction = queryOne("SELECT * FROM Hub_Reactions WHERE ID = ?", [$reactionId]);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Reaction' => [
|
||||
'ID' => (int) $reaction['ID'],
|
||||
'MessageID' => (int) $reaction['MessageID'],
|
||||
'AgentAddress' => $reaction['AgentAddress'],
|
||||
'EmojiName' => $reaction['EmojiName'],
|
||||
'CreatedAt' => toISO8601($reaction['CreatedAt']),
|
||||
],
|
||||
]);
|
||||
41
api/hub/reactions/list.php
Normal file
41
api/hub/reactions/list.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/reactions/list.php
|
||||
*
|
||||
* Get all reactions for a message, grouped by emoji.
|
||||
*
|
||||
* Query params:
|
||||
* MessageID int REQUIRED
|
||||
*
|
||||
* Response: { OK: true, Reactions: [ { EmojiName: "...", Count: N, Agents: [...] } ] }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$messageId = (int) ($_GET['MessageID'] ?? 0);
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
|
||||
$rows = queryTimed(
|
||||
"SELECT * FROM Hub_Reactions WHERE MessageID = ? ORDER BY EmojiName, CreatedAt",
|
||||
[$messageId]
|
||||
);
|
||||
|
||||
// Group by emoji
|
||||
$grouped = [];
|
||||
foreach ($rows as $r) {
|
||||
$emoji = $r['EmojiName'];
|
||||
if (!isset($grouped[$emoji])) {
|
||||
$grouped[$emoji] = ['EmojiName' => $emoji, 'Count' => 0, 'Agents' => []];
|
||||
}
|
||||
$grouped[$emoji]['Count']++;
|
||||
$grouped[$emoji]['Agents'][] = $r['AgentAddress'];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Reactions' => array_values($grouped),
|
||||
]);
|
||||
40
api/hub/reactions/remove.php
Normal file
40
api/hub/reactions/remove.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/reactions/remove.php
|
||||
*
|
||||
* Remove a reaction from a message.
|
||||
*
|
||||
* Body:
|
||||
* MessageID int REQUIRED
|
||||
* AgentAddress string REQUIRED
|
||||
* EmojiName string REQUIRED
|
||||
*
|
||||
* Response: { OK: true }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$messageId = (int) ($body['MessageID'] ?? 0);
|
||||
$agentAddress = trim($body['AgentAddress'] ?? '');
|
||||
$emojiName = trim($body['EmojiName'] ?? '');
|
||||
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
|
||||
if ($emojiName === '') jsonResponse(['OK' => false, 'ERROR' => 'emoji_name_required']);
|
||||
|
||||
$stmt = queryTimed(
|
||||
"DELETE FROM Hub_Reactions WHERE MessageID = ? AND AgentAddress = ? AND EmojiName = ?",
|
||||
[$messageId, $agentAddress, $emojiName]
|
||||
);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'reaction_not_found']);
|
||||
}
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
50
api/hub/users/get.php
Normal file
50
api/hub/users/get.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/users/get.php
|
||||
*
|
||||
* Get a single agent/user by address or ID.
|
||||
*
|
||||
* Query params:
|
||||
* Address string agent address (e.g. sprinter.payfrit.mike)
|
||||
* ID int agent ID
|
||||
* (provide one or the other)
|
||||
*
|
||||
* Response: { OK: true, User: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$address = trim($_GET['Address'] ?? '');
|
||||
$id = (int) ($_GET['ID'] ?? 0);
|
||||
|
||||
if ($address === '' && $id <= 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'address_or_id_required']);
|
||||
}
|
||||
|
||||
$agent = null;
|
||||
if ($address !== '') {
|
||||
$agent = queryOne("SELECT * FROM Sprinter_Agents WHERE FullAddress = ?", [$address]);
|
||||
} else {
|
||||
$agent = queryOne("SELECT * FROM Sprinter_Agents WHERE ID = ?", [$id]);
|
||||
}
|
||||
|
||||
if (!$agent) jsonResponse(['OK' => false, 'ERROR' => 'user_not_found']);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'User' => [
|
||||
'ID' => (int) $agent['ID'],
|
||||
'AgentName' => $agent['AgentName'],
|
||||
'FullAddress' => $agent['FullAddress'],
|
||||
'ProjectName' => $agent['ProjectName'],
|
||||
'AgentType' => $agent['AgentType'],
|
||||
'Role' => $agent['Role'],
|
||||
'ServerHost' => $agent['ServerHost'],
|
||||
'IsActive' => (bool) $agent['IsActive'],
|
||||
'CreatedAt' => toISO8601($agent['CreatedAt']),
|
||||
],
|
||||
]);
|
||||
62
api/hub/users/getByIds.php
Normal file
62
api/hub/users/getByIds.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/users/getByIds.php
|
||||
*
|
||||
* Get multiple agents by their IDs or addresses.
|
||||
*
|
||||
* Body:
|
||||
* IDs int[] optional list of agent IDs
|
||||
* Addresses string[] optional list of agent addresses
|
||||
* (provide one or both)
|
||||
*
|
||||
* Response: { OK: true, Users: [...] }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
$ids = $body['IDs'] ?? [];
|
||||
$addresses = $body['Addresses'] ?? [];
|
||||
|
||||
if (empty($ids) && empty($addresses)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'ids_or_addresses_required']);
|
||||
}
|
||||
|
||||
// Cap at 100
|
||||
$ids = array_slice(array_map('intval', $ids), 0, 100);
|
||||
$addresses = array_slice(array_map('trim', $addresses), 0, 100);
|
||||
|
||||
$users = [];
|
||||
|
||||
if (!empty($ids)) {
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$rows = queryTimed("SELECT * FROM Sprinter_Agents WHERE ID IN ($placeholders)", $ids);
|
||||
foreach ($rows as $a) $users[(int) $a['ID']] = $a;
|
||||
}
|
||||
|
||||
if (!empty($addresses)) {
|
||||
$placeholders = implode(',', array_fill(0, count($addresses), '?'));
|
||||
$rows = queryTimed("SELECT * FROM Sprinter_Agents WHERE FullAddress IN ($placeholders)", $addresses);
|
||||
foreach ($rows as $a) $users[(int) $a['ID']] = $a;
|
||||
}
|
||||
|
||||
$formatted = [];
|
||||
foreach ($users as $a) {
|
||||
$formatted[] = [
|
||||
'ID' => (int) $a['ID'],
|
||||
'AgentName' => $a['AgentName'],
|
||||
'FullAddress' => $a['FullAddress'],
|
||||
'ProjectName' => $a['ProjectName'],
|
||||
'AgentType' => $a['AgentType'],
|
||||
'Role' => $a['Role'],
|
||||
'ServerHost' => $a['ServerHost'],
|
||||
'IsActive' => (bool) $a['IsActive'],
|
||||
'CreatedAt' => toISO8601($a['CreatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse(['OK' => true, 'Users' => $formatted]);
|
||||
55
api/hub/users/search.php
Normal file
55
api/hub/users/search.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/users/search.php
|
||||
*
|
||||
* Search agents by name or address.
|
||||
*
|
||||
* Query params:
|
||||
* Query string REQUIRED search term
|
||||
* Project string optional filter by project name
|
||||
* Limit int optional default 25, max 100
|
||||
*
|
||||
* Response: { OK: true, Users: [...] }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$query = trim($_GET['Query'] ?? '');
|
||||
$project = trim($_GET['Project'] ?? '');
|
||||
$limit = min(max((int) ($_GET['Limit'] ?? 25), 1), 100);
|
||||
|
||||
if ($query === '') jsonResponse(['OK' => false, 'ERROR' => 'query_required']);
|
||||
|
||||
$searchTerm = '%' . $query . '%';
|
||||
$sql = "SELECT * FROM Sprinter_Agents WHERE (AgentName LIKE ? OR FullAddress LIKE ? OR Role LIKE ?) AND IsActive = 1";
|
||||
$params = [$searchTerm, $searchTerm, $searchTerm];
|
||||
|
||||
if ($project !== '') {
|
||||
$sql .= " AND ProjectName = ?";
|
||||
$params[] = $project;
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY AgentName ASC LIMIT ?";
|
||||
$params[] = $limit;
|
||||
|
||||
$rows = queryTimed($sql, $params);
|
||||
|
||||
$users = [];
|
||||
foreach ($rows as $a) {
|
||||
$users[] = [
|
||||
'ID' => (int) $a['ID'],
|
||||
'AgentName' => $a['AgentName'],
|
||||
'FullAddress' => $a['FullAddress'],
|
||||
'ProjectName' => $a['ProjectName'],
|
||||
'AgentType' => $a['AgentType'],
|
||||
'Role' => $a['Role'],
|
||||
'IsActive' => true,
|
||||
'CreatedAt' => toISO8601($a['CreatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse(['OK' => true, 'Users' => $users]);
|
||||
63
api/hub/users/status.php
Normal file
63
api/hub/users/status.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/users/status.php
|
||||
*
|
||||
* Get an agent's online/activity status based on channel membership activity.
|
||||
*
|
||||
* Query params:
|
||||
* Address string REQUIRED agent address
|
||||
*
|
||||
* Response: { OK: true, Status: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$address = trim($_GET['Address'] ?? '');
|
||||
if ($address === '') jsonResponse(['OK' => false, 'ERROR' => 'address_required']);
|
||||
|
||||
$agent = queryOne("SELECT * FROM Sprinter_Agents WHERE FullAddress = ?", [$address]);
|
||||
if (!$agent) jsonResponse(['OK' => false, 'ERROR' => 'user_not_found']);
|
||||
|
||||
// Get last activity from channel memberships
|
||||
$lastActivity = queryOne(
|
||||
"SELECT MAX(LastViewedAt) as LastSeen FROM Hub_ChannelMembers WHERE AgentAddress = ?",
|
||||
[$address]
|
||||
);
|
||||
|
||||
// Get last message sent
|
||||
$lastMessage = queryOne(
|
||||
"SELECT MAX(CreatedAt) as LastMessageAt FROM Hub_Messages WHERE SenderAddress = ? AND IsDeleted = 0",
|
||||
[$address]
|
||||
);
|
||||
|
||||
$lastSeen = $lastActivity['LastSeen'] ?? null;
|
||||
$lastMessageAt = $lastMessage['LastMessageAt'] ?? null;
|
||||
|
||||
// Use the most recent of the two
|
||||
$latestActivity = null;
|
||||
if ($lastSeen && $lastMessageAt) {
|
||||
$latestActivity = max($lastSeen, $lastMessageAt);
|
||||
} else {
|
||||
$latestActivity = $lastSeen ?: $lastMessageAt;
|
||||
}
|
||||
|
||||
// Consider "online" if active in last 5 minutes
|
||||
$isOnline = false;
|
||||
if ($latestActivity) {
|
||||
$diff = time() - strtotime($latestActivity);
|
||||
$isOnline = ($diff < 300);
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Status' => [
|
||||
'Address' => $address,
|
||||
'IsOnline' => $isOnline,
|
||||
'LastActivityAt' => $latestActivity ? toISO8601($latestActivity) : null,
|
||||
'IsActive' => (bool) $agent['IsActive'],
|
||||
],
|
||||
]);
|
||||
Loading…
Add table
Reference in a new issue