Compare commits
17 commits
schwifty/f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cd373dd616 | |||
| c8ac6ae3fa | |||
| 1dacefcf70 | |||
| 4a9db0de0a | |||
| 629c7d2cef | |||
| 61c9bb8038 | |||
| 41aba807b4 | |||
| eb033b6d4f | |||
| f02a0d0adb | |||
| 8f3fc62b19 | |||
| 32c2cc1381 | |||
| d1630e69b2 | |||
| e3933ce0c8 | |||
| cc7d6f6b4f | |||
| b25198b3f5 | |||
| fd3183035e | |||
| c05bbe684f |
52 changed files with 4105 additions and 7 deletions
|
|
@ -10,7 +10,48 @@ if ($headerSecret !== $secret) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deploy: clone if needed, otherwise pull (with auto-recovery)
|
||||||
|
$repoPath = dirname(__DIR__);
|
||||||
|
$repoUrl = 'https://payfrit:Noorani%401234@git.payfrit.com/payfrit/payfrit-api.git';
|
||||||
|
$output = [];
|
||||||
|
$exitCode = 0;
|
||||||
|
$method = 'unknown';
|
||||||
|
|
||||||
|
if (is_dir($repoPath . '/.git')) {
|
||||||
|
// Try pull first
|
||||||
|
$method = 'pull';
|
||||||
|
exec("cd " . escapeshellarg($repoPath) . " && git pull origin main 2>&1", $output, $exitCode);
|
||||||
|
|
||||||
|
// If pull fails, nuke the broken .git and re-clone
|
||||||
|
if ($exitCode !== 0) {
|
||||||
|
$method = 'recovery-clone';
|
||||||
|
$output[] = '--- pull failed, attempting recovery clone ---';
|
||||||
|
exec("rm -rf " . escapeshellarg($repoPath . '/.git') . " 2>&1");
|
||||||
|
// Fall through to clone logic below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($repoPath . '/.git')) {
|
||||||
|
// Fresh clone into temp dir, then copy into web root
|
||||||
|
$method = ($method === 'recovery-clone') ? 'recovery-clone' : 'fresh-clone';
|
||||||
|
$tmpDir = '/tmp/payfrit-api-clone-' . time();
|
||||||
|
$cloneOutput = [];
|
||||||
|
exec("git clone --branch main " . escapeshellarg($repoUrl) . " " . escapeshellarg($tmpDir) . " 2>&1", $cloneOutput, $exitCode);
|
||||||
|
$output = array_merge($output, $cloneOutput);
|
||||||
|
if ($exitCode === 0) {
|
||||||
|
exec("cp -a " . escapeshellarg($tmpDir) . "/. " . escapeshellarg($repoPath) . "/ 2>&1", $output, $exitCode);
|
||||||
|
exec("rm -rf " . escapeshellarg($tmpDir) . " 2>&1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write trigger file for backward compatibility
|
||||||
$triggerFile = '/tmp/deploy-payfrit-api.trigger';
|
$triggerFile = '/tmp/deploy-payfrit-api.trigger';
|
||||||
file_put_contents($triggerFile, date('Y-m-d H:i:s'));
|
file_put_contents($triggerFile, date('Y-m-d H:i:s'));
|
||||||
|
|
||||||
echo json_encode(['OK' => true, 'MESSAGE' => 'Deploy triggered', 'TIME' => date('c')]);
|
echo json_encode([
|
||||||
|
'OK' => $exitCode === 0,
|
||||||
|
'METHOD' => $method,
|
||||||
|
'MESSAGE' => $exitCode === 0 ? 'Deploy successful' : 'Deploy failed',
|
||||||
|
'OUTPUT' => implode("\n", $output),
|
||||||
|
'TIME' => date('c')
|
||||||
|
]);
|
||||||
|
|
|
||||||
|
|
@ -516,6 +516,59 @@ const PUBLIC_ROUTES = [
|
||||||
'/api/tabs/pendingOrders.php',
|
'/api/tabs/pendingOrders.php',
|
||||||
'/api/tabs/increaseAuth.php',
|
'/api/tabs/increaseAuth.php',
|
||||||
'/api/tabs/cancel.php',
|
'/api/tabs/cancel.php',
|
||||||
|
// team tasks (bot-to-bot, no user auth)
|
||||||
|
'/api/tasks/team/create.php',
|
||||||
|
'/api/tasks/team/update.php',
|
||||||
|
'/api/tasks/team/active.php',
|
||||||
|
'/api/tasks/team/list.php',
|
||||||
|
// hub auth
|
||||||
|
'/api/hub/auth/login.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',
|
||||||
|
'/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',
|
||||||
|
// vc gateway - invite management (agent auth)
|
||||||
|
'/api/hub/vcgateway/invites/create.php',
|
||||||
|
'/api/hub/vcgateway/invites/revoke.php',
|
||||||
|
'/api/hub/vcgateway/invites/list.php',
|
||||||
|
'/api/hub/vcgateway/invites/get.php',
|
||||||
|
// vc gateway - visitor endpoints (visitor token auth)
|
||||||
|
'/api/hub/vcgateway/visitor/auth.php',
|
||||||
|
'/api/hub/vcgateway/visitor/feed.php',
|
||||||
|
// vc gateway - DM (visitor token auth)
|
||||||
|
'/api/hub/vcgateway/dm/send.php',
|
||||||
|
'/api/hub/vcgateway/dm/messages.php',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
113
api/hub/auth/login.php
Normal file
113
api/hub/auth/login.php
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/hub/auth/login.php
|
||||||
|
*
|
||||||
|
* Authenticate a user (human or bot) against the Hub.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* login_id string REQUIRED agent name, full address, or email
|
||||||
|
* password string REQUIRED password (checked against Hub_Users or Sprinter_Agents)
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* { OK: true, token: "...", user: { ... } }
|
||||||
|
* Also sets the Token response header for the proxy layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = readJsonBody();
|
||||||
|
|
||||||
|
$loginId = trim($body['login_id'] ?? '');
|
||||||
|
$password = $body['password'] ?? '';
|
||||||
|
|
||||||
|
if ($loginId === '') {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'login_id_required']);
|
||||||
|
}
|
||||||
|
if ($password === '') {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'password_required']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the agent — try by full address, then by name
|
||||||
|
$agent = queryOne(
|
||||||
|
"SELECT a.*, u.PasswordHash, u.Token, u.TokenExpiresAt
|
||||||
|
FROM Sprinter_Agents a
|
||||||
|
LEFT JOIN Hub_Users u ON u.AgentID = a.ID
|
||||||
|
WHERE a.FullAddress = ? AND a.IsActive = 1",
|
||||||
|
[$loginId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$agent) {
|
||||||
|
// Try by agent name (e.g. "john" → "sprinter.payfrit.john")
|
||||||
|
$agent = queryOne(
|
||||||
|
"SELECT a.*, u.PasswordHash, u.Token, u.TokenExpiresAt
|
||||||
|
FROM Sprinter_Agents a
|
||||||
|
LEFT JOIN Hub_Users u ON u.AgentID = a.ID
|
||||||
|
WHERE a.AgentName = ? AND a.IsActive = 1",
|
||||||
|
[$loginId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$agent) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'invalid_credentials'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password
|
||||||
|
$storedHash = $agent['PasswordHash'] ?? null;
|
||||||
|
|
||||||
|
if ($storedHash === null) {
|
||||||
|
// No Hub_Users row yet — auto-provision on first login
|
||||||
|
// For MVP: accept the password, hash it, create the row and token
|
||||||
|
$hash = password_hash($password, PASSWORD_BCRYPT);
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$expires = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||||
|
|
||||||
|
queryTimed(
|
||||||
|
"INSERT INTO Hub_Users (AgentID, PasswordHash, Token, TokenExpiresAt)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE PasswordHash = VALUES(PasswordHash), Token = VALUES(Token), TokenExpiresAt = VALUES(TokenExpiresAt)",
|
||||||
|
[(int)$agent['ID'], $hash, $token, $expires]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Verify password
|
||||||
|
if (!password_verify($password, $storedHash)) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'invalid_credentials'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh token if expired or missing
|
||||||
|
$token = $agent['Token'] ?? '';
|
||||||
|
$expiresAt = $agent['TokenExpiresAt'] ?? '';
|
||||||
|
if ($token === '' || (strtotime($expiresAt) < time())) {
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$expires = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||||
|
queryTimed(
|
||||||
|
"UPDATE Hub_Users SET Token = ?, TokenExpiresAt = ? WHERE AgentID = ?",
|
||||||
|
[$token, $expires, (int)$agent['ID']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return user info mapped for the web client
|
||||||
|
$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']),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Set Token header so hub-proxy.php forwards it to the client
|
||||||
|
header('Token: ' . $token);
|
||||||
|
|
||||||
|
jsonResponse([
|
||||||
|
'OK' => true,
|
||||||
|
'token' => $token,
|
||||||
|
'user' => $user,
|
||||||
|
]);
|
||||||
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;
|
||||||
70
api/hub/channels/stats.php
Normal file
70
api/hub/channels/stats.php
Normal 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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
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),
|
||||||
|
],
|
||||||
|
]);
|
||||||
45
api/hub/files/download.php
Normal file
45
api/hub/files/download.php
Normal 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
39
api/hub/files/info.php
Normal 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
57
api/hub/files/list.php
Normal 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
124
api/hub/files/upload.php
Normal 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']),
|
||||||
|
],
|
||||||
|
]);
|
||||||
47
api/hub/messages/delete.php
Normal file
47
api/hub/messages/delete.php
Normal 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
61
api/hub/messages/edit.php
Normal 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
87
api/hub/messages/list.php
Normal 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,
|
||||||
|
]);
|
||||||
79
api/hub/messages/search.php
Normal file
79
api/hub/messages/search.php
Normal 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
82
api/hub/messages/send.php
Normal 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']),
|
||||||
|
],
|
||||||
|
]);
|
||||||
48
api/hub/messages/thread.php
Normal file
48
api/hub/messages/thread.php
Normal 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
48
api/hub/pins/list.php
Normal 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
53
api/hub/pins/pin.php
Normal 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
34
api/hub/pins/unpin.php
Normal 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
67
api/hub/reactions/add.php
Normal 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']),
|
||||||
|
],
|
||||||
|
]);
|
||||||
41
api/hub/reactions/list.php
Normal file
41
api/hub/reactions/list.php
Normal 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),
|
||||||
|
]);
|
||||||
40
api/hub/reactions/remove.php
Normal file
40
api/hub/reactions/remove.php
Normal 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
50
api/hub/users/get.php
Normal 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']),
|
||||||
|
],
|
||||||
|
]);
|
||||||
62
api/hub/users/getByIds.php
Normal file
62
api/hub/users/getByIds.php
Normal 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
55
api/hub/users/search.php
Normal 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
63
api/hub/users/status.php
Normal 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'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
337
api/hub/vcgateway/API-CONTRACT.md
Normal file
337
api/hub/vcgateway/API-CONTRACT.md
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
# VC Gateway — API Contract
|
||||||
|
|
||||||
|
**Module:** `/api/hub/vcgateway/`
|
||||||
|
**Owner:** @mike (backend), @netasha (frontend)
|
||||||
|
**Status:** Implemented on dev (2026-03-27)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The VC Gateway enables anonymous visitors (e.g. investors) to:
|
||||||
|
1. Authenticate via a shareable invite link (no account needed)
|
||||||
|
2. Read messages from scoped channels (read-only)
|
||||||
|
3. Send DMs to the host (rate-limited)
|
||||||
|
|
||||||
|
Two auth models:
|
||||||
|
- **Agent auth** (`X-Agent-Address` header) — for team members managing invite links
|
||||||
|
- **Visitor auth** (`X-Visitor-Token` header) — for anonymous visitors using the gateway
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Invite Management (Agent Auth)
|
||||||
|
|
||||||
|
#### `POST /api/hub/vcgateway/invites/create.php`
|
||||||
|
|
||||||
|
Create an invite link.
|
||||||
|
|
||||||
|
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"AllowedChannels": [1, 5, 12],
|
||||||
|
"HostAddress": "sprinter.payfrit.john",
|
||||||
|
"Label": "Sequoia - Partner X",
|
||||||
|
"ExpiresAt": "2026-04-15T00:00:00Z",
|
||||||
|
"MaxUses": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| AllowedChannels | int[] | ✅ | Hub channel IDs visitor can read |
|
||||||
|
| HostAddress | string | ✅ | Sprinter agent address for DM target |
|
||||||
|
| Label | string | ❌ | Human label (max 255 chars) |
|
||||||
|
| ExpiresAt | string | ❌ | ISO8601 datetime; null = never |
|
||||||
|
| MaxUses | int | ❌ | 0 = unlimited (default) |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OK": true,
|
||||||
|
"ID": 1,
|
||||||
|
"Token": "a1b2c3d4e5f6...",
|
||||||
|
"InviteURL": "https://dev.payfrit.com/vc/a1b2c3d4e5f6..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `POST /api/hub/vcgateway/invites/revoke.php`
|
||||||
|
|
||||||
|
Revoke an invite link. All visitor sessions become invalid.
|
||||||
|
|
||||||
|
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{ "ID": 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{ "OK": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `GET /api/hub/vcgateway/invites/list.php`
|
||||||
|
|
||||||
|
List invite links with optional status filter.
|
||||||
|
|
||||||
|
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
|
||||||
|
| Param | Type | Default | Options |
|
||||||
|
|-------|------|---------|---------|
|
||||||
|
| Status | string | all | `active`, `revoked`, `expired`, `all` |
|
||||||
|
| Limit | int | 50 | max 200 |
|
||||||
|
| Offset | int | 0 | |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OK": true,
|
||||||
|
"Links": [
|
||||||
|
{
|
||||||
|
"ID": 1,
|
||||||
|
"Token": "a1b2c3...",
|
||||||
|
"Label": "Sequoia - Partner X",
|
||||||
|
"AllowedChannels": [1, 5, 12],
|
||||||
|
"HostAddress": "sprinter.payfrit.john",
|
||||||
|
"ExpiresAt": "2026-04-15T00:00:00Z",
|
||||||
|
"MaxUses": 3,
|
||||||
|
"UseCount": 1,
|
||||||
|
"VisitorCount": 1,
|
||||||
|
"Status": "active",
|
||||||
|
"CreatedBy": "sprinter.payfrit.john",
|
||||||
|
"CreatedAt": "2026-03-27T20:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status values:** `active`, `revoked`, `expired`, `exhausted`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `GET /api/hub/vcgateway/invites/get.php`
|
||||||
|
|
||||||
|
Get a single invite link with its visitors.
|
||||||
|
|
||||||
|
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
|
||||||
|
|
||||||
|
**Query params:** `ID=1` or `Token=a1b2c3...`
|
||||||
|
|
||||||
|
**Response:** Same as list item, plus a `Visitors` array:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OK": true,
|
||||||
|
"Link": {
|
||||||
|
"...": "same fields as list",
|
||||||
|
"Visitors": [
|
||||||
|
{
|
||||||
|
"ID": 1,
|
||||||
|
"DisplayName": "Alex Chen",
|
||||||
|
"CreatedAt": "2026-03-27T21:00:00Z",
|
||||||
|
"LastActiveAt": "2026-03-27T21:15:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Visitor Endpoints (Visitor Auth)
|
||||||
|
|
||||||
|
#### `POST /api/hub/vcgateway/visitor/auth.php`
|
||||||
|
|
||||||
|
Authenticate as a visitor. No auth header needed — uses the invite token.
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"InviteToken": "a1b2c3d4e5f6...",
|
||||||
|
"DisplayName": "Alex Chen"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| InviteToken | string | ✅ | Token from invite URL |
|
||||||
|
| DisplayName | string | ❌ | Default: "Visitor" (max 100 chars) |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OK": true,
|
||||||
|
"VisitorToken": "x9y8z7...",
|
||||||
|
"VisitorID": 1,
|
||||||
|
"DisplayName": "Alex Chen",
|
||||||
|
"AllowedChannels": [
|
||||||
|
{
|
||||||
|
"ID": 1,
|
||||||
|
"Name": "general",
|
||||||
|
"DisplayName": "General",
|
||||||
|
"Purpose": "Team discussion",
|
||||||
|
"ChannelType": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"HostAddress": "sprinter.payfrit.john"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Save `VisitorToken` — use it as `X-Visitor-Token` header for all subsequent calls.**
|
||||||
|
|
||||||
|
**Error codes:** `invalid_invite_token`, `invite_revoked`, `invite_expired`, `invite_exhausted`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `GET /api/hub/vcgateway/visitor/feed.php`
|
||||||
|
|
||||||
|
Read-only message feed for an allowed channel.
|
||||||
|
|
||||||
|
**Headers:** `X-Visitor-Token: x9y8z7...`
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
|
||||||
|
| Param | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| ChannelID | int | ✅ | Must be in visitor's AllowedChannels |
|
||||||
|
| Before | int | ❌ | Cursor: messages before this ID |
|
||||||
|
| After | int | ❌ | Cursor: messages after this ID |
|
||||||
|
| Limit | int | ❌ | Default 50, max 100 |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OK": true,
|
||||||
|
"Channel": {
|
||||||
|
"ID": 1,
|
||||||
|
"Name": "general",
|
||||||
|
"DisplayName": "General",
|
||||||
|
"Purpose": "Team discussion"
|
||||||
|
},
|
||||||
|
"Messages": [
|
||||||
|
{
|
||||||
|
"ID": 100,
|
||||||
|
"SenderAddress": "sprinter.payfrit.john",
|
||||||
|
"SenderName": "john",
|
||||||
|
"Content": "Let's discuss the roadmap",
|
||||||
|
"ParentID": null,
|
||||||
|
"IsEdited": false,
|
||||||
|
"CreatedAt": "2026-03-27T20:30:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"HasMore": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error codes:** `channel_id_required`, `channel_not_allowed`, `channel_not_found`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DM Endpoints (Visitor Auth)
|
||||||
|
|
||||||
|
#### `POST /api/hub/vcgateway/dm/send.php`
|
||||||
|
|
||||||
|
Send a DM from visitor to host. Rate-limited (10 messages per 5 minutes).
|
||||||
|
|
||||||
|
**Headers:** `X-Visitor-Token: x9y8z7...`
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{ "Content": "Hi, I had a question about your Series A terms." }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| Content | string | ✅ | Max 4000 chars |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OK": true,
|
||||||
|
"MessageID": 201,
|
||||||
|
"DMChannelID": 15,
|
||||||
|
"CreatedAt": "2026-03-27T21:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rate limit error (HTTP 429):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OK": false,
|
||||||
|
"ERROR": "rate_limit_exceeded",
|
||||||
|
"Limit": 10,
|
||||||
|
"WindowSeconds": 300,
|
||||||
|
"ResetsAt": "2026-03-27T21:05:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The DM channel is auto-created on first message. The host sees DMs in their normal Hub message list (channel type: `direct`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `GET /api/hub/vcgateway/dm/messages.php`
|
||||||
|
|
||||||
|
List messages in the visitor's DM channel.
|
||||||
|
|
||||||
|
**Headers:** `X-Visitor-Token: x9y8z7...`
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
|
||||||
|
| Param | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| Before | int | ❌ | Cursor: messages before this ID |
|
||||||
|
| After | int | ❌ | Cursor: messages after this ID |
|
||||||
|
| Limit | int | ❌ | Default 50, max 100 |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OK": true,
|
||||||
|
"Messages": [
|
||||||
|
{
|
||||||
|
"ID": 201,
|
||||||
|
"SenderAddress": "visitor:1:Alex Chen",
|
||||||
|
"SenderName": "Alex Chen",
|
||||||
|
"IsVisitor": true,
|
||||||
|
"Content": "Hi, I had a question about your Series A terms.",
|
||||||
|
"IsEdited": false,
|
||||||
|
"CreatedAt": "2026-03-27T21:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ID": 205,
|
||||||
|
"SenderAddress": "sprinter.payfrit.john",
|
||||||
|
"SenderName": "john",
|
||||||
|
"IsVisitor": false,
|
||||||
|
"Content": "Sure, happy to discuss. What are you thinking?",
|
||||||
|
"IsEdited": false,
|
||||||
|
"CreatedAt": "2026-03-27T21:02:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DMChannelID": 15,
|
||||||
|
"HasMore": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns empty `Messages` array if no DM channel exists yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- **Tables:** `Hub_InviteLinks`, `Hub_Visitors`, `Hub_VisitorRateLimit`
|
||||||
|
- **DM channels** use existing `Hub_Channels` (type: `direct`) and `Hub_Messages`
|
||||||
|
- **Rate limiting:** 10 messages per 5-minute sliding window, stored in `Hub_VisitorRateLimit`
|
||||||
|
- **Visitor sender format:** `visitor:{id}:{displayName}` in `SenderAddress`
|
||||||
|
- **Host replies** use normal Hub message send (existing endpoint), targeting the DM channel
|
||||||
|
- **Revoking a link** invalidates all visitor sessions derived from it
|
||||||
|
- **Schema:** `/api/hub/vcgateway/schema.sql`
|
||||||
110
api/hub/vcgateway/dm/messages.php
Normal file
110
api/hub/vcgateway/dm/messages.php
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET /api/hub/vcgateway/dm/messages.php
|
||||||
|
*
|
||||||
|
* List messages in the visitor's DM channel with the host.
|
||||||
|
* Authenticated via X-Visitor-Token header.
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* Before int optional Cursor: messages before this ID
|
||||||
|
* After int optional Cursor: messages after this ID
|
||||||
|
* Limit int optional Max messages (default: 50, max: 100)
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* OK, Messages[], DMChannelID, HasMore
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$visitor = requireVisitorAuth();
|
||||||
|
|
||||||
|
$dmChannelId = $visitor['DMChannelID'] ? (int)$visitor['DMChannelID'] : null;
|
||||||
|
|
||||||
|
// No DM channel yet = no messages
|
||||||
|
if (!$dmChannelId) {
|
||||||
|
jsonResponse([
|
||||||
|
'OK' => true,
|
||||||
|
'Messages' => [],
|
||||||
|
'DMChannelID' => null,
|
||||||
|
'HasMore' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
$before = (int)($_GET['Before'] ?? 0);
|
||||||
|
$after = (int)($_GET['After'] ?? 0);
|
||||||
|
$limit = min(100, max(1, (int)($_GET['Limit'] ?? 50)));
|
||||||
|
|
||||||
|
$where = ['m.ChannelID = ?', 'm.IsDeleted = 0'];
|
||||||
|
$params = [$dmChannelId];
|
||||||
|
|
||||||
|
if ($before > 0) {
|
||||||
|
$where[] = 'm.ID < ?';
|
||||||
|
$params[] = $before;
|
||||||
|
}
|
||||||
|
if ($after > 0) {
|
||||||
|
$where[] = 'm.ID > ?';
|
||||||
|
$params[] = $after;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereClause = implode(' AND ', $where);
|
||||||
|
$fetchLimit = $limit + 1;
|
||||||
|
$order = ($after > 0) ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
|
$rows = queryTimed(
|
||||||
|
"SELECT m.ID, m.SenderAddress, m.Content, m.IsEdited, m.CreatedAt
|
||||||
|
FROM Hub_Messages m
|
||||||
|
WHERE $whereClause
|
||||||
|
ORDER BY m.ID $order
|
||||||
|
LIMIT $fetchLimit",
|
||||||
|
$params
|
||||||
|
);
|
||||||
|
|
||||||
|
$hasMore = count($rows) > $limit;
|
||||||
|
if ($hasMore) {
|
||||||
|
array_pop($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($after > 0) {
|
||||||
|
$rows = array_reverse($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
$visitorId = (int)$visitor['ID'];
|
||||||
|
$messages = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
// Determine if sender is visitor or host
|
||||||
|
$isVisitor = str_starts_with($row['SenderAddress'], 'visitor:');
|
||||||
|
$senderName = $isVisitor ? $visitor['DisplayName'] : $row['SenderAddress'];
|
||||||
|
|
||||||
|
if (!$isVisitor) {
|
||||||
|
// Resolve agent name
|
||||||
|
$agent = queryOne(
|
||||||
|
"SELECT AgentName FROM Sprinter_Agents WHERE FullAddress = ? LIMIT 1",
|
||||||
|
[$row['SenderAddress']]
|
||||||
|
);
|
||||||
|
if ($agent) {
|
||||||
|
$senderName = $agent['AgentName'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages[] = [
|
||||||
|
'ID' => (int)$row['ID'],
|
||||||
|
'SenderAddress' => $row['SenderAddress'],
|
||||||
|
'SenderName' => $senderName,
|
||||||
|
'IsVisitor' => $isVisitor,
|
||||||
|
'Content' => $row['Content'],
|
||||||
|
'IsEdited' => (bool)$row['IsEdited'],
|
||||||
|
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse([
|
||||||
|
'OK' => true,
|
||||||
|
'Messages' => $messages,
|
||||||
|
'DMChannelID' => $dmChannelId,
|
||||||
|
'HasMore' => $hasMore,
|
||||||
|
]);
|
||||||
89
api/hub/vcgateway/dm/send.php
Normal file
89
api/hub/vcgateway/dm/send.php
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/hub/vcgateway/dm/send.php
|
||||||
|
*
|
||||||
|
* Send a DM from visitor to host. Rate-limited.
|
||||||
|
* Creates the DM channel on first message.
|
||||||
|
* Authenticated via X-Visitor-Token header.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* Content string required Message text (max 4000 chars)
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* OK, MessageID, DMChannelID, CreatedAt
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$visitor = requireVisitorAuth();
|
||||||
|
$body = readJsonBody();
|
||||||
|
|
||||||
|
$content = trim($body['Content'] ?? '');
|
||||||
|
if (empty($content)) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'content_required'], 400);
|
||||||
|
}
|
||||||
|
if (strlen($content) > 4000) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'content_too_long', 'MaxLength' => 4000], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit check
|
||||||
|
checkVisitorRateLimit((int)$visitor['ID']);
|
||||||
|
|
||||||
|
$visitorId = (int)$visitor['ID'];
|
||||||
|
$hostAddress = $visitor['HostAddress'];
|
||||||
|
$dmChannelId = $visitor['DMChannelID'] ? (int)$visitor['DMChannelID'] : null;
|
||||||
|
|
||||||
|
// Create DM channel if it doesn't exist yet
|
||||||
|
if (!$dmChannelId) {
|
||||||
|
$channelName = 'vc-dm-visitor-' . $visitorId;
|
||||||
|
$displayName = 'VC DM: ' . $visitor['DisplayName'];
|
||||||
|
|
||||||
|
queryTimed(
|
||||||
|
"INSERT INTO Hub_Channels (Name, DisplayName, Purpose, ChannelType, CreatedBy)
|
||||||
|
VALUES (?, ?, ?, 'direct', ?)",
|
||||||
|
[
|
||||||
|
$channelName,
|
||||||
|
$displayName,
|
||||||
|
'VC Gateway DM between visitor and host',
|
||||||
|
$hostAddress,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$dmChannelId = (int)lastInsertId();
|
||||||
|
|
||||||
|
// Add the host as a member
|
||||||
|
queryTimed(
|
||||||
|
"INSERT INTO Hub_ChannelMembers (ChannelID, AgentAddress, Role)
|
||||||
|
VALUES (?, ?, 'owner')",
|
||||||
|
[$dmChannelId, $hostAddress]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Link the DM channel to the visitor
|
||||||
|
queryTimed(
|
||||||
|
"UPDATE Hub_Visitors SET DMChannelID = ? WHERE ID = ?",
|
||||||
|
[$dmChannelId, $visitorId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the message
|
||||||
|
// Visitor sender address format: "visitor:{visitorId}:{displayName}"
|
||||||
|
$senderAddress = 'visitor:' . $visitorId . ':' . $visitor['DisplayName'];
|
||||||
|
|
||||||
|
queryTimed(
|
||||||
|
"INSERT INTO Hub_Messages (ChannelID, SenderAddress, Content)
|
||||||
|
VALUES (?, ?, ?)",
|
||||||
|
[$dmChannelId, $senderAddress, $content]
|
||||||
|
);
|
||||||
|
|
||||||
|
$messageId = (int)lastInsertId();
|
||||||
|
|
||||||
|
jsonResponse([
|
||||||
|
'OK' => true,
|
||||||
|
'MessageID' => $messageId,
|
||||||
|
'DMChannelID' => $dmChannelId,
|
||||||
|
'CreatedAt' => toISO8601(date('Y-m-d H:i:s')),
|
||||||
|
]);
|
||||||
127
api/hub/vcgateway/helpers.php
Normal file
127
api/hub/vcgateway/helpers.php
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* VC Gateway Helpers
|
||||||
|
*
|
||||||
|
* Shared functions for the VC Gateway module.
|
||||||
|
* Handles visitor authentication, rate limiting, and invite link validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../helpers.php';
|
||||||
|
|
||||||
|
// Rate limit: max messages per window
|
||||||
|
define('VCGW_RATE_LIMIT_MAX', 10); // 10 messages
|
||||||
|
define('VCGW_RATE_LIMIT_WINDOW_SEC', 300); // per 5-minute window
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate a visitor via X-Visitor-Token header.
|
||||||
|
* Returns the visitor row (with invite link data) or aborts with 401.
|
||||||
|
*/
|
||||||
|
function requireVisitorAuth(): array {
|
||||||
|
$token = headerValue('X-Visitor-Token');
|
||||||
|
if (empty($token)) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'visitor_token_required'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$visitor = queryOne(
|
||||||
|
"SELECT v.*, il.Token AS InviteToken, il.AllowedChannels, il.HostAddress,
|
||||||
|
il.IsRevoked AS InviteRevoked, il.ExpiresAt AS InviteExpiresAt
|
||||||
|
FROM Hub_Visitors v
|
||||||
|
JOIN Hub_InviteLinks il ON il.ID = v.InviteLinkID
|
||||||
|
WHERE v.VisitorToken = ?
|
||||||
|
LIMIT 1",
|
||||||
|
[$token]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$visitor) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'invalid_visitor_token'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the underlying invite link is still valid
|
||||||
|
if ($visitor['InviteRevoked']) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'invite_revoked'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($visitor['InviteExpiresAt'] && strtotime($visitor['InviteExpiresAt']) < time()) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'invite_expired'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last active
|
||||||
|
queryTimed("UPDATE Hub_Visitors SET LastActiveAt = NOW() WHERE ID = ?", [$visitor['ID']]);
|
||||||
|
|
||||||
|
return $visitor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the allowed channel IDs for a visitor.
|
||||||
|
*/
|
||||||
|
function getVisitorAllowedChannels(array $visitor): array {
|
||||||
|
$channels = json_decode($visitor['AllowedChannels'], true);
|
||||||
|
return is_array($channels) ? array_map('intval', $channels) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a visitor can read a specific channel.
|
||||||
|
*/
|
||||||
|
function visitorCanReadChannel(array $visitor, int $channelId): bool {
|
||||||
|
$allowed = getVisitorAllowedChannels($visitor);
|
||||||
|
return in_array($channelId, $allowed, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check rate limit for a visitor. Aborts with 429 if exceeded.
|
||||||
|
*/
|
||||||
|
function checkVisitorRateLimit(int $visitorId): void {
|
||||||
|
// Current window start (rounded to 5-min intervals)
|
||||||
|
$windowSec = VCGW_RATE_LIMIT_WINDOW_SEC;
|
||||||
|
$windowStart = date('Y-m-d H:i:s', (int)(floor(time() / $windowSec) * $windowSec));
|
||||||
|
|
||||||
|
$row = queryOne(
|
||||||
|
"SELECT MessageCount FROM Hub_VisitorRateLimit
|
||||||
|
WHERE VisitorID = ? AND WindowStart = ?",
|
||||||
|
[$visitorId, $windowStart]
|
||||||
|
);
|
||||||
|
|
||||||
|
$currentCount = $row ? (int)$row['MessageCount'] : 0;
|
||||||
|
|
||||||
|
if ($currentCount >= VCGW_RATE_LIMIT_MAX) {
|
||||||
|
$resetAt = (int)(floor(time() / $windowSec) * $windowSec) + $windowSec;
|
||||||
|
jsonResponse([
|
||||||
|
'OK' => false,
|
||||||
|
'ERROR' => 'rate_limit_exceeded',
|
||||||
|
'Limit' => VCGW_RATE_LIMIT_MAX,
|
||||||
|
'WindowSeconds' => $windowSec,
|
||||||
|
'ResetsAt' => toISO8601(date('Y-m-d H:i:s', $resetAt)),
|
||||||
|
], 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the count
|
||||||
|
queryTimed(
|
||||||
|
"INSERT INTO Hub_VisitorRateLimit (VisitorID, WindowStart, MessageCount)
|
||||||
|
VALUES (?, ?, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE MessageCount = MessageCount + 1",
|
||||||
|
[$visitorId, $windowStart]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require that the caller is a Sprinter agent (via X-Agent-Address header).
|
||||||
|
* Returns the agent address string.
|
||||||
|
*/
|
||||||
|
function requireAgentAuth(): string {
|
||||||
|
$address = headerValue('X-Agent-Address');
|
||||||
|
if (empty($address)) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the agent exists in Sprinter_Agents
|
||||||
|
$agent = queryOne(
|
||||||
|
"SELECT ID, FullAddress FROM Sprinter_Agents WHERE FullAddress = ? AND IsActive = 1 LIMIT 1",
|
||||||
|
[$address]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$agent) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'agent_not_found'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $agent['FullAddress'];
|
||||||
|
}
|
||||||
96
api/hub/vcgateway/invites/create.php
Normal file
96
api/hub/vcgateway/invites/create.php
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/hub/vcgateway/invites/create.php
|
||||||
|
*
|
||||||
|
* Create an invite link for VC Gateway.
|
||||||
|
* Requires agent auth (X-Agent-Address header).
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* Label string optional Human label (e.g. "Sequoia - Partner X")
|
||||||
|
* AllowedChannels int[] required Array of Hub_Channels.ID the visitor can read
|
||||||
|
* HostAddress string required Sprinter agent address for DM target
|
||||||
|
* ExpiresAt string optional ISO8601 expiration datetime (null = never)
|
||||||
|
* MaxUses int optional Max times this link can be used (0 = unlimited)
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* OK, ID, Token, InviteURL
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$agentAddress = requireAgentAuth();
|
||||||
|
$body = readJsonBody();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
$allowedChannels = $body['AllowedChannels'] ?? null;
|
||||||
|
$hostAddress = trim($body['HostAddress'] ?? '');
|
||||||
|
|
||||||
|
if (!is_array($allowedChannels) || empty($allowedChannels)) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'allowed_channels_required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($hostAddress)) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'host_address_required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all channel IDs exist
|
||||||
|
$channelIds = array_map('intval', $allowedChannels);
|
||||||
|
$placeholders = implode(',', array_fill(0, count($channelIds), '?'));
|
||||||
|
$channels = queryTimed(
|
||||||
|
"SELECT ID FROM Hub_Channels WHERE ID IN ($placeholders)",
|
||||||
|
$channelIds
|
||||||
|
);
|
||||||
|
|
||||||
|
$foundIds = array_column($channels, 'ID');
|
||||||
|
$missing = array_diff($channelIds, array_map('intval', $foundIds));
|
||||||
|
if (!empty($missing)) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'invalid_channel_ids', 'InvalidIDs' => array_values($missing)], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
$label = trim($body['Label'] ?? '');
|
||||||
|
if (strlen($label) > 255) {
|
||||||
|
$label = substr($label, 0, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAt = null;
|
||||||
|
if (!empty($body['ExpiresAt'])) {
|
||||||
|
$dt = strtotime($body['ExpiresAt']);
|
||||||
|
if ($dt === false || $dt <= time()) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'expires_at_must_be_future'], 400);
|
||||||
|
}
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', $dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxUses = max(0, (int)($body['MaxUses'] ?? 0));
|
||||||
|
|
||||||
|
// Generate a URL-safe token
|
||||||
|
$token = bin2hex(random_bytes(24)); // 48 chars, URL-safe
|
||||||
|
|
||||||
|
queryTimed(
|
||||||
|
"INSERT INTO Hub_InviteLinks (Token, Label, AllowedChannels, HostAddress, ExpiresAt, MaxUses, CreatedBy)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
$token,
|
||||||
|
$label,
|
||||||
|
json_encode($channelIds),
|
||||||
|
$hostAddress,
|
||||||
|
$expiresAt,
|
||||||
|
$maxUses,
|
||||||
|
$agentAddress,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$id = (int)lastInsertId();
|
||||||
|
$inviteUrl = baseUrl() . '/vc/' . $token;
|
||||||
|
|
||||||
|
jsonResponse([
|
||||||
|
'OK' => true,
|
||||||
|
'ID' => $id,
|
||||||
|
'Token' => $token,
|
||||||
|
'InviteURL' => $inviteUrl,
|
||||||
|
]);
|
||||||
92
api/hub/vcgateway/invites/get.php
Normal file
92
api/hub/vcgateway/invites/get.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET /api/hub/vcgateway/invites/get.php
|
||||||
|
*
|
||||||
|
* Get a single invite link by ID or Token.
|
||||||
|
* Requires agent auth (X-Agent-Address header).
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* ID int optional Invite link ID
|
||||||
|
* Token string optional Invite link token
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* OK, Link (object)
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$agentAddress = requireAgentAuth();
|
||||||
|
|
||||||
|
$id = (int)($_GET['ID'] ?? 0);
|
||||||
|
$token = trim($_GET['Token'] ?? '');
|
||||||
|
|
||||||
|
if ($id <= 0 && empty($token)) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'id_or_token_required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id > 0) {
|
||||||
|
$row = queryOne("SELECT * FROM Hub_InviteLinks WHERE ID = ?", [$id]);
|
||||||
|
} else {
|
||||||
|
$row = queryOne("SELECT * FROM Hub_InviteLinks WHERE Token = ?", [$token]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'invite_not_found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get visitor count
|
||||||
|
$vcRow = queryOne(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM Hub_Visitors WHERE InviteLinkID = ?",
|
||||||
|
[(int)$row['ID']]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compute status
|
||||||
|
$computedStatus = 'active';
|
||||||
|
if ($row['IsRevoked']) {
|
||||||
|
$computedStatus = 'revoked';
|
||||||
|
} elseif ($row['ExpiresAt'] && strtotime($row['ExpiresAt']) <= time()) {
|
||||||
|
$computedStatus = 'expired';
|
||||||
|
} elseif ($row['MaxUses'] > 0 && $row['UseCount'] >= $row['MaxUses']) {
|
||||||
|
$computedStatus = 'exhausted';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get visitors using this link
|
||||||
|
$visitors = queryTimed(
|
||||||
|
"SELECT ID, DisplayName, CreatedAt, LastActiveAt
|
||||||
|
FROM Hub_Visitors WHERE InviteLinkID = ?
|
||||||
|
ORDER BY CreatedAt DESC",
|
||||||
|
[(int)$row['ID']]
|
||||||
|
);
|
||||||
|
|
||||||
|
$visitorList = [];
|
||||||
|
foreach ($visitors as $v) {
|
||||||
|
$visitorList[] = [
|
||||||
|
'ID' => (int)$v['ID'],
|
||||||
|
'DisplayName' => $v['DisplayName'],
|
||||||
|
'CreatedAt' => toISO8601($v['CreatedAt']),
|
||||||
|
'LastActiveAt' => toISO8601($v['LastActiveAt']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse([
|
||||||
|
'OK' => true,
|
||||||
|
'Link' => [
|
||||||
|
'ID' => (int)$row['ID'],
|
||||||
|
'Token' => $row['Token'],
|
||||||
|
'Label' => $row['Label'],
|
||||||
|
'AllowedChannels' => json_decode($row['AllowedChannels'], true),
|
||||||
|
'HostAddress' => $row['HostAddress'],
|
||||||
|
'ExpiresAt' => $row['ExpiresAt'] ? toISO8601($row['ExpiresAt']) : null,
|
||||||
|
'MaxUses' => (int)$row['MaxUses'],
|
||||||
|
'UseCount' => (int)$row['UseCount'],
|
||||||
|
'VisitorCount' => (int)($vcRow['cnt'] ?? 0),
|
||||||
|
'Status' => $computedStatus,
|
||||||
|
'CreatedBy' => $row['CreatedBy'],
|
||||||
|
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||||
|
'Visitors' => $visitorList,
|
||||||
|
],
|
||||||
|
]);
|
||||||
104
api/hub/vcgateway/invites/list.php
Normal file
104
api/hub/vcgateway/invites/list.php
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET /api/hub/vcgateway/invites/list.php
|
||||||
|
*
|
||||||
|
* List invite links. Optionally filter by status.
|
||||||
|
* Requires agent auth (X-Agent-Address header).
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* Status string optional "active" | "revoked" | "expired" | "all" (default: "all")
|
||||||
|
* Limit int optional Max results (default: 50, max: 200)
|
||||||
|
* Offset int optional Pagination offset (default: 0)
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* OK, Links[], Total
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$agentAddress = requireAgentAuth();
|
||||||
|
|
||||||
|
$status = strtolower(trim($_GET['Status'] ?? 'all'));
|
||||||
|
$limit = min(200, max(1, (int)($_GET['Limit'] ?? 50)));
|
||||||
|
$offset = max(0, (int)($_GET['Offset'] ?? 0));
|
||||||
|
|
||||||
|
// Build WHERE clause
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
switch ($status) {
|
||||||
|
case 'active':
|
||||||
|
$where[] = 'il.IsRevoked = 0';
|
||||||
|
$where[] = '(il.ExpiresAt IS NULL OR il.ExpiresAt > NOW())';
|
||||||
|
$where[] = '(il.MaxUses = 0 OR il.UseCount < il.MaxUses)';
|
||||||
|
break;
|
||||||
|
case 'revoked':
|
||||||
|
$where[] = 'il.IsRevoked = 1';
|
||||||
|
break;
|
||||||
|
case 'expired':
|
||||||
|
$where[] = 'il.IsRevoked = 0';
|
||||||
|
$where[] = 'il.ExpiresAt IS NOT NULL AND il.ExpiresAt <= NOW()';
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
// No filter
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
$countRow = queryOne(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM Hub_InviteLinks il $whereClause",
|
||||||
|
$params
|
||||||
|
);
|
||||||
|
$total = (int)($countRow['cnt'] ?? 0);
|
||||||
|
|
||||||
|
// Fetch links
|
||||||
|
$rows = queryTimed(
|
||||||
|
"SELECT il.*,
|
||||||
|
(SELECT COUNT(*) FROM Hub_Visitors v WHERE v.InviteLinkID = il.ID) AS VisitorCount
|
||||||
|
FROM Hub_InviteLinks il
|
||||||
|
$whereClause
|
||||||
|
ORDER BY il.CreatedAt DESC
|
||||||
|
LIMIT $limit OFFSET $offset",
|
||||||
|
$params
|
||||||
|
);
|
||||||
|
|
||||||
|
$links = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
// Determine computed status
|
||||||
|
$computedStatus = 'active';
|
||||||
|
if ($row['IsRevoked']) {
|
||||||
|
$computedStatus = 'revoked';
|
||||||
|
} elseif ($row['ExpiresAt'] && strtotime($row['ExpiresAt']) <= time()) {
|
||||||
|
$computedStatus = 'expired';
|
||||||
|
} elseif ($row['MaxUses'] > 0 && $row['UseCount'] >= $row['MaxUses']) {
|
||||||
|
$computedStatus = 'exhausted';
|
||||||
|
}
|
||||||
|
|
||||||
|
$links[] = [
|
||||||
|
'ID' => (int)$row['ID'],
|
||||||
|
'Token' => $row['Token'],
|
||||||
|
'Label' => $row['Label'],
|
||||||
|
'AllowedChannels' => json_decode($row['AllowedChannels'], true),
|
||||||
|
'HostAddress' => $row['HostAddress'],
|
||||||
|
'ExpiresAt' => $row['ExpiresAt'] ? toISO8601($row['ExpiresAt']) : null,
|
||||||
|
'MaxUses' => (int)$row['MaxUses'],
|
||||||
|
'UseCount' => (int)$row['UseCount'],
|
||||||
|
'VisitorCount' => (int)$row['VisitorCount'],
|
||||||
|
'Status' => $computedStatus,
|
||||||
|
'CreatedBy' => $row['CreatedBy'],
|
||||||
|
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse([
|
||||||
|
'OK' => true,
|
||||||
|
'Links' => $links,
|
||||||
|
'Total' => $total,
|
||||||
|
]);
|
||||||
48
api/hub/vcgateway/invites/revoke.php
Normal file
48
api/hub/vcgateway/invites/revoke.php
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/hub/vcgateway/invites/revoke.php
|
||||||
|
*
|
||||||
|
* Revoke an invite link. All visitor sessions using this link become invalid.
|
||||||
|
* Requires agent auth (X-Agent-Address header).
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* ID int required The invite link ID to revoke
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$agentAddress = requireAgentAuth();
|
||||||
|
$body = readJsonBody();
|
||||||
|
|
||||||
|
$id = (int)($body['ID'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'id_required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the link exists and belongs to this agent (or they created it)
|
||||||
|
$link = queryOne(
|
||||||
|
"SELECT ID, CreatedBy, IsRevoked FROM Hub_InviteLinks WHERE ID = ?",
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$link) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'invite_not_found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($link['IsRevoked']) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'already_revoked'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryTimed(
|
||||||
|
"UPDATE Hub_InviteLinks SET IsRevoked = 1 WHERE ID = ?",
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
|
||||||
|
jsonResponse(['OK' => true]);
|
||||||
51
api/hub/vcgateway/schema.sql
Normal file
51
api/hub/vcgateway/schema.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
-- VC Gateway Schema
|
||||||
|
-- Invite links, visitor sessions, and DM support for anonymous visitors.
|
||||||
|
-- Part of Sprinter Hub - supports fundraising / investor access.
|
||||||
|
|
||||||
|
-- Invite links with expiration and channel scoping
|
||||||
|
CREATE TABLE IF NOT EXISTS Hub_InviteLinks (
|
||||||
|
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
Token VARCHAR(64) NOT NULL COMMENT 'URL-safe token for link',
|
||||||
|
Label VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Human label e.g. investor name',
|
||||||
|
AllowedChannels JSON NOT NULL COMMENT 'Array of Hub_Channels.ID the visitor can read',
|
||||||
|
HostAddress VARCHAR(150) NOT NULL COMMENT 'Sprinter agent address of the host (DM target)',
|
||||||
|
ExpiresAt DATETIME NULL COMMENT 'NULL = never expires',
|
||||||
|
MaxUses INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 = unlimited',
|
||||||
|
UseCount INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
IsRevoked TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
CreatedBy VARCHAR(150) NOT NULL COMMENT 'Agent who created the link',
|
||||||
|
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE KEY uq_invite_token (Token),
|
||||||
|
INDEX idx_created_by (CreatedBy),
|
||||||
|
INDEX idx_revoked (IsRevoked),
|
||||||
|
INDEX idx_expires (ExpiresAt)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Visitor sessions - created when someone authenticates via invite link
|
||||||
|
CREATE TABLE IF NOT EXISTS Hub_Visitors (
|
||||||
|
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
InviteLinkID INT UNSIGNED NOT NULL,
|
||||||
|
VisitorToken VARCHAR(64) NOT NULL COMMENT 'Session token for visitor API calls',
|
||||||
|
DisplayName VARCHAR(100) NOT NULL DEFAULT 'Visitor',
|
||||||
|
DMChannelID INT UNSIGNED NULL COMMENT 'Hub_Channels.ID for visitor<->host DM',
|
||||||
|
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
LastActiveAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE KEY uq_visitor_token (VisitorToken),
|
||||||
|
INDEX idx_invite_link (InviteLinkID),
|
||||||
|
INDEX idx_dm_channel (DMChannelID),
|
||||||
|
CONSTRAINT fk_visitor_invite FOREIGN KEY (InviteLinkID) REFERENCES Hub_InviteLinks(ID) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Rate limiting: track visitor message counts per sliding window
|
||||||
|
CREATE TABLE IF NOT EXISTS Hub_VisitorRateLimit (
|
||||||
|
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
VisitorID INT UNSIGNED NOT NULL,
|
||||||
|
WindowStart DATETIME NOT NULL,
|
||||||
|
MessageCount INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
UNIQUE KEY uq_visitor_window (VisitorID, WindowStart),
|
||||||
|
CONSTRAINT fk_ratelimit_visitor FOREIGN KEY (VisitorID) REFERENCES Hub_Visitors(ID) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
113
api/hub/vcgateway/visitor/auth.php
Normal file
113
api/hub/vcgateway/visitor/auth.php
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/hub/vcgateway/visitor/auth.php
|
||||||
|
*
|
||||||
|
* Authenticate as a visitor using an invite link token.
|
||||||
|
* Creates a visitor session and returns a VisitorToken for subsequent API calls.
|
||||||
|
* No user account or Sprinter agent required.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* InviteToken string required The invite link token from the URL
|
||||||
|
* DisplayName string optional Visitor's display name (default: "Visitor")
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* OK, VisitorToken, VisitorID, DisplayName, AllowedChannels[], HostAddress
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = readJsonBody();
|
||||||
|
|
||||||
|
$inviteToken = trim($body['InviteToken'] ?? '');
|
||||||
|
if (empty($inviteToken)) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'invite_token_required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the invite link
|
||||||
|
$link = queryOne(
|
||||||
|
"SELECT * FROM Hub_InviteLinks WHERE Token = ? LIMIT 1",
|
||||||
|
[$inviteToken]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$link) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'invalid_invite_token'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if revoked
|
||||||
|
if ($link['IsRevoked']) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'invite_revoked'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if ($link['ExpiresAt'] && strtotime($link['ExpiresAt']) < time()) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'invite_expired'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max uses
|
||||||
|
if ($link['MaxUses'] > 0 && $link['UseCount'] >= $link['MaxUses']) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'invite_exhausted'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize display name
|
||||||
|
$displayName = trim($body['DisplayName'] ?? '');
|
||||||
|
if (empty($displayName)) {
|
||||||
|
$displayName = 'Visitor';
|
||||||
|
}
|
||||||
|
$displayName = substr($displayName, 0, 100);
|
||||||
|
// Strip any HTML/script
|
||||||
|
$displayName = htmlspecialchars($displayName, ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
|
// Generate visitor token
|
||||||
|
$visitorToken = bin2hex(random_bytes(24));
|
||||||
|
|
||||||
|
// Create the visitor session
|
||||||
|
queryTimed(
|
||||||
|
"INSERT INTO Hub_Visitors (InviteLinkID, VisitorToken, DisplayName)
|
||||||
|
VALUES (?, ?, ?)",
|
||||||
|
[(int)$link['ID'], $visitorToken, $displayName]
|
||||||
|
);
|
||||||
|
|
||||||
|
$visitorId = (int)lastInsertId();
|
||||||
|
|
||||||
|
// Increment use count on the invite link
|
||||||
|
queryTimed(
|
||||||
|
"UPDATE Hub_InviteLinks SET UseCount = UseCount + 1 WHERE ID = ?",
|
||||||
|
[(int)$link['ID']]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the session info
|
||||||
|
$allowedChannels = json_decode($link['AllowedChannels'], true);
|
||||||
|
|
||||||
|
// Fetch channel details for the allowed channels
|
||||||
|
$channelDetails = [];
|
||||||
|
if (!empty($allowedChannels)) {
|
||||||
|
$placeholders = implode(',', array_fill(0, count($allowedChannels), '?'));
|
||||||
|
$channels = queryTimed(
|
||||||
|
"SELECT ID, Name, DisplayName, Purpose, ChannelType
|
||||||
|
FROM Hub_Channels
|
||||||
|
WHERE ID IN ($placeholders) AND IsArchived = 0",
|
||||||
|
array_map('intval', $allowedChannels)
|
||||||
|
);
|
||||||
|
foreach ($channels as $ch) {
|
||||||
|
$channelDetails[] = [
|
||||||
|
'ID' => (int)$ch['ID'],
|
||||||
|
'Name' => $ch['Name'],
|
||||||
|
'DisplayName' => $ch['DisplayName'],
|
||||||
|
'Purpose' => $ch['Purpose'],
|
||||||
|
'ChannelType' => $ch['ChannelType'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse([
|
||||||
|
'OK' => true,
|
||||||
|
'VisitorToken' => $visitorToken,
|
||||||
|
'VisitorID' => $visitorId,
|
||||||
|
'DisplayName' => $displayName,
|
||||||
|
'AllowedChannels' => $channelDetails,
|
||||||
|
'HostAddress' => $link['HostAddress'],
|
||||||
|
]);
|
||||||
128
api/hub/vcgateway/visitor/feed.php
Normal file
128
api/hub/vcgateway/visitor/feed.php
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET /api/hub/vcgateway/visitor/feed.php
|
||||||
|
*
|
||||||
|
* Read-only message feed for a visitor's allowed channels.
|
||||||
|
* Authenticated via X-Visitor-Token header (not user token).
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* ChannelID int required Channel to read messages from
|
||||||
|
* Before int optional Cursor: return messages before this ID
|
||||||
|
* After int optional Cursor: return messages after this ID
|
||||||
|
* Limit int optional Max messages (default: 50, max: 100)
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* OK, Messages[], Channel (metadata), HasMore
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$visitor = requireVisitorAuth();
|
||||||
|
|
||||||
|
$channelId = (int)($_GET['ChannelID'] ?? 0);
|
||||||
|
if ($channelId <= 0) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify visitor has access to this channel
|
||||||
|
if (!visitorCanReadChannel($visitor, $channelId)) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_allowed'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify channel exists and is not archived
|
||||||
|
$channel = queryOne(
|
||||||
|
"SELECT ID, Name, DisplayName, Purpose, ChannelType
|
||||||
|
FROM Hub_Channels WHERE ID = ? AND IsArchived = 0",
|
||||||
|
[$channelId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$channel) {
|
||||||
|
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
$before = (int)($_GET['Before'] ?? 0);
|
||||||
|
$after = (int)($_GET['After'] ?? 0);
|
||||||
|
$limit = min(100, max(1, (int)($_GET['Limit'] ?? 50)));
|
||||||
|
|
||||||
|
// Build query - read only non-deleted messages
|
||||||
|
$where = ['m.ChannelID = ?', 'm.IsDeleted = 0'];
|
||||||
|
$params = [$channelId];
|
||||||
|
|
||||||
|
if ($before > 0) {
|
||||||
|
$where[] = 'm.ID < ?';
|
||||||
|
$params[] = $before;
|
||||||
|
}
|
||||||
|
if ($after > 0) {
|
||||||
|
$where[] = 'm.ID > ?';
|
||||||
|
$params[] = $after;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereClause = implode(' AND ', $where);
|
||||||
|
|
||||||
|
// Fetch one extra to check HasMore
|
||||||
|
$fetchLimit = $limit + 1;
|
||||||
|
|
||||||
|
// Order: newest first for Before cursor, oldest first for After cursor
|
||||||
|
$order = ($after > 0) ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
|
$rows = queryTimed(
|
||||||
|
"SELECT m.ID, m.SenderAddress, m.Content, m.ParentID,
|
||||||
|
m.IsEdited, m.CreatedAt, m.UpdatedAt
|
||||||
|
FROM Hub_Messages m
|
||||||
|
WHERE $whereClause
|
||||||
|
ORDER BY m.ID $order
|
||||||
|
LIMIT $fetchLimit",
|
||||||
|
$params
|
||||||
|
);
|
||||||
|
|
||||||
|
$hasMore = count($rows) > $limit;
|
||||||
|
if ($hasMore) {
|
||||||
|
array_pop($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we fetched in ASC order, reverse for consistent newest-first
|
||||||
|
if ($after > 0) {
|
||||||
|
$rows = array_reverse($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format messages (read-only: no edit/delete capabilities for visitors)
|
||||||
|
$messages = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
// Resolve sender display name
|
||||||
|
$senderName = $row['SenderAddress'];
|
||||||
|
// Try to get agent name
|
||||||
|
$agent = queryOne(
|
||||||
|
"SELECT AgentName FROM Sprinter_Agents WHERE FullAddress = ? LIMIT 1",
|
||||||
|
[$row['SenderAddress']]
|
||||||
|
);
|
||||||
|
if ($agent) {
|
||||||
|
$senderName = $agent['AgentName'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages[] = [
|
||||||
|
'ID' => (int)$row['ID'],
|
||||||
|
'SenderAddress' => $row['SenderAddress'],
|
||||||
|
'SenderName' => $senderName,
|
||||||
|
'Content' => $row['Content'],
|
||||||
|
'ParentID' => $row['ParentID'] ? (int)$row['ParentID'] : null,
|
||||||
|
'IsEdited' => (bool)$row['IsEdited'],
|
||||||
|
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse([
|
||||||
|
'OK' => true,
|
||||||
|
'Channel' => [
|
||||||
|
'ID' => (int)$channel['ID'],
|
||||||
|
'Name' => $channel['Name'],
|
||||||
|
'DisplayName' => $channel['DisplayName'],
|
||||||
|
'Purpose' => $channel['Purpose'],
|
||||||
|
],
|
||||||
|
'Messages' => $messages,
|
||||||
|
'HasMore' => $hasMore,
|
||||||
|
]);
|
||||||
33
api/tasks/team/active.php
Normal file
33
api/tasks/team/active.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../../helpers.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tasks/team/active.php
|
||||||
|
* List all active and paused tasks.
|
||||||
|
*
|
||||||
|
* Optional query params: BotName (filter by bot)
|
||||||
|
* Returns: { OK: true, Tasks: [...] }
|
||||||
|
*/
|
||||||
|
|
||||||
|
$botName = trim($_GET['BotName'] ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sql = "SELECT ID, BotName, Title, Status, Channel, StartedOn, PausedOn, Notes, AssignedBy
|
||||||
|
FROM TeamTasks
|
||||||
|
WHERE Status IN ('active', 'paused')";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($botName !== '') {
|
||||||
|
$sql .= " AND BotName = ?";
|
||||||
|
$params[] = $botName;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " ORDER BY StartedOn DESC";
|
||||||
|
|
||||||
|
$tasks = queryTimed($sql, $params);
|
||||||
|
|
||||||
|
jsonResponse(['OK' => true, 'Tasks' => $tasks]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
apiAbort(['OK' => false, 'ERROR' => 'Failed to fetch tasks: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
54
api/tasks/team/create.php
Normal file
54
api/tasks/team/create.php
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../../helpers.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tasks/team/create.php
|
||||||
|
* Register a new team task.
|
||||||
|
*
|
||||||
|
* Body: { BotName, Title, Channel?, Notes?, AssignedBy? }
|
||||||
|
* Returns: { OK: true, ID: <new task ID> }
|
||||||
|
*/
|
||||||
|
|
||||||
|
$data = readJsonBody();
|
||||||
|
|
||||||
|
$botName = trim($data['BotName'] ?? '');
|
||||||
|
$title = trim($data['Title'] ?? '');
|
||||||
|
$channel = trim($data['Channel'] ?? '');
|
||||||
|
$notes = trim($data['Notes'] ?? '');
|
||||||
|
$assignedBy = trim($data['AssignedBy'] ?? '');
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if ($botName === '' || $title === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
apiAbort(['OK' => false, 'ERROR' => 'BotName and Title are required']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length guards
|
||||||
|
if (strlen($botName) > 50) {
|
||||||
|
http_response_code(400);
|
||||||
|
apiAbort(['OK' => false, 'ERROR' => 'BotName max 50 characters']);
|
||||||
|
}
|
||||||
|
if (strlen($title) > 255) {
|
||||||
|
http_response_code(400);
|
||||||
|
apiAbort(['OK' => false, 'ERROR' => 'Title max 255 characters']);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
queryTimed("
|
||||||
|
INSERT INTO TeamTasks (BotName, Title, Status, Channel, StartedOn, Notes, AssignedBy)
|
||||||
|
VALUES (?, ?, 'active', ?, NOW(), ?, ?)
|
||||||
|
", [
|
||||||
|
$botName,
|
||||||
|
$title,
|
||||||
|
$channel ?: null,
|
||||||
|
$notes ?: null,
|
||||||
|
$assignedBy ?: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$id = (int) lastInsertId();
|
||||||
|
|
||||||
|
jsonResponse(['OK' => true, 'ID' => $id]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
apiAbort(['OK' => false, 'ERROR' => 'Failed to create task: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
450
api/tasks/team/index.php
Normal file
450
api/tasks/team/index.php
Normal file
|
|
@ -0,0 +1,450 @@
|
||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../../helpers.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team Task Dashboard — pretty HTML view
|
||||||
|
* GET /api/tasks/team/ or /api/tasks/team/index.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Grab filters from query string
|
||||||
|
$botName = trim($_GET['BotName'] ?? '');
|
||||||
|
$status = trim($_GET['Status'] ?? '');
|
||||||
|
$assignedBy = trim($_GET['AssignedBy'] ?? '');
|
||||||
|
|
||||||
|
$validStatuses = ['active', 'paused', 'done', 'cancelled'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($botName !== '') {
|
||||||
|
$where[] = "BotName = ?";
|
||||||
|
$params[] = $botName;
|
||||||
|
}
|
||||||
|
if ($status !== '') {
|
||||||
|
if (in_array($status, $validStatuses, true)) {
|
||||||
|
$where[] = "Status = ?";
|
||||||
|
$params[] = $status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($assignedBy !== '') {
|
||||||
|
$where[] = "AssignedBy = ?";
|
||||||
|
$params[] = $assignedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereClause = empty($where) ? '' : 'WHERE ' . implode(' AND ', $where);
|
||||||
|
|
||||||
|
$tasks = queryTimed("
|
||||||
|
SELECT ID, BotName, Title, Status, Channel, StartedOn, PausedOn, CompletedOn, Notes, AssignedBy
|
||||||
|
FROM TeamTasks
|
||||||
|
$whereClause
|
||||||
|
ORDER BY FIELD(Status, 'active', 'paused', 'done', 'cancelled'), StartedOn DESC
|
||||||
|
", $params);
|
||||||
|
|
||||||
|
// Get unique bot names for filter dropdown
|
||||||
|
$bots = queryTimed("SELECT DISTINCT BotName FROM TeamTasks ORDER BY BotName", []);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
$stats = queryOne("SELECT
|
||||||
|
COUNT(*) AS Total,
|
||||||
|
SUM(Status = 'active') AS Active,
|
||||||
|
SUM(Status = 'paused') AS Paused,
|
||||||
|
SUM(Status = 'done') AS Done,
|
||||||
|
SUM(Status = 'cancelled') AS Cancelled
|
||||||
|
FROM TeamTasks", []);
|
||||||
|
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$tasks = [];
|
||||||
|
$bots = [];
|
||||||
|
$stats = ['Total' => 0, 'Active' => 0, 'Paused' => 0, 'Done' => 0, 'Cancelled' => 0];
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(string $status): string {
|
||||||
|
$colors = [
|
||||||
|
'active' => '#22c55e',
|
||||||
|
'paused' => '#f59e0b',
|
||||||
|
'done' => '#6b7280',
|
||||||
|
'cancelled' => '#ef4444',
|
||||||
|
];
|
||||||
|
$icons = [
|
||||||
|
'active' => '●',
|
||||||
|
'paused' => '⏸',
|
||||||
|
'done' => '✓',
|
||||||
|
'cancelled' => '✕',
|
||||||
|
];
|
||||||
|
$color = $colors[$status] ?? '#6b7280';
|
||||||
|
$icon = $icons[$status] ?? '';
|
||||||
|
return "<span class=\"badge\" style=\"--badge-color: $color\">$icon " . htmlspecialchars($status) . "</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(string $datetime): string {
|
||||||
|
if (empty($datetime)) return '—';
|
||||||
|
$ts = strtotime($datetime);
|
||||||
|
$diff = time() - $ts;
|
||||||
|
if ($diff < 60) return 'just now';
|
||||||
|
if ($diff < 3600) return floor($diff / 60) . 'm ago';
|
||||||
|
if ($diff < 86400) return floor($diff / 3600) . 'h ago';
|
||||||
|
if ($diff < 604800) return floor($diff / 86400) . 'd ago';
|
||||||
|
return date('M j', $ts);
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Team Tasks — Payfrit</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
padding: 24px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f8fafc;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 32px;
|
||||||
|
background: #1e293b;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #0f172a;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 32px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters select, .filters a.btn {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters select:hover, .filters a.btn:hover {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters a.btn {
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 32px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #64748b;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
background: #1e293b;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table td {
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table tr:hover td {
|
||||||
|
background: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table tr.status-done td {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table tr.status-cancelled td {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #334155;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #f1f5f9;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-notes {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 4px;
|
||||||
|
max-width: 400px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--badge-color);
|
||||||
|
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--badge-color) 30%, transparent);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assigned-by {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #60a5fa;
|
||||||
|
background: rgba(96, 165, 250, 0.1);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 32px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-link {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: #1e293b;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-link:hover { color: #60a5fa; border-color: #60a5fa; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header, .stats-bar, .filters, .container { padding-left: 16px; padding-right: 16px; }
|
||||||
|
.task-table { font-size: 13px; }
|
||||||
|
.task-table th, .task-table td { padding: 8px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>🚀 Team Tasks</h1>
|
||||||
|
<p>Payfrit bot task tracker — real-time view of all team activity</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stat">
|
||||||
|
<div>
|
||||||
|
<div class="stat-number"><?= (int)$stats['Total'] ?></div>
|
||||||
|
<div class="stat-label">Total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div>
|
||||||
|
<div class="stat-number" style="color: #22c55e"><?= (int)$stats['Active'] ?></div>
|
||||||
|
<div class="stat-label">Active</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div>
|
||||||
|
<div class="stat-number" style="color: #f59e0b"><?= (int)$stats['Paused'] ?></div>
|
||||||
|
<div class="stat-label">Paused</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div>
|
||||||
|
<div class="stat-number" style="color: #6b7280"><?= (int)$stats['Done'] ?></div>
|
||||||
|
<div class="stat-label">Done</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div>
|
||||||
|
<div class="stat-number" style="color: #ef4444"><?= (int)$stats['Cancelled'] ?></div>
|
||||||
|
<div class="stat-label">Cancelled</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="filters" method="GET">
|
||||||
|
<label>Filter:</label>
|
||||||
|
<select name="BotName" onchange="this.form.submit()">
|
||||||
|
<option value="">All Bots</option>
|
||||||
|
<?php foreach ($bots as $b): ?>
|
||||||
|
<option value="<?= htmlspecialchars($b['BotName']) ?>" <?= $botName === $b['BotName'] ? 'selected' : '' ?>>
|
||||||
|
@<?= htmlspecialchars($b['BotName']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<select name="Status" onchange="this.form.submit()">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<?php foreach ($validStatuses as $s): ?>
|
||||||
|
<option value="<?= $s ?>" <?= $status === $s ? 'selected' : '' ?>><?= ucfirst($s) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<?php if ($botName || $status || $assignedBy): ?>
|
||||||
|
<a class="btn" href="?">Clear Filters</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<?php if (empty($tasks)): ?>
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No tasks found</p>
|
||||||
|
<span>Tasks will appear here when bots register work via the API.</span>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<table class="task-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Bot</th>
|
||||||
|
<th>Task</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Assigned By</th>
|
||||||
|
<th>Channel</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Completed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($tasks as $t): ?>
|
||||||
|
<tr class="status-<?= htmlspecialchars($t['Status']) ?>">
|
||||||
|
<td style="color: #64748b; font-size: 12px;"><?= (int)$t['ID'] ?></td>
|
||||||
|
<td>
|
||||||
|
<span class="bot-name">
|
||||||
|
<span class="bot-avatar"><?= strtoupper(substr($t['BotName'], 0, 1)) ?></span>
|
||||||
|
@<?= htmlspecialchars($t['BotName']) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="task-title"><?= htmlspecialchars($t['Title']) ?></div>
|
||||||
|
<?php if (!empty($t['Notes'])): ?>
|
||||||
|
<div class="task-notes"><?= htmlspecialchars($t['Notes']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td><?= statusBadge($t['Status']) ?></td>
|
||||||
|
<td class="assigned-by"><?= !empty($t['AssignedBy']) ? '@' . htmlspecialchars($t['AssignedBy']) : '—' ?></td>
|
||||||
|
<td><?= !empty($t['Channel']) ? '<span class="channel-tag">#' . htmlspecialchars($t['Channel']) . '</span>' : '—' ?></td>
|
||||||
|
<td class="time" title="<?= htmlspecialchars($t['StartedOn'] ?? '') ?>"><?= timeAgo($t['StartedOn'] ?? '') ?></td>
|
||||||
|
<td class="time" title="<?= htmlspecialchars($t['CompletedOn'] ?? '') ?>"><?= timeAgo($t['CompletedOn'] ?? '') ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="json-link" href="list.php" target="_blank">{ } JSON</a>
|
||||||
|
|
||||||
|
<?php if (isset($error)): ?>
|
||||||
|
<script>console.error('Task dashboard error: <?= addslashes($error) ?>');</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
89
api/tasks/team/list.php
Normal file
89
api/tasks/team/list.php
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../../helpers.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tasks/team/list.php
|
||||||
|
* List all tasks with optional filters.
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* BotName — filter by bot name
|
||||||
|
* Status — filter by status (active/paused/done/cancelled)
|
||||||
|
* Channel — filter by channel
|
||||||
|
* AssignedBy — filter by who assigned
|
||||||
|
* Since — only tasks started on or after this date (YYYY-MM-DD)
|
||||||
|
* Limit — max rows (default 100, max 500)
|
||||||
|
* Offset — pagination offset (default 0)
|
||||||
|
*
|
||||||
|
* Returns: { OK: true, Tasks: [...], Total: <count> }
|
||||||
|
*/
|
||||||
|
|
||||||
|
$botName = trim($_GET['BotName'] ?? '');
|
||||||
|
$status = trim($_GET['Status'] ?? '');
|
||||||
|
$channel = trim($_GET['Channel'] ?? '');
|
||||||
|
$assignedBy = trim($_GET['AssignedBy'] ?? '');
|
||||||
|
$since = trim($_GET['Since'] ?? '');
|
||||||
|
$limit = min(max((int) ($_GET['Limit'] ?? 100), 1), 500);
|
||||||
|
$offset = max((int) ($_GET['Offset'] ?? 0), 0);
|
||||||
|
|
||||||
|
$validStatuses = ['active', 'paused', 'done', 'cancelled'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($botName !== '') {
|
||||||
|
$where[] = "BotName = ?";
|
||||||
|
$params[] = $botName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status !== '') {
|
||||||
|
if (!in_array($status, $validStatuses, true)) {
|
||||||
|
http_response_code(400);
|
||||||
|
apiAbort(['OK' => false, 'ERROR' => 'Invalid status filter']);
|
||||||
|
}
|
||||||
|
$where[] = "Status = ?";
|
||||||
|
$params[] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($channel !== '') {
|
||||||
|
$where[] = "Channel = ?";
|
||||||
|
$params[] = $channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($assignedBy !== '') {
|
||||||
|
$where[] = "AssignedBy = ?";
|
||||||
|
$params[] = $assignedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($since !== '') {
|
||||||
|
// Validate date format
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $since)) {
|
||||||
|
http_response_code(400);
|
||||||
|
apiAbort(['OK' => false, 'ERROR' => 'Since must be YYYY-MM-DD']);
|
||||||
|
}
|
||||||
|
$where[] = "StartedOn >= ?";
|
||||||
|
$params[] = $since . ' 00:00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereClause = empty($where) ? '' : 'WHERE ' . implode(' AND ', $where);
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
$countRow = queryOne("SELECT COUNT(*) AS Total FROM TeamTasks $whereClause", $params);
|
||||||
|
$total = (int) ($countRow['Total'] ?? 0);
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
$params[] = $limit;
|
||||||
|
$params[] = $offset;
|
||||||
|
$tasks = queryTimed("
|
||||||
|
SELECT ID, BotName, Title, Status, Channel, StartedOn, PausedOn, CompletedOn, Notes, AssignedBy
|
||||||
|
FROM TeamTasks
|
||||||
|
$whereClause
|
||||||
|
ORDER BY StartedOn DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
", $params);
|
||||||
|
|
||||||
|
jsonResponse(['OK' => true, 'Tasks' => $tasks, 'Total' => $total]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
apiAbort(['OK' => false, 'ERROR' => 'Failed to fetch tasks: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
17
api/tasks/team/schema.sql
Normal file
17
api/tasks/team/schema.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- TeamTasks: Centralized task tracker for all bots
|
||||||
|
-- Run against payfrit_dev (and later payfrit prod)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS TeamTasks (
|
||||||
|
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
BotName VARCHAR(50) NOT NULL,
|
||||||
|
Title VARCHAR(255) NOT NULL,
|
||||||
|
Status ENUM('active','paused','done','cancelled') NOT NULL DEFAULT 'active',
|
||||||
|
Channel VARCHAR(100) DEFAULT NULL,
|
||||||
|
StartedOn DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PausedOn DATETIME DEFAULT NULL,
|
||||||
|
CompletedOn DATETIME DEFAULT NULL,
|
||||||
|
Notes TEXT DEFAULT NULL,
|
||||||
|
AssignedBy VARCHAR(50) DEFAULT NULL,
|
||||||
|
INDEX idx_status (Status),
|
||||||
|
INDEX idx_botname (BotName)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
80
api/tasks/team/update.php
Normal file
80
api/tasks/team/update.php
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../../helpers.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tasks/team/update.php
|
||||||
|
* Update an existing team task (change status, add notes).
|
||||||
|
*
|
||||||
|
* Body: { ID, Status?, Notes?, Channel? }
|
||||||
|
* Returns: { OK: true }
|
||||||
|
*/
|
||||||
|
|
||||||
|
$data = readJsonBody();
|
||||||
|
|
||||||
|
$id = (int) ($data['ID'] ?? 0);
|
||||||
|
$status = trim($data['Status'] ?? '');
|
||||||
|
$notes = $data['Notes'] ?? null;
|
||||||
|
$channel = $data['Channel'] ?? null;
|
||||||
|
|
||||||
|
if ($id <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
apiAbort(['OK' => false, 'ERROR' => 'ID is required']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate status if provided
|
||||||
|
$validStatuses = ['active', 'paused', 'done', 'cancelled'];
|
||||||
|
if ($status !== '' && !in_array($status, $validStatuses, true)) {
|
||||||
|
http_response_code(400);
|
||||||
|
apiAbort(['OK' => false, 'ERROR' => 'Status must be one of: ' . implode(', ', $validStatuses)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify task exists
|
||||||
|
$task = queryOne("SELECT ID, Status FROM TeamTasks WHERE ID = ?", [$id]);
|
||||||
|
if (!$task) {
|
||||||
|
http_response_code(404);
|
||||||
|
apiAbort(['OK' => false, 'ERROR' => 'Task not found']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dynamic update
|
||||||
|
$sets = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($status !== '') {
|
||||||
|
$sets[] = "Status = ?";
|
||||||
|
$params[] = $status;
|
||||||
|
|
||||||
|
// Auto-set timestamp columns based on status transitions
|
||||||
|
if ($status === 'paused') {
|
||||||
|
$sets[] = "PausedOn = NOW()";
|
||||||
|
} elseif ($status === 'done' || $status === 'cancelled') {
|
||||||
|
$sets[] = "CompletedOn = NOW()";
|
||||||
|
} elseif ($status === 'active' && $task['Status'] === 'paused') {
|
||||||
|
// Resuming from pause — clear PausedOn
|
||||||
|
$sets[] = "PausedOn = NULL";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($notes !== null) {
|
||||||
|
$sets[] = "Notes = ?";
|
||||||
|
$params[] = trim($notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($channel !== null) {
|
||||||
|
$sets[] = "Channel = ?";
|
||||||
|
$params[] = trim($channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($sets)) {
|
||||||
|
http_response_code(400);
|
||||||
|
apiAbort(['OK' => false, 'ERROR' => 'Nothing to update — provide Status, Notes, or Channel']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$params[] = $id;
|
||||||
|
queryTimed("UPDATE TeamTasks SET " . implode(', ', $sets) . " WHERE ID = ?", $params);
|
||||||
|
|
||||||
|
jsonResponse(['OK' => true]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
apiAbort(['OK' => false, 'ERROR' => 'Failed to update task: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
@ -91,10 +91,10 @@ foreach ($parentItems as $parent) {
|
||||||
$qty = (int) $parent['Quantity'];
|
$qty = (int) $parent['Quantity'];
|
||||||
|
|
||||||
if ((int) $parent['CategoryID'] !== 31) {
|
if ((int) $parent['CategoryID'] !== 31) {
|
||||||
$payfritsCut += $price * $qty * (float) $order['PayfritFee'];
|
$payfritsCut += round($price * $qty * (float) $order['PayfritFee'], 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
$lineTotal = $price * $qty;
|
$lineTotal = round($price * $qty, 2);
|
||||||
$cartGrandTotal += $lineTotal;
|
$cartGrandTotal += $lineTotal;
|
||||||
|
|
||||||
$itemRows .= '<tr>';
|
$itemRows .= '<tr>';
|
||||||
|
|
@ -116,7 +116,7 @@ foreach ($parentItems as $parent) {
|
||||||
foreach ($children as $child) {
|
foreach ($children as $child) {
|
||||||
$modParent = queryOne("SELECT Name FROM Items WHERE ID = ?", [(int) $child['ParentItemID']]);
|
$modParent = queryOne("SELECT Name FROM Items WHERE ID = ?", [(int) $child['ParentItemID']]);
|
||||||
$modParentName = $modParent['Name'] ?? '';
|
$modParentName = $modParent['Name'] ?? '';
|
||||||
$modTotal = (float) $child['Price'] * $qty;
|
$modTotal = round((float) $child['Price'] * $qty, 2);
|
||||||
$cartGrandTotal += $modTotal;
|
$cartGrandTotal += $modTotal;
|
||||||
|
|
||||||
$itemRows .= '<tr class="modifier">';
|
$itemRows .= '<tr class="modifier">';
|
||||||
|
|
@ -152,17 +152,32 @@ $isCardOrder = (float) $order['PaymentFromCreditCard'] > 0;
|
||||||
$receiptTip = (float) ($order['TipAmount'] ?? 0);
|
$receiptTip = (float) ($order['TipAmount'] ?? 0);
|
||||||
$totalBeforeCardFee = $cartGrandTotal + $taxAmountRaw + $payfritFeeRaw + $deliveryFee + $receiptTip;
|
$totalBeforeCardFee = $cartGrandTotal + $taxAmountRaw + $payfritFeeRaw + $deliveryFee + $receiptTip;
|
||||||
|
|
||||||
if ($isCardOrder) {
|
// Use ACTUAL payment amounts from the database as the source of truth,
|
||||||
|
// so the receipt total always matches what was really charged.
|
||||||
|
$actualCardPaid = (float) $order['PaymentFromCreditCard'];
|
||||||
|
$actualCashPaid = (float) $order['PaymentPaidInCash'];
|
||||||
|
$taxAmount = round($taxAmountRaw * 100) / 100;
|
||||||
|
|
||||||
|
if ($isCardOrder && $actualCardPaid > 0) {
|
||||||
|
// Grand total = what was charged to card + any balance applied
|
||||||
|
$orderGrandTotal = round(($actualCardPaid + $balanceApplied) * 100) / 100;
|
||||||
|
// Back-calculate card/processing fee as the remainder
|
||||||
|
$cardFee = $orderGrandTotal - $cartGrandTotal - $taxAmount - round($payfritFeeRaw * 100) / 100 - $deliveryFee - $receiptTip;
|
||||||
|
$cardFee = round($cardFee * 100) / 100;
|
||||||
|
if ($cardFee < 0) $cardFee = 0;
|
||||||
|
} elseif ($isCashOrder && $actualCashPaid > 0) {
|
||||||
|
$orderGrandTotal = round(($actualCashPaid + $balanceApplied) * 100) / 100;
|
||||||
|
$cardFee = 0;
|
||||||
|
} elseif ($isCardOrder) {
|
||||||
|
// Fallback: recalculate if no payment record yet
|
||||||
$cardFeePercent = 0.029;
|
$cardFeePercent = 0.029;
|
||||||
$cardFeeFixed = 0.30;
|
$cardFeeFixed = 0.30;
|
||||||
$totalCustomerPaysRaw = ($totalBeforeCardFee + $cardFeeFixed) / (1 - $cardFeePercent);
|
$totalCustomerPaysRaw = ($totalBeforeCardFee + $cardFeeFixed) / (1 - $cardFeePercent);
|
||||||
$orderGrandTotal = round($totalCustomerPaysRaw * 100) / 100;
|
$orderGrandTotal = round($totalCustomerPaysRaw * 100) / 100;
|
||||||
$taxAmount = round($taxAmountRaw * 100) / 100;
|
|
||||||
$cardFee = $orderGrandTotal - $cartGrandTotal - $taxAmount - round($payfritFeeRaw * 100) / 100 - $deliveryFee - $receiptTip;
|
$cardFee = $orderGrandTotal - $cartGrandTotal - $taxAmount - round($payfritFeeRaw * 100) / 100 - $deliveryFee - $receiptTip;
|
||||||
$cardFee = round($cardFee * 100) / 100;
|
$cardFee = round($cardFee * 100) / 100;
|
||||||
} else {
|
} else {
|
||||||
$orderGrandTotal = round($totalBeforeCardFee * 100) / 100;
|
$orderGrandTotal = round($totalBeforeCardFee * 100) / 100;
|
||||||
$taxAmount = round($taxAmountRaw * 100) / 100;
|
|
||||||
$cardFee = 0;
|
$cardFee = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
16
show_order.php
Normal file
16
show_order.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Legacy redirect: show_order.php → /receipt/
|
||||||
|
* Old links (payfr.it/show_order.php?UUID=...) now point to the PHP receipt page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$uuid = $_GET['UUID'] ?? '';
|
||||||
|
$isAdminView = $_GET['is_admin_view'] ?? '0';
|
||||||
|
|
||||||
|
$params = http_build_query([
|
||||||
|
'UUID' => $uuid,
|
||||||
|
'is_admin_view' => $isAdminView,
|
||||||
|
]);
|
||||||
|
|
||||||
|
header("Location: /receipt/index.php?{$params}", true, 301);
|
||||||
|
exit;
|
||||||
Loading…
Add table
Reference in a new issue