From 1dacefcf709cb1e7447b6271f0980338a8885e8c Mon Sep 17 00:00:00 2001 From: Mike Date: Fri, 27 Mar 2026 02:03:14 +0000 Subject: [PATCH] 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) --- api/helpers.php | 26 ++++++++ api/hub/channels/stats.php | 70 ++++++++++++++++++++ api/hub/files/download.php | 45 +++++++++++++ api/hub/files/info.php | 39 +++++++++++ api/hub/files/list.php | 57 ++++++++++++++++ api/hub/files/upload.php | 124 +++++++++++++++++++++++++++++++++++ api/hub/messages/delete.php | 47 +++++++++++++ api/hub/messages/edit.php | 61 +++++++++++++++++ api/hub/messages/list.php | 87 ++++++++++++++++++++++++ api/hub/messages/search.php | 79 ++++++++++++++++++++++ api/hub/messages/send.php | 82 +++++++++++++++++++++++ api/hub/messages/thread.php | 48 ++++++++++++++ api/hub/pins/list.php | 48 ++++++++++++++ api/hub/pins/pin.php | 53 +++++++++++++++ api/hub/pins/unpin.php | 34 ++++++++++ api/hub/reactions/add.php | 67 +++++++++++++++++++ api/hub/reactions/list.php | 41 ++++++++++++ api/hub/reactions/remove.php | 40 +++++++++++ api/hub/users/get.php | 50 ++++++++++++++ api/hub/users/getByIds.php | 62 ++++++++++++++++++ api/hub/users/search.php | 55 ++++++++++++++++ api/hub/users/status.php | 63 ++++++++++++++++++ 22 files changed, 1278 insertions(+) create mode 100644 api/hub/channels/stats.php create mode 100644 api/hub/files/download.php create mode 100644 api/hub/files/info.php create mode 100644 api/hub/files/list.php create mode 100644 api/hub/files/upload.php create mode 100644 api/hub/messages/delete.php create mode 100644 api/hub/messages/edit.php create mode 100644 api/hub/messages/list.php create mode 100644 api/hub/messages/search.php create mode 100644 api/hub/messages/send.php create mode 100644 api/hub/messages/thread.php create mode 100644 api/hub/pins/list.php create mode 100644 api/hub/pins/pin.php create mode 100644 api/hub/pins/unpin.php create mode 100644 api/hub/reactions/add.php create mode 100644 api/hub/reactions/list.php create mode 100644 api/hub/reactions/remove.php create mode 100644 api/hub/users/get.php create mode 100644 api/hub/users/getByIds.php create mode 100644 api/hub/users/search.php create mode 100644 api/hub/users/status.php diff --git a/api/helpers.php b/api/helpers.php index 00f208d..1d91798 100644 --- a/api/helpers.php +++ b/api/helpers.php @@ -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', ]; /** diff --git a/api/hub/channels/stats.php b/api/hub/channels/stats.php new file mode 100644 index 0000000..437007a --- /dev/null +++ b/api/hub/channels/stats.php @@ -0,0 +1,70 @@ + 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, + ], +]); diff --git a/api/hub/files/download.php b/api/hub/files/download.php new file mode 100644 index 0000000..5faa497 --- /dev/null +++ b/api/hub/files/download.php @@ -0,0 +1,45 @@ + 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; diff --git a/api/hub/files/info.php b/api/hub/files/info.php new file mode 100644 index 0000000..f52c47f --- /dev/null +++ b/api/hub/files/info.php @@ -0,0 +1,39 @@ + 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']), + ], +]); diff --git a/api/hub/files/list.php b/api/hub/files/list.php new file mode 100644 index 0000000..686c484 --- /dev/null +++ b/api/hub/files/list.php @@ -0,0 +1,57 @@ + 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, +]); diff --git a/api/hub/files/upload.php b/api/hub/files/upload.php new file mode 100644 index 0000000..85ee238 --- /dev/null +++ b/api/hub/files/upload.php @@ -0,0 +1,124 @@ + 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']), + ], +]); diff --git a/api/hub/messages/delete.php b/api/hub/messages/delete.php new file mode 100644 index 0000000..cee6a96 --- /dev/null +++ b/api/hub/messages/delete.php @@ -0,0 +1,47 @@ + 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]); diff --git a/api/hub/messages/edit.php b/api/hub/messages/edit.php new file mode 100644 index 0000000..071ad1f --- /dev/null +++ b/api/hub/messages/edit.php @@ -0,0 +1,61 @@ + 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']), + ], +]); diff --git a/api/hub/messages/list.php b/api/hub/messages/list.php new file mode 100644 index 0000000..a3fd8ba --- /dev/null +++ b/api/hub/messages/list.php @@ -0,0 +1,87 @@ + 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, +]); diff --git a/api/hub/messages/search.php b/api/hub/messages/search.php new file mode 100644 index 0000000..3056a3e --- /dev/null +++ b/api/hub/messages/search.php @@ -0,0 +1,79 @@ + 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, +]); diff --git a/api/hub/messages/send.php b/api/hub/messages/send.php new file mode 100644 index 0000000..c2743ae --- /dev/null +++ b/api/hub/messages/send.php @@ -0,0 +1,82 @@ + 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']), + ], +]); diff --git a/api/hub/messages/thread.php b/api/hub/messages/thread.php new file mode 100644 index 0000000..6dd5557 --- /dev/null +++ b/api/hub/messages/thread.php @@ -0,0 +1,48 @@ + 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), +]); diff --git a/api/hub/pins/list.php b/api/hub/pins/list.php new file mode 100644 index 0000000..99a2e58 --- /dev/null +++ b/api/hub/pins/list.php @@ -0,0 +1,48 @@ + 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]); diff --git a/api/hub/pins/pin.php b/api/hub/pins/pin.php new file mode 100644 index 0000000..3a8b1cb --- /dev/null +++ b/api/hub/pins/pin.php @@ -0,0 +1,53 @@ + 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]); diff --git a/api/hub/pins/unpin.php b/api/hub/pins/unpin.php new file mode 100644 index 0000000..f0d1987 --- /dev/null +++ b/api/hub/pins/unpin.php @@ -0,0 +1,34 @@ + 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]); diff --git a/api/hub/reactions/add.php b/api/hub/reactions/add.php new file mode 100644 index 0000000..65da940 --- /dev/null +++ b/api/hub/reactions/add.php @@ -0,0 +1,67 @@ + 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']), + ], +]); diff --git a/api/hub/reactions/list.php b/api/hub/reactions/list.php new file mode 100644 index 0000000..46c8b9c --- /dev/null +++ b/api/hub/reactions/list.php @@ -0,0 +1,41 @@ + 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), +]); diff --git a/api/hub/reactions/remove.php b/api/hub/reactions/remove.php new file mode 100644 index 0000000..9bdf5da --- /dev/null +++ b/api/hub/reactions/remove.php @@ -0,0 +1,40 @@ + 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]); diff --git a/api/hub/users/get.php b/api/hub/users/get.php new file mode 100644 index 0000000..ef2ba6b --- /dev/null +++ b/api/hub/users/get.php @@ -0,0 +1,50 @@ + 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']), + ], +]); diff --git a/api/hub/users/getByIds.php b/api/hub/users/getByIds.php new file mode 100644 index 0000000..3a4ba6d --- /dev/null +++ b/api/hub/users/getByIds.php @@ -0,0 +1,62 @@ + 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]); diff --git a/api/hub/users/search.php b/api/hub/users/search.php new file mode 100644 index 0000000..9ebefdd --- /dev/null +++ b/api/hub/users/search.php @@ -0,0 +1,55 @@ + 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]); diff --git a/api/hub/users/status.php b/api/hub/users/status.php new file mode 100644 index 0000000..f582011 --- /dev/null +++ b/api/hub/users/status.php @@ -0,0 +1,63 @@ + 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'], + ], +]);