From 629c7d2cef46b8ce3e22e2872c14ff75dd1ad210 Mon Sep 17 00:00:00 2001 From: Mike Date: Fri, 27 Mar 2026 01:06:14 +0000 Subject: [PATCH] =?UTF-8?q?Add=20Hub=20Channels=20API=20=E2=80=94=20CRUD?= =?UTF-8?q?=20endpoints=20for=20channel=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New endpoints under /api/hub/channels/: - create.php: Create channel with type (public/private/direct), auto-add creator as owner - list.php: List channels with filters (type, agent membership, archived, pagination) - get.php: Get channel by ID or Name, includes member list - update.php: Update display name, purpose, archive status (admin/owner only) - delete.php: Hard-delete channel (owner only), FK cascade removes members - members.php: List channel members with agent info - join.php: Join public channels (private requires invite) - leave.php: Leave channel (owners blocked from leaving) Database: Hub_Channels + Hub_ChannelMembers tables with FK cascade. Task #59 (T51-Sub1) Co-Authored-By: Claude Opus 4.6 (1M context) --- api/helpers.php | 9 ++++ api/hub/channels/create.php | 91 +++++++++++++++++++++++++++++++++ api/hub/channels/delete.php | 51 +++++++++++++++++++ api/hub/channels/get.php | 74 +++++++++++++++++++++++++++ api/hub/channels/join.php | 62 +++++++++++++++++++++++ api/hub/channels/leave.php | 51 +++++++++++++++++++ api/hub/channels/list.php | 77 ++++++++++++++++++++++++++++ api/hub/channels/members.php | 54 ++++++++++++++++++++ api/hub/channels/schema.sql | 32 ++++++++++++ api/hub/channels/update.php | 98 ++++++++++++++++++++++++++++++++++++ 10 files changed, 599 insertions(+) create mode 100644 api/hub/channels/create.php create mode 100644 api/hub/channels/delete.php create mode 100644 api/hub/channels/get.php create mode 100644 api/hub/channels/join.php create mode 100644 api/hub/channels/leave.php create mode 100644 api/hub/channels/list.php create mode 100644 api/hub/channels/members.php create mode 100644 api/hub/channels/schema.sql create mode 100644 api/hub/channels/update.php diff --git a/api/helpers.php b/api/helpers.php index cc040f1..00f208d 100644 --- a/api/helpers.php +++ b/api/helpers.php @@ -521,6 +521,15 @@ const PUBLIC_ROUTES = [ '/api/tasks/team/update.php', '/api/tasks/team/active.php', '/api/tasks/team/list.php', + // hub channels (agent-to-agent, no user auth) + '/api/hub/channels/create.php', + '/api/hub/channels/list.php', + '/api/hub/channels/get.php', + '/api/hub/channels/update.php', + '/api/hub/channels/delete.php', + '/api/hub/channels/members.php', + '/api/hub/channels/join.php', + '/api/hub/channels/leave.php', ]; /** diff --git a/api/hub/channels/create.php b/api/hub/channels/create.php new file mode 100644 index 0000000..af9cd64 --- /dev/null +++ b/api/hub/channels/create.php @@ -0,0 +1,91 @@ + false, 'ERROR' => 'method_not_allowed'], 405); +} + +$body = readJsonBody(); + +// --- Validate required fields --- +$name = trim($body['Name'] ?? ''); +$createdBy = trim($body['CreatedBy'] ?? ''); + +if ($name === '') { + jsonResponse(['OK' => false, 'ERROR' => 'name_required']); +} +if ($createdBy === '') { + jsonResponse(['OK' => false, 'ERROR' => 'created_by_required']); +} + +// Sanitize name: lowercase, alphanumeric + hyphens only +$name = strtolower(preg_replace('/[^a-zA-Z0-9\-]/', '', $name)); +if ($name === '' || strlen($name) > 100) { + jsonResponse(['OK' => false, 'ERROR' => 'invalid_name']); +} + +$displayName = trim($body['DisplayName'] ?? '') ?: $name; +$purpose = trim($body['Purpose'] ?? ''); +$channelType = strtolower(trim($body['ChannelType'] ?? 'public')); + +if (!in_array($channelType, ['public', 'private', 'direct'], true)) { + jsonResponse(['OK' => false, 'ERROR' => 'invalid_channel_type']); +} + +// Enforce length limits +if (strlen($displayName) > 200) $displayName = substr($displayName, 0, 200); +if (strlen($purpose) > 500) $purpose = substr($purpose, 0, 500); + +// --- Check uniqueness --- +$existing = queryOne("SELECT ID FROM Hub_Channels WHERE Name = ?", [$name]); +if ($existing) { + jsonResponse(['OK' => false, 'ERROR' => 'name_already_exists']); +} + +// --- Insert channel --- +queryTimed( + "INSERT INTO Hub_Channels (Name, DisplayName, Purpose, ChannelType, CreatedBy) + VALUES (?, ?, ?, ?, ?)", + [$name, $displayName, $purpose, $channelType, $createdBy] +); +$channelId = (int) lastInsertId(); + +// --- Auto-add creator as owner --- +queryTimed( + "INSERT INTO Hub_ChannelMembers (ChannelID, AgentAddress, Role) VALUES (?, ?, 'owner')", + [$channelId, $createdBy] +); + +// --- Fetch and return --- +$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$channelId]); + +jsonResponse([ + 'OK' => true, + 'Channel' => [ + 'ID' => (int) $channel['ID'], + 'Name' => $channel['Name'], + 'DisplayName' => $channel['DisplayName'], + 'Purpose' => $channel['Purpose'], + 'ChannelType' => $channel['ChannelType'], + 'CreatedBy' => $channel['CreatedBy'], + 'IsArchived' => (bool) $channel['IsArchived'], + 'CreatedAt' => toISO8601($channel['CreatedAt']), + 'UpdatedAt' => toISO8601($channel['UpdatedAt']), + 'MemberCount' => 1, + ], +]); diff --git a/api/hub/channels/delete.php b/api/hub/channels/delete.php new file mode 100644 index 0000000..d77fee7 --- /dev/null +++ b/api/hub/channels/delete.php @@ -0,0 +1,51 @@ + false, 'ERROR' => 'method_not_allowed'], 405); +} + +$body = readJsonBody(); + +$id = (int) ($body['ID'] ?? 0); +$agent = trim($body['Agent'] ?? ''); + +if ($id <= 0) { + jsonResponse(['OK' => false, 'ERROR' => 'id_required']); +} +if ($agent === '') { + jsonResponse(['OK' => false, 'ERROR' => 'agent_required']); +} + +// Verify channel exists +$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$id]); +if (!$channel) { + jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404); +} + +// Only owner can delete +$membership = queryOne( + "SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?", + [$id, $agent] +); +if (!$membership || $membership['Role'] !== 'owner') { + jsonResponse(['OK' => false, 'ERROR' => 'not_authorized_owner_only'], 403); +} + +// Delete channel (FK cascade will remove members) +queryTimed("DELETE FROM Hub_Channels WHERE ID = ?", [$id]); + +jsonResponse(['OK' => true]); diff --git a/api/hub/channels/get.php b/api/hub/channels/get.php new file mode 100644 index 0000000..b19945c --- /dev/null +++ b/api/hub/channels/get.php @@ -0,0 +1,74 @@ + false, 'ERROR' => 'method_not_allowed'], 405); +} + +$id = (int) ($_GET['ID'] ?? 0); +$name = trim($_GET['Name'] ?? ''); + +if ($id <= 0 && $name === '') { + jsonResponse(['OK' => false, 'ERROR' => 'id_or_name_required']); +} + +if ($id > 0) { + $channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$id]); +} else { + $channel = queryOne("SELECT * FROM Hub_Channels WHERE Name = ?", [$name]); +} + +if (!$channel) { + jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404); +} + +// Fetch members +$members = queryTimed( + "SELECT m.*, a.AgentName, a.AgentType, a.Role AS AgentRole + FROM Hub_ChannelMembers m + LEFT JOIN Sprinter_Agents a ON a.FullAddress = m.AgentAddress + WHERE m.ChannelID = ? + ORDER BY m.JoinedAt ASC", + [(int) $channel['ID']] +); + +$memberList = []; +foreach ($members as $m) { + $memberList[] = [ + 'AgentAddress' => $m['AgentAddress'], + 'AgentName' => $m['AgentName'] ?? '', + 'AgentType' => $m['AgentType'] ?? '', + 'Role' => $m['Role'], + 'JoinedAt' => toISO8601($m['JoinedAt']), + 'LastViewedAt' => $m['LastViewedAt'] ? toISO8601($m['LastViewedAt']) : null, + ]; +} + +jsonResponse([ + 'OK' => true, + 'Channel' => [ + 'ID' => (int) $channel['ID'], + 'Name' => $channel['Name'], + 'DisplayName' => $channel['DisplayName'], + 'Purpose' => $channel['Purpose'], + 'ChannelType' => $channel['ChannelType'], + 'CreatedBy' => $channel['CreatedBy'], + 'IsArchived' => (bool) $channel['IsArchived'], + 'CreatedAt' => toISO8601($channel['CreatedAt']), + 'UpdatedAt' => toISO8601($channel['UpdatedAt']), + 'MemberCount' => count($memberList), + ], + 'Members' => $memberList, +]); diff --git a/api/hub/channels/join.php b/api/hub/channels/join.php new file mode 100644 index 0000000..dace3e5 --- /dev/null +++ b/api/hub/channels/join.php @@ -0,0 +1,62 @@ + false, 'ERROR' => 'method_not_allowed'], 405); +} + +$body = readJsonBody(); + +$channelId = (int) ($body['ChannelID'] ?? 0); +$agent = trim($body['Agent'] ?? ''); + +if ($channelId <= 0) { + jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']); +} +if ($agent === '') { + jsonResponse(['OK' => false, 'ERROR' => 'agent_required']); +} + +// Verify channel exists and is not archived +$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$channelId]); +if (!$channel) { + jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404); +} +if ((bool) $channel['IsArchived']) { + jsonResponse(['OK' => false, 'ERROR' => 'channel_archived']); +} + +// Private/direct channels can't be self-joined +if ($channel['ChannelType'] !== 'public') { + jsonResponse(['OK' => false, 'ERROR' => 'channel_not_public']); +} + +// Check if already a member +$existing = queryOne( + "SELECT ID FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?", + [$channelId, $agent] +); +if ($existing) { + jsonResponse(['OK' => true, 'Note' => 'already_member']); +} + +// Add member +queryTimed( + "INSERT INTO Hub_ChannelMembers (ChannelID, AgentAddress, Role) VALUES (?, ?, 'member')", + [$channelId, $agent] +); + +jsonResponse(['OK' => true]); diff --git a/api/hub/channels/leave.php b/api/hub/channels/leave.php new file mode 100644 index 0000000..59ecbde --- /dev/null +++ b/api/hub/channels/leave.php @@ -0,0 +1,51 @@ + false, 'ERROR' => 'method_not_allowed'], 405); +} + +$body = readJsonBody(); + +$channelId = (int) ($body['ChannelID'] ?? 0); +$agent = trim($body['Agent'] ?? ''); + +if ($channelId <= 0) { + jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']); +} +if ($agent === '') { + jsonResponse(['OK' => false, 'ERROR' => 'agent_required']); +} + +// Check membership +$membership = queryOne( + "SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?", + [$channelId, $agent] +); + +if (!$membership) { + jsonResponse(['OK' => false, 'ERROR' => 'not_a_member']); +} + +if ($membership['Role'] === 'owner') { + jsonResponse(['OK' => false, 'ERROR' => 'owner_cannot_leave']); +} + +queryTimed( + "DELETE FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?", + [$channelId, $agent] +); + +jsonResponse(['OK' => true]); diff --git a/api/hub/channels/list.php b/api/hub/channels/list.php new file mode 100644 index 0000000..08264de --- /dev/null +++ b/api/hub/channels/list.php @@ -0,0 +1,77 @@ + false, 'ERROR' => 'method_not_allowed'], 405); +} + +$channelType = trim($_GET['ChannelType'] ?? ''); +$agent = trim($_GET['Agent'] ?? ''); +$includeArchived = ($_GET['IncludeArchived'] ?? '0') === '1'; +$limit = min(max((int) ($_GET['Limit'] ?? 50), 1), 200); +$offset = max((int) ($_GET['Offset'] ?? 0), 0); + +$where = []; +$params = []; + +if (!$includeArchived) { + $where[] = 'c.IsArchived = 0'; +} + +if ($channelType !== '' && in_array($channelType, ['public', 'private', 'direct'], true)) { + $where[] = 'c.ChannelType = ?'; + $params[] = $channelType; +} + +$join = ''; +if ($agent !== '') { + $join = 'INNER JOIN Hub_ChannelMembers m ON m.ChannelID = c.ID AND m.AgentAddress = ?'; + array_unshift($params, $agent); +} + +$whereClause = count($where) > 0 ? 'WHERE ' . implode(' AND ', $where) : ''; + +// Count total +$countSql = "SELECT COUNT(*) AS cnt FROM Hub_Channels c $join $whereClause"; +$countRow = queryOne($countSql, $params); +$total = (int) ($countRow['cnt'] ?? 0); + +// Fetch page +$dataSql = "SELECT c.*, (SELECT COUNT(*) FROM Hub_ChannelMembers WHERE ChannelID = c.ID) AS MemberCount + FROM Hub_Channels c $join $whereClause + ORDER BY c.CreatedAt DESC LIMIT ? OFFSET ?"; +$dataParams = array_merge($params, [$limit, $offset]); +$rows = queryTimed($dataSql, $dataParams); + +$channels = []; +foreach ($rows as $r) { + $channels[] = [ + 'ID' => (int) $r['ID'], + 'Name' => $r['Name'], + 'DisplayName' => $r['DisplayName'], + 'Purpose' => $r['Purpose'], + 'ChannelType' => $r['ChannelType'], + 'CreatedBy' => $r['CreatedBy'], + 'IsArchived' => (bool) $r['IsArchived'], + 'CreatedAt' => toISO8601($r['CreatedAt']), + 'UpdatedAt' => toISO8601($r['UpdatedAt']), + 'MemberCount' => (int) $r['MemberCount'], + ]; +} + +jsonResponse(['OK' => true, 'Channels' => $channels, 'Total' => $total]); diff --git a/api/hub/channels/members.php b/api/hub/channels/members.php new file mode 100644 index 0000000..703f6f6 --- /dev/null +++ b/api/hub/channels/members.php @@ -0,0 +1,54 @@ + false, 'ERROR' => 'method_not_allowed'], 405); +} + +$channelId = (int) ($_GET['ChannelID'] ?? 0); +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'], 404); +} + +$members = queryTimed( + "SELECT m.AgentAddress, m.Role, m.JoinedAt, m.LastViewedAt, + a.AgentName, a.AgentType, a.Role AS AgentRole, a.IsActive + FROM Hub_ChannelMembers m + LEFT JOIN Sprinter_Agents a ON a.FullAddress = m.AgentAddress + WHERE m.ChannelID = ? + ORDER BY m.JoinedAt ASC", + [$channelId] +); + +$list = []; +foreach ($members as $m) { + $list[] = [ + 'AgentAddress' => $m['AgentAddress'], + 'AgentName' => $m['AgentName'] ?? '', + 'AgentType' => $m['AgentType'] ?? '', + 'AgentRole' => $m['AgentRole'] ?? '', + 'IsActive' => isset($m['IsActive']) ? (bool) $m['IsActive'] : null, + 'ChannelRole' => $m['Role'], + 'JoinedAt' => toISO8601($m['JoinedAt']), + 'LastViewedAt' => $m['LastViewedAt'] ? toISO8601($m['LastViewedAt']) : null, + ]; +} + +jsonResponse(['OK' => true, 'Members' => $list, 'Total' => count($list)]); diff --git a/api/hub/channels/schema.sql b/api/hub/channels/schema.sql new file mode 100644 index 0000000..0b8c607 --- /dev/null +++ b/api/hub/channels/schema.sql @@ -0,0 +1,32 @@ +-- Hub Channels Schema +-- Part of Sprinter Hub Migration (Task #51, Sub-task #59) + +CREATE TABLE IF NOT EXISTS Hub_Channels ( + ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + Name VARCHAR(100) NOT NULL, + DisplayName VARCHAR(200) NOT NULL DEFAULT '', + Purpose VARCHAR(500) DEFAULT '', + ChannelType ENUM('public','private','direct') NOT NULL DEFAULT 'public', + CreatedBy VARCHAR(150) NOT NULL COMMENT 'Sprinter agent address', + IsArchived TINYINT(1) NOT NULL DEFAULT 0, + CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uq_channel_name (Name), + INDEX idx_type (ChannelType), + INDEX idx_created_by (CreatedBy), + INDEX idx_archived (IsArchived) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS Hub_ChannelMembers ( + ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ChannelID INT UNSIGNED NOT NULL, + AgentAddress VARCHAR(150) NOT NULL COMMENT 'Sprinter agent address', + Role ENUM('member','admin','owner') NOT NULL DEFAULT 'member', + JoinedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + LastViewedAt DATETIME DEFAULT NULL, + + UNIQUE KEY uq_channel_agent (ChannelID, AgentAddress), + INDEX idx_agent (AgentAddress), + CONSTRAINT fk_member_channel FOREIGN KEY (ChannelID) REFERENCES Hub_Channels(ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/api/hub/channels/update.php b/api/hub/channels/update.php new file mode 100644 index 0000000..9f3872d --- /dev/null +++ b/api/hub/channels/update.php @@ -0,0 +1,98 @@ + false, 'ERROR' => 'method_not_allowed'], 405); +} + +$body = readJsonBody(); + +$id = (int) ($body['ID'] ?? 0); +$agent = trim($body['Agent'] ?? ''); + +if ($id <= 0) { + jsonResponse(['OK' => false, 'ERROR' => 'id_required']); +} +if ($agent === '') { + jsonResponse(['OK' => false, 'ERROR' => 'agent_required']); +} + +// Verify channel exists +$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$id]); +if (!$channel) { + jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404); +} + +// Check permissions: must be admin or owner +$membership = queryOne( + "SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?", + [$id, $agent] +); +if (!$membership || !in_array($membership['Role'], ['admin', 'owner'], true)) { + jsonResponse(['OK' => false, 'ERROR' => 'not_authorized'], 403); +} + +// Build update fields +$sets = []; +$params = []; + +if (isset($body['DisplayName'])) { + $val = trim($body['DisplayName']); + if (strlen($val) > 200) $val = substr($val, 0, 200); + $sets[] = 'DisplayName = ?'; + $params[] = $val; +} + +if (isset($body['Purpose'])) { + $val = trim($body['Purpose']); + if (strlen($val) > 500) $val = substr($val, 0, 500); + $sets[] = 'Purpose = ?'; + $params[] = $val; +} + +if (isset($body['IsArchived'])) { + $sets[] = 'IsArchived = ?'; + $params[] = $body['IsArchived'] ? 1 : 0; +} + +if (count($sets) === 0) { + jsonResponse(['OK' => false, 'ERROR' => 'nothing_to_update']); +} + +$params[] = $id; +queryTimed("UPDATE Hub_Channels SET " . implode(', ', $sets) . " WHERE ID = ?", $params); + +// Fetch updated +$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$id]); +$memberCount = queryOne("SELECT COUNT(*) AS cnt FROM Hub_ChannelMembers WHERE ChannelID = ?", [$id]); + +jsonResponse([ + 'OK' => true, + 'Channel' => [ + 'ID' => (int) $channel['ID'], + 'Name' => $channel['Name'], + 'DisplayName' => $channel['DisplayName'], + 'Purpose' => $channel['Purpose'], + 'ChannelType' => $channel['ChannelType'], + 'CreatedBy' => $channel['CreatedBy'], + 'IsArchived' => (bool) $channel['IsArchived'], + 'CreatedAt' => toISO8601($channel['CreatedAt']), + 'UpdatedAt' => toISO8601($channel['UpdatedAt']), + 'MemberCount' => (int) ($memberCount['cnt'] ?? 0), + ], +]);