Add Hub Channels API — CRUD endpoints for channel management
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) <noreply@anthropic.com>
This commit is contained in:
parent
61c9bb8038
commit
629c7d2cef
10 changed files with 599 additions and 0 deletions
|
|
@ -521,6 +521,15 @@ const PUBLIC_ROUTES = [
|
||||||
'/api/tasks/team/update.php',
|
'/api/tasks/team/update.php',
|
||||||
'/api/tasks/team/active.php',
|
'/api/tasks/team/active.php',
|
||||||
'/api/tasks/team/list.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',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
91
api/hub/channels/create.php
Normal file
91
api/hub/channels/create.php
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/hub/channels/create.php
|
||||||
|
*
|
||||||
|
* Create a new channel.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* Name string REQUIRED unique slug (lowercase, hyphens, no spaces)
|
||||||
|
* DisplayName string optional human-readable name (defaults to Name)
|
||||||
|
* Purpose string optional channel description
|
||||||
|
* ChannelType string optional public|private|direct (default: public)
|
||||||
|
* CreatedBy string REQUIRED agent address (e.g. sprinter.payfrit.mike)
|
||||||
|
*
|
||||||
|
* Response: { OK: true, Channel: { ... } }
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
jsonResponse(['OK' => 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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
51
api/hub/channels/delete.php
Normal file
51
api/hub/channels/delete.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/hub/channels/delete.php
|
||||||
|
*
|
||||||
|
* Delete (hard-delete) a channel. Only the owner can delete.
|
||||||
|
* For soft-delete, use update.php with IsArchived=true instead.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* ID int REQUIRED
|
||||||
|
* Agent string REQUIRED requesting agent (must be owner)
|
||||||
|
*
|
||||||
|
* Response: { OK: true }
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
jsonResponse(['OK' => 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]);
|
||||||
74
api/hub/channels/get.php
Normal file
74
api/hub/channels/get.php
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET /api/hub/channels/get.php
|
||||||
|
*
|
||||||
|
* Get a single channel by ID or Name.
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* ID int get by ID
|
||||||
|
* Name string get by name (if ID not provided)
|
||||||
|
*
|
||||||
|
* Response: { OK: true, Channel: { ... }, Members: [...] }
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
jsonResponse(['OK' => 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,
|
||||||
|
]);
|
||||||
62
api/hub/channels/join.php
Normal file
62
api/hub/channels/join.php
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/hub/channels/join.php
|
||||||
|
*
|
||||||
|
* Join a channel. Public channels are open to anyone.
|
||||||
|
* Private channels require an invite (admin/owner must add via addMember).
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* ChannelID int REQUIRED
|
||||||
|
* Agent string REQUIRED agent address joining
|
||||||
|
*
|
||||||
|
* Response: { OK: true }
|
||||||
|
*/
|
||||||
|
|
||||||
|
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);
|
||||||
|
$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]);
|
||||||
51
api/hub/channels/leave.php
Normal file
51
api/hub/channels/leave.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/hub/channels/leave.php
|
||||||
|
*
|
||||||
|
* Leave a channel. Owners cannot leave (must transfer ownership or delete).
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* ChannelID int REQUIRED
|
||||||
|
* Agent string REQUIRED agent address leaving
|
||||||
|
*
|
||||||
|
* Response: { OK: true }
|
||||||
|
*/
|
||||||
|
|
||||||
|
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);
|
||||||
|
$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]);
|
||||||
77
api/hub/channels/list.php
Normal file
77
api/hub/channels/list.php
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET /api/hub/channels/list.php
|
||||||
|
*
|
||||||
|
* List channels with optional filters.
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* ChannelType string optional filter by type (public|private|direct)
|
||||||
|
* Agent string optional only channels this agent is a member of
|
||||||
|
* IncludeArchived 1|0 optional include archived channels (default: 0)
|
||||||
|
* Limit int optional max results (default: 50, max: 200)
|
||||||
|
* Offset int optional pagination offset (default: 0)
|
||||||
|
*
|
||||||
|
* Response: { OK: true, Channels: [...], Total: int }
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
jsonResponse(['OK' => 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]);
|
||||||
54
api/hub/channels/members.php
Normal file
54
api/hub/channels/members.php
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET /api/hub/channels/members.php
|
||||||
|
*
|
||||||
|
* List members of a channel.
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* ChannelID int REQUIRED
|
||||||
|
*
|
||||||
|
* Response: { OK: true, Members: [...] }
|
||||||
|
*/
|
||||||
|
|
||||||
|
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']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)]);
|
||||||
32
api/hub/channels/schema.sql
Normal file
32
api/hub/channels/schema.sql
Normal file
|
|
@ -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;
|
||||||
98
api/hub/channels/update.php
Normal file
98
api/hub/channels/update.php
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/hub/channels/update.php
|
||||||
|
*
|
||||||
|
* Update a channel's metadata.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* ID int REQUIRED
|
||||||
|
* DisplayName string optional
|
||||||
|
* Purpose string optional
|
||||||
|
* IsArchived bool optional
|
||||||
|
* Agent string REQUIRED requesting agent (must be admin/owner)
|
||||||
|
*
|
||||||
|
* Response: { OK: true, Channel: { ... } }
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
jsonResponse(['OK' => 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),
|
||||||
|
],
|
||||||
|
]);
|
||||||
Loading…
Add table
Reference in a new issue