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