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:
Mike 2026-03-27 02:03:14 +00:00
parent 4a9db0de0a
commit 1dacefcf70
22 changed files with 1278 additions and 0 deletions

View file

@ -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',
];
/**

View 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,
],
]);

View 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
View 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
View 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
View 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']),
],
]);

View 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
View 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
View 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,
]);

View 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
View 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']),
],
]);

View 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
View 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
View 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
View 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
View 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']),
],
]);

View 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),
]);

View 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
View 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']),
],
]);

View 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
View 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
View 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'],
],
]);