Compare commits

...
Sign in to create a new pull request.

17 commits

Author SHA1 Message Date
cd373dd616 Add VC Gateway endpoints for invite links, visitor auth, DM, and rate limiting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:34:52 +00:00
c8ac6ae3fa Add Hub auth/login.php endpoint — fixes Web client login
The web client calls auth/login to authenticate users, but this endpoint
was missing from the Hub API. Creates:
- api/hub/auth/login.php: password-based auth with token generation
- Hub_Users table: stores bcrypt password hashes and session tokens
- Auto-provisions on first login (creates credentials for existing agents)
- Adds route to PUBLIC_ROUTES in helpers.php

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:41:24 +00:00
1dacefcf70 Add Hub Messages, Files, Users, Reactions, and Pins APIs
Complete backend for SprintChat Hub migration:
- Messages: send, edit, delete, list (paginated cursor), thread, search
- Files: upload (multipart), download, thumbnail, info, list
- Users: get, getByIds, search, status (online detection)
- Reactions: add, remove, list (grouped by emoji)
- Pins: pin, unpin, list (with message content)
- Channel stats: member/message/pinned/unread counts

4 new DB tables: Hub_Messages, Hub_Files, Hub_Reactions, Hub_PinnedPosts
21 new endpoints added to PUBLIC_ROUTES

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:03:14 +00:00
4a9db0de0a Fix deploy webhook: auto-recover from broken .git directory
If git pull fails (corrupted .git), automatically remove the broken
.git dir and re-clone. This prevents the chicken-and-egg problem
where a broken repo state makes the deploy webhook permanently stuck.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 01:35:09 +00:00
629c7d2cef Add Hub Channels API — CRUD endpoints for channel management
New endpoints under /api/hub/channels/:
- create.php: Create channel with type (public/private/direct), auto-add creator as owner
- list.php: List channels with filters (type, agent membership, archived, pagination)
- get.php: Get channel by ID or Name, includes member list
- update.php: Update display name, purpose, archive status (admin/owner only)
- delete.php: Hard-delete channel (owner only), FK cascade removes members
- members.php: List channel members with agent info
- join.php: Join public channels (private requires invite)
- leave.php: Leave channel (owners blocked from leaving)

Database: Hub_Channels + Hub_ChannelMembers tables with FK cascade.
Task #59 (T51-Sub1)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 01:06:14 +00:00
61c9bb8038 Remove temporary phpcheck diagnostic 2026-03-25 03:27:54 +00:00
41aba807b4 Fix deploy webhook to do git pull directly + add phpcheck diagnostic 2026-03-25 03:25:58 +00:00
eb033b6d4f Move phpcheck to webhook dir for access 2026-03-25 03:22:33 +00:00
f02a0d0adb Move phpcheck to api dir 2026-03-25 03:21:53 +00:00
8f3fc62b19 Add temporary PHP config check script 2026-03-25 03:21:23 +00:00
32c2cc1381 Add pretty HTML dashboard for team tasks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:30:51 +00:00
d1630e69b2 Add team task tracker endpoints to public routes
Bot-to-bot endpoints don't have user tokens, so they need to
bypass auth middleware.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 19:04:15 +00:00
e3933ce0c8 Add team task tracker API endpoints
New endpoints at api/tasks/team/ for centralized bot task tracking:
- POST create.php — register a new task
- POST update.php — change status, add notes (auto-sets PausedOn/CompletedOn)
- GET active.php — list active/paused tasks, optional BotName filter
- GET list.php — full listing with filters (BotName, Status, Channel, AssignedBy, Since) and pagination

Includes schema.sql for the TeamTasks table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 19:00:12 +00:00
cc7d6f6b4f Merge pull request 'fix: harden auth middleware security' (#1) from schwifty/fix-auth-security into main 2026-03-23 01:44:02 +00:00
b25198b3f5 fix: receipt total now uses actual payment amount from DB
Instead of recalculating the grand total from line items + rates (which
can drift by a penny due to floating point), use the actual PaymentFromCreditCard
or PaymentPaidInCash values from the Payments table. This ensures the receipt
always matches what the customer was actually charged.
2026-03-22 21:38:17 +00:00
fd3183035e Fix receipt rounding: round line items to cents before summing
Individual line prices were displayed rounded (via dollars()) but the
raw floating-point values were accumulated into the subtotal. This caused
totals like $0.99 instead of $1.00 when item prices had fractional cents.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:33:42 +00:00
c05bbe684f Add show_order.php redirect to receipt page
Legacy URL payfr.it/show_order.php?UUID=... was returning 404.
Added PHP redirect (301) to /receipt/index.php which has the actual receipt logic.
2026-03-22 21:28:36 +00:00
52 changed files with 4105 additions and 7 deletions

View file

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

View file

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

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

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

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

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

View 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;

View file

@ -0,0 +1,70 @@
<?php
/**
* GET /api/hub/channels/stats.php
*
* Get channel statistics: member count, message count, pinned count, unread count.
*
* Query params:
* ChannelID int REQUIRED
* AgentAddress string optional if provided, includes unread count for this agent
*
* Response: { OK: true, Stats: { ... } }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$channelId = (int) ($_GET['ChannelID'] ?? 0);
$agentAddress = trim($_GET['AgentAddress'] ?? '');
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
$channel = queryOne("SELECT ID FROM Hub_Channels WHERE ID = ?", [$channelId]);
if (!$channel) jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found']);
$memberCount = (int) (queryOne(
"SELECT COUNT(*) as cnt FROM Hub_ChannelMembers WHERE ChannelID = ?",
[$channelId]
)['cnt'] ?? 0);
$messageCount = (int) (queryOne(
"SELECT COUNT(*) as cnt FROM Hub_Messages WHERE ChannelID = ? AND IsDeleted = 0",
[$channelId]
)['cnt'] ?? 0);
$pinnedCount = (int) (queryOne(
"SELECT COUNT(*) as cnt FROM Hub_PinnedPosts WHERE ChannelID = ?",
[$channelId]
)['cnt'] ?? 0);
$unreadCount = 0;
if ($agentAddress !== '') {
$membership = queryOne(
"SELECT LastViewedAt FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
[$channelId, $agentAddress]
);
if ($membership && $membership['LastViewedAt']) {
$unreadCount = (int) (queryOne(
"SELECT COUNT(*) as cnt FROM Hub_Messages
WHERE ChannelID = ? AND IsDeleted = 0 AND CreatedAt > ?",
[$channelId, $membership['LastViewedAt']]
)['cnt'] ?? 0);
} elseif ($membership) {
// Never viewed — all messages are unread
$unreadCount = $messageCount;
}
}
jsonResponse([
'OK' => true,
'Stats' => [
'ChannelID' => $channelId,
'MemberCount' => $memberCount,
'MessageCount' => $messageCount,
'PinnedCount' => $pinnedCount,
'UnreadCount' => $unreadCount,
],
]);

View file

@ -0,0 +1,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),
],
]);

View file

@ -0,0 +1,45 @@
<?php
/**
* GET /api/hub/files/download.php
*
* Download a file by ID.
*
* Query params:
* FileID int REQUIRED
* Thumb int optional 1 = return thumbnail instead
*
* Response: binary file stream
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$fileId = (int) ($_GET['FileID'] ?? 0);
$thumb = (int) ($_GET['Thumb'] ?? 0);
if ($fileId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'file_id_required']);
$record = queryOne("SELECT * FROM Hub_Files WHERE ID = ?", [$fileId]);
if (!$record) jsonResponse(['OK' => false, 'ERROR' => 'file_not_found']);
$path = ($thumb && $record['ThumbnailPath'])
? appRoot() . '/' . $record['ThumbnailPath']
: appRoot() . '/' . $record['StoragePath'];
if (!file_exists($path)) {
jsonResponse(['OK' => false, 'ERROR' => 'file_missing_from_disk']);
}
$mimeType = $thumb ? 'image/jpeg' : $record['MimeType'];
$fileName = $thumb ? 'thumb_' . $record['FileName'] : $record['FileName'];
header('Content-Type: ' . $mimeType);
header('Content-Disposition: inline; filename="' . addslashes($fileName) . '"');
header('Content-Length: ' . filesize($path));
header('Cache-Control: public, max-age=86400');
readfile($path);
exit;

39
api/hub/files/info.php Normal file
View file

@ -0,0 +1,39 @@
<?php
/**
* GET /api/hub/files/info.php
*
* Get file metadata by ID.
*
* Query params:
* FileID int REQUIRED
*
* Response: { OK: true, File: { ... } }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$fileId = (int) ($_GET['FileID'] ?? 0);
if ($fileId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'file_id_required']);
$record = queryOne("SELECT * FROM Hub_Files WHERE ID = ?", [$fileId]);
if (!$record) jsonResponse(['OK' => false, 'ERROR' => 'file_not_found']);
jsonResponse([
'OK' => true,
'File' => [
'ID' => (int) $record['ID'],
'MessageID' => $record['MessageID'] ? (int) $record['MessageID'] : null,
'ChannelID' => (int) $record['ChannelID'],
'UploaderAddress' => $record['UploaderAddress'],
'FileName' => $record['FileName'],
'FileSize' => (int) $record['FileSize'],
'MimeType' => $record['MimeType'],
'DownloadURL' => baseUrl() . '/' . $record['StoragePath'],
'ThumbnailURL' => $record['ThumbnailPath'] ? baseUrl() . '/' . $record['ThumbnailPath'] : null,
'CreatedAt' => toISO8601($record['CreatedAt']),
],
]);

57
api/hub/files/list.php Normal file
View file

@ -0,0 +1,57 @@
<?php
/**
* GET /api/hub/files/list.php
*
* List files in a channel.
*
* Query params:
* ChannelID int REQUIRED
* Limit int optional default 50, max 200
* Offset int optional default 0
*
* Response: { OK: true, Files: [...], Total: int }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$channelId = (int) ($_GET['ChannelID'] ?? 0);
$limit = min(max((int) ($_GET['Limit'] ?? 50), 1), 200);
$offset = max((int) ($_GET['Offset'] ?? 0), 0);
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
$total = (int) (queryOne(
"SELECT COUNT(*) as cnt FROM Hub_Files WHERE ChannelID = ?",
[$channelId]
)['cnt'] ?? 0);
$rows = queryTimed(
"SELECT * FROM Hub_Files WHERE ChannelID = ? ORDER BY CreatedAt DESC LIMIT ? OFFSET ?",
[$channelId, $limit, $offset]
);
$files = [];
foreach ($rows as $r) {
$files[] = [
'ID' => (int) $r['ID'],
'MessageID' => $r['MessageID'] ? (int) $r['MessageID'] : null,
'ChannelID' => (int) $r['ChannelID'],
'UploaderAddress' => $r['UploaderAddress'],
'FileName' => $r['FileName'],
'FileSize' => (int) $r['FileSize'],
'MimeType' => $r['MimeType'],
'DownloadURL' => baseUrl() . '/' . $r['StoragePath'],
'ThumbnailURL' => $r['ThumbnailPath'] ? baseUrl() . '/' . $r['ThumbnailPath'] : null,
'CreatedAt' => toISO8601($r['CreatedAt']),
];
}
jsonResponse([
'OK' => true,
'Files' => $files,
'Total' => $total,
]);

124
api/hub/files/upload.php Normal file
View file

@ -0,0 +1,124 @@
<?php
/**
* POST /api/hub/files/upload.php
*
* Upload a file (multipart/form-data).
*
* Form fields:
* ChannelID int REQUIRED
* UploaderAddress string REQUIRED agent address
* MessageID int optional attach to an existing message
* file file REQUIRED the uploaded file
*
* Response: { OK: true, File: { ... } }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$channelId = (int) ($_POST['ChannelID'] ?? 0);
$uploaderAddress = trim($_POST['UploaderAddress'] ?? '');
$messageId = isset($_POST['MessageID']) ? (int) $_POST['MessageID'] : null;
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
if ($uploaderAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'uploader_address_required']);
// Verify channel exists
$channel = queryOne("SELECT ID FROM Hub_Channels WHERE ID = ?", [$channelId]);
if (!$channel) jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found']);
// Check file upload
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
$errCode = $_FILES['file']['error'] ?? -1;
jsonResponse(['OK' => false, 'ERROR' => 'file_upload_failed', 'Code' => $errCode]);
}
$file = $_FILES['file'];
$maxSize = 50 * 1024 * 1024; // 50MB
if ($file['size'] > $maxSize) {
jsonResponse(['OK' => false, 'ERROR' => 'file_too_large', 'MaxBytes' => $maxSize]);
}
// Sanitize filename
$originalName = basename($file['name']);
$originalName = preg_replace('/[^a-zA-Z0-9._\-]/', '_', $originalName);
if ($originalName === '' || $originalName === '.') $originalName = 'upload';
$mimeType = $file['type'] ?: 'application/octet-stream';
// Storage path: /uploads/hub/{channelId}/{uuid}_{filename}
$uuid = generateUUID();
$storageDir = uploadsRoot() . '/hub/' . $channelId;
if (!is_dir($storageDir)) {
mkdir($storageDir, 0755, true);
}
$storageName = $uuid . '_' . $originalName;
$storagePath = $storageDir . '/' . $storageName;
$relPath = 'uploads/hub/' . $channelId . '/' . $storageName;
if (!move_uploaded_file($file['tmp_name'], $storagePath)) {
jsonResponse(['OK' => false, 'ERROR' => 'file_save_failed']);
}
// Generate thumbnail for images
$thumbnailRelPath = null;
if (str_starts_with($mimeType, 'image/') && extension_loaded('gd')) {
$thumbDir = $storageDir . '/thumbs';
if (!is_dir($thumbDir)) mkdir($thumbDir, 0755, true);
$thumbName = $uuid . '_thumb.jpg';
$thumbPath = $thumbDir . '/' . $thumbName;
$src = null;
if ($mimeType === 'image/jpeg') $src = @imagecreatefromjpeg($storagePath);
elseif ($mimeType === 'image/png') $src = @imagecreatefrompng($storagePath);
elseif ($mimeType === 'image/gif') $src = @imagecreatefromgif($storagePath);
elseif ($mimeType === 'image/webp') $src = @imagecreatefromwebp($storagePath);
if ($src) {
$origW = imagesx($src);
$origH = imagesy($src);
$maxThumb = 200;
$ratio = min($maxThumb / $origW, $maxThumb / $origH, 1);
$newW = (int) ($origW * $ratio);
$newH = (int) ($origH * $ratio);
$thumb = imagecreatetruecolor($newW, $newH);
imagecopyresampled($thumb, $src, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
imagejpeg($thumb, $thumbPath, 80);
imagedestroy($src);
imagedestroy($thumb);
$thumbnailRelPath = 'uploads/hub/' . $channelId . '/thumbs/' . $thumbName;
}
}
// Insert to DB
queryTimed(
"INSERT INTO Hub_Files (MessageID, ChannelID, UploaderAddress, FileName, FileSize, MimeType, StoragePath, ThumbnailPath)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[$messageId, $channelId, $uploaderAddress, $originalName, $file['size'], $mimeType, $relPath, $thumbnailRelPath]
);
$fileId = (int) lastInsertId();
$record = queryOne("SELECT * FROM Hub_Files WHERE ID = ?", [$fileId]);
jsonResponse([
'OK' => true,
'File' => [
'ID' => (int) $record['ID'],
'MessageID' => $record['MessageID'] ? (int) $record['MessageID'] : null,
'ChannelID' => (int) $record['ChannelID'],
'UploaderAddress' => $record['UploaderAddress'],
'FileName' => $record['FileName'],
'FileSize' => (int) $record['FileSize'],
'MimeType' => $record['MimeType'],
'DownloadURL' => baseUrl() . '/' . $record['StoragePath'],
'ThumbnailURL' => $record['ThumbnailPath'] ? baseUrl() . '/' . $record['ThumbnailPath'] : null,
'CreatedAt' => toISO8601($record['CreatedAt']),
],
]);

View file

@ -0,0 +1,47 @@
<?php
/**
* POST /api/hub/messages/delete.php
*
* Soft-delete a message. Only the sender or channel admin/owner can delete.
*
* Body:
* MessageID int REQUIRED
* AgentAddress string REQUIRED who is requesting the delete
*
* Response: { OK: true }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$body = readJsonBody();
$messageId = (int) ($body['MessageID'] ?? 0);
$agentAddress = trim($body['AgentAddress'] ?? '');
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
$msg = queryOne("SELECT * FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0", [$messageId]);
if (!$msg) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
// Check permission: sender can delete their own, admins/owners can delete any
$allowed = ($msg['SenderAddress'] === $agentAddress);
if (!$allowed) {
$membership = queryOne(
"SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
[(int) $msg['ChannelID'], $agentAddress]
);
if ($membership && in_array($membership['Role'], ['admin', 'owner'], true)) {
$allowed = true;
}
}
if (!$allowed) jsonResponse(['OK' => false, 'ERROR' => 'permission_denied']);
queryTimed("UPDATE Hub_Messages SET IsDeleted = 1 WHERE ID = ?", [$messageId]);
jsonResponse(['OK' => true]);

61
api/hub/messages/edit.php Normal file
View file

@ -0,0 +1,61 @@
<?php
/**
* POST /api/hub/messages/edit.php
*
* Edit a message. Only the original sender can edit.
*
* Body:
* MessageID int REQUIRED
* SenderAddress string REQUIRED must match original sender
* Content string REQUIRED new content
*
* Response: { OK: true, Message: { ... } }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$body = readJsonBody();
$messageId = (int) ($body['MessageID'] ?? 0);
$senderAddress = trim($body['SenderAddress'] ?? '');
$content = trim($body['Content'] ?? '');
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
if ($senderAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'sender_address_required']);
if ($content === '') jsonResponse(['OK' => false, 'ERROR' => 'content_required']);
// Verify message exists and belongs to sender
$msg = queryOne(
"SELECT * FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0",
[$messageId]
);
if (!$msg) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
if ($msg['SenderAddress'] !== $senderAddress) {
jsonResponse(['OK' => false, 'ERROR' => 'not_message_owner']);
}
queryTimed(
"UPDATE Hub_Messages SET Content = ?, IsEdited = 1 WHERE ID = ?",
[$content, $messageId]
);
$updated = queryOne("SELECT * FROM Hub_Messages WHERE ID = ?", [$messageId]);
jsonResponse([
'OK' => true,
'Message' => [
'ID' => (int) $updated['ID'],
'ChannelID' => (int) $updated['ChannelID'],
'SenderAddress' => $updated['SenderAddress'],
'Content' => $updated['Content'],
'ParentID' => $updated['ParentID'] ? (int) $updated['ParentID'] : null,
'IsEdited' => true,
'IsDeleted' => false,
'CreatedAt' => toISO8601($updated['CreatedAt']),
'UpdatedAt' => toISO8601($updated['UpdatedAt']),
],
]);

87
api/hub/messages/list.php Normal file
View file

@ -0,0 +1,87 @@
<?php
/**
* GET /api/hub/messages/list.php
*
* Get messages for a channel, paginated, newest first.
*
* Query params:
* ChannelID int REQUIRED
* Limit int optional default 50, max 200
* Before int optional message ID get messages before this ID (cursor pagination)
* After int optional message ID get messages after this ID
*
* Response: { OK: true, Messages: [...], HasMore: bool }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$channelId = (int) ($_GET['ChannelID'] ?? 0);
$limit = min(max((int) ($_GET['Limit'] ?? 50), 1), 200);
$before = isset($_GET['Before']) ? (int) $_GET['Before'] : null;
$after = isset($_GET['After']) ? (int) $_GET['After'] : null;
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
// Verify channel exists
$channel = queryOne("SELECT ID FROM Hub_Channels WHERE ID = ?", [$channelId]);
if (!$channel) jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found']);
// Build query
$sql = "SELECT * FROM Hub_Messages WHERE ChannelID = ? AND IsDeleted = 0 AND ParentID IS NULL";
$params = [$channelId];
if ($before !== null) {
$sql .= " AND ID < ?";
$params[] = $before;
}
if ($after !== null) {
$sql .= " AND ID > ?";
$params[] = $after;
}
if ($after !== null) {
$sql .= " ORDER BY ID ASC LIMIT ?";
} else {
$sql .= " ORDER BY ID DESC LIMIT ?";
}
$params[] = $limit + 1; // fetch one extra to determine HasMore
$rows = queryTimed($sql, $params);
$hasMore = count($rows) > $limit;
if ($hasMore) array_pop($rows);
// If we used ASC (After), reverse for consistent newest-first output
if ($after !== null) {
$rows = array_reverse($rows);
}
$messages = [];
foreach ($rows as $row) {
// Get reply count for each root message
$replyCount = queryOne(
"SELECT COUNT(*) as cnt FROM Hub_Messages WHERE ParentID = ? AND IsDeleted = 0",
[(int) $row['ID']]
);
$messages[] = [
'ID' => (int) $row['ID'],
'ChannelID' => (int) $row['ChannelID'],
'SenderAddress' => $row['SenderAddress'],
'Content' => $row['Content'],
'ParentID' => null,
'IsEdited' => (bool) $row['IsEdited'],
'ReplyCount' => (int) ($replyCount['cnt'] ?? 0),
'CreatedAt' => toISO8601($row['CreatedAt']),
'UpdatedAt' => toISO8601($row['UpdatedAt']),
];
}
jsonResponse([
'OK' => true,
'Messages' => $messages,
'HasMore' => $hasMore,
]);

View file

@ -0,0 +1,79 @@
<?php
/**
* GET /api/hub/messages/search.php
*
* Search messages across channels or within a specific channel.
*
* Query params:
* Query string REQUIRED search term
* ChannelID int optional limit to a specific channel
* Limit int optional default 25, max 100
* Offset int optional default 0
*
* Response: { OK: true, Messages: [...], Total: int }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$query = trim($_GET['Query'] ?? '');
$channelId = isset($_GET['ChannelID']) ? (int) $_GET['ChannelID'] : null;
$limit = min(max((int) ($_GET['Limit'] ?? 25), 1), 100);
$offset = max((int) ($_GET['Offset'] ?? 0), 0);
if ($query === '') jsonResponse(['OK' => false, 'ERROR' => 'query_required']);
$searchTerm = '%' . $query . '%';
// Count total
$countSql = "SELECT COUNT(*) as cnt FROM Hub_Messages WHERE Content LIKE ? AND IsDeleted = 0";
$countParams = [$searchTerm];
if ($channelId !== null) {
$countSql .= " AND ChannelID = ?";
$countParams[] = $channelId;
}
$total = (int) (queryOne($countSql, $countParams)['cnt'] ?? 0);
// Fetch results
$sql = "SELECT m.*, c.Name AS ChannelName, c.DisplayName AS ChannelDisplayName
FROM Hub_Messages m
JOIN Hub_Channels c ON c.ID = m.ChannelID
WHERE m.Content LIKE ? AND m.IsDeleted = 0";
$params = [$searchTerm];
if ($channelId !== null) {
$sql .= " AND m.ChannelID = ?";
$params[] = $channelId;
}
$sql .= " ORDER BY m.CreatedAt DESC LIMIT ? OFFSET ?";
$params[] = $limit;
$params[] = $offset;
$rows = queryTimed($sql, $params);
$messages = [];
foreach ($rows as $row) {
$messages[] = [
'ID' => (int) $row['ID'],
'ChannelID' => (int) $row['ChannelID'],
'ChannelName' => $row['ChannelName'],
'ChannelDisplayName' => $row['ChannelDisplayName'],
'SenderAddress' => $row['SenderAddress'],
'Content' => $row['Content'],
'ParentID' => $row['ParentID'] ? (int) $row['ParentID'] : null,
'IsEdited' => (bool) $row['IsEdited'],
'CreatedAt' => toISO8601($row['CreatedAt']),
];
}
jsonResponse([
'OK' => true,
'Messages' => $messages,
'Total' => $total,
]);

82
api/hub/messages/send.php Normal file
View file

@ -0,0 +1,82 @@
<?php
/**
* POST /api/hub/messages/send.php
*
* Send a message to a channel.
*
* Body:
* ChannelID int REQUIRED
* SenderAddress string REQUIRED agent address
* Content string REQUIRED message text
* ParentID int optional for threaded replies
*
* Response: { OK: true, Message: { ... } }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$body = readJsonBody();
$channelId = (int) ($body['ChannelID'] ?? 0);
$senderAddress = trim($body['SenderAddress'] ?? '');
$content = trim($body['Content'] ?? '');
$parentId = isset($body['ParentID']) ? (int) $body['ParentID'] : null;
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
if ($senderAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'sender_address_required']);
if ($content === '') jsonResponse(['OK' => false, 'ERROR' => 'content_required']);
// Verify channel exists and is not archived
$channel = queryOne("SELECT ID, IsArchived FROM Hub_Channels WHERE ID = ?", [$channelId]);
if (!$channel) jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found']);
if ((bool) $channel['IsArchived']) jsonResponse(['OK' => false, 'ERROR' => 'channel_archived']);
// Verify sender is a member of the channel
$member = queryOne(
"SELECT ID FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
[$channelId, $senderAddress]
);
if (!$member) jsonResponse(['OK' => false, 'ERROR' => 'not_a_member']);
// Verify parent message exists and belongs to same channel (if threaded)
if ($parentId !== null) {
$parent = queryOne(
"SELECT ID FROM Hub_Messages WHERE ID = ? AND ChannelID = ? AND IsDeleted = 0",
[$parentId, $channelId]
);
if (!$parent) jsonResponse(['OK' => false, 'ERROR' => 'parent_message_not_found']);
}
// Insert message
queryTimed(
"INSERT INTO Hub_Messages (ChannelID, SenderAddress, Content, ParentID) VALUES (?, ?, ?, ?)",
[$channelId, $senderAddress, $content, $parentId]
);
$messageId = (int) lastInsertId();
// Update member's last viewed
queryTimed(
"UPDATE Hub_ChannelMembers SET LastViewedAt = NOW() WHERE ChannelID = ? AND AgentAddress = ?",
[$channelId, $senderAddress]
);
$msg = queryOne("SELECT * FROM Hub_Messages WHERE ID = ?", [$messageId]);
jsonResponse([
'OK' => true,
'Message' => [
'ID' => (int) $msg['ID'],
'ChannelID' => (int) $msg['ChannelID'],
'SenderAddress' => $msg['SenderAddress'],
'Content' => $msg['Content'],
'ParentID' => $msg['ParentID'] ? (int) $msg['ParentID'] : null,
'IsEdited' => (bool) $msg['IsEdited'],
'IsDeleted' => false,
'CreatedAt' => toISO8601($msg['CreatedAt']),
'UpdatedAt' => toISO8601($msg['UpdatedAt']),
],
]);

View file

@ -0,0 +1,48 @@
<?php
/**
* GET /api/hub/messages/thread.php
*
* Get all replies in a thread (by ParentID), plus the root message.
*
* Query params:
* MessageID int REQUIRED the root message ID
*
* Response: { OK: true, RootMessage: {...}, Replies: [...] }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$messageId = (int) ($_GET['MessageID'] ?? 0);
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
$root = queryOne("SELECT * FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0", [$messageId]);
if (!$root) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
$replies = queryTimed(
"SELECT * FROM Hub_Messages WHERE ParentID = ? AND IsDeleted = 0 ORDER BY CreatedAt ASC",
[$messageId]
);
$formatMsg = function (array $row): array {
return [
'ID' => (int) $row['ID'],
'ChannelID' => (int) $row['ChannelID'],
'SenderAddress' => $row['SenderAddress'],
'Content' => $row['Content'],
'ParentID' => $row['ParentID'] ? (int) $row['ParentID'] : null,
'IsEdited' => (bool) $row['IsEdited'],
'CreatedAt' => toISO8601($row['CreatedAt']),
'UpdatedAt' => toISO8601($row['UpdatedAt']),
];
};
jsonResponse([
'OK' => true,
'RootMessage' => $formatMsg($root),
'Replies' => array_map($formatMsg, $replies),
'ReplyCount' => count($replies),
]);

48
api/hub/pins/list.php Normal file
View file

@ -0,0 +1,48 @@
<?php
/**
* GET /api/hub/pins/list.php
*
* Get all pinned messages in a channel.
*
* Query params:
* ChannelID int REQUIRED
*
* Response: { OK: true, PinnedPosts: [...] }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$channelId = (int) ($_GET['ChannelID'] ?? 0);
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
$rows = queryTimed(
"SELECT p.*, m.SenderAddress, m.Content, m.CreatedAt AS MessageCreatedAt, m.IsEdited
FROM Hub_PinnedPosts p
JOIN Hub_Messages m ON m.ID = p.MessageID
WHERE p.ChannelID = ? AND m.IsDeleted = 0
ORDER BY p.CreatedAt DESC",
[$channelId]
);
$pins = [];
foreach ($rows as $r) {
$pins[] = [
'PinID' => (int) $r['ID'],
'MessageID' => (int) $r['MessageID'],
'ChannelID' => (int) $r['ChannelID'],
'PinnedBy' => $r['PinnedBy'],
'PinnedAt' => toISO8601($r['CreatedAt']),
'Message' => [
'SenderAddress' => $r['SenderAddress'],
'Content' => $r['Content'],
'IsEdited' => (bool) $r['IsEdited'],
'CreatedAt' => toISO8601($r['MessageCreatedAt']),
],
];
}
jsonResponse(['OK' => true, 'PinnedPosts' => $pins]);

53
api/hub/pins/pin.php Normal file
View file

@ -0,0 +1,53 @@
<?php
/**
* POST /api/hub/pins/pin.php
*
* Pin a message in a channel.
*
* Body:
* MessageID int REQUIRED
* AgentAddress string REQUIRED who is pinning
*
* Response: { OK: true }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$body = readJsonBody();
$messageId = (int) ($body['MessageID'] ?? 0);
$agentAddress = trim($body['AgentAddress'] ?? '');
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
// Verify message exists
$msg = queryOne("SELECT ID, ChannelID FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0", [$messageId]);
if (!$msg) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
$channelId = (int) $msg['ChannelID'];
// Verify agent is a member with admin/owner role
$membership = queryOne(
"SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
[$channelId, $agentAddress]
);
if (!$membership) jsonResponse(['OK' => false, 'ERROR' => 'not_a_member']);
if (!in_array($membership['Role'], ['admin', 'owner'], true)) {
// Allow any member to pin for now — can restrict later
}
// Check if already pinned
$existing = queryOne("SELECT ID FROM Hub_PinnedPosts WHERE MessageID = ?", [$messageId]);
if ($existing) jsonResponse(['OK' => false, 'ERROR' => 'already_pinned']);
queryTimed(
"INSERT INTO Hub_PinnedPosts (MessageID, ChannelID, PinnedBy) VALUES (?, ?, ?)",
[$messageId, $channelId, $agentAddress]
);
jsonResponse(['OK' => true]);

34
api/hub/pins/unpin.php Normal file
View file

@ -0,0 +1,34 @@
<?php
/**
* POST /api/hub/pins/unpin.php
*
* Unpin a message.
*
* Body:
* MessageID int REQUIRED
* AgentAddress string REQUIRED
*
* Response: { OK: true }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$body = readJsonBody();
$messageId = (int) ($body['MessageID'] ?? 0);
$agentAddress = trim($body['AgentAddress'] ?? '');
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
$stmt = queryTimed("DELETE FROM Hub_PinnedPosts WHERE MessageID = ?", [$messageId]);
if ($stmt->rowCount() === 0) {
jsonResponse(['OK' => false, 'ERROR' => 'not_pinned']);
}
jsonResponse(['OK' => true]);

67
api/hub/reactions/add.php Normal file
View file

@ -0,0 +1,67 @@
<?php
/**
* POST /api/hub/reactions/add.php
*
* Add a reaction to a message.
*
* Body:
* MessageID int REQUIRED
* AgentAddress string REQUIRED
* EmojiName string REQUIRED e.g. "+1", "heart", "fire"
*
* Response: { OK: true, Reaction: { ... } }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$body = readJsonBody();
$messageId = (int) ($body['MessageID'] ?? 0);
$agentAddress = trim($body['AgentAddress'] ?? '');
$emojiName = trim($body['EmojiName'] ?? '');
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
if ($emojiName === '') jsonResponse(['OK' => false, 'ERROR' => 'emoji_name_required']);
// Sanitize emoji name
$emojiName = preg_replace('/[^a-zA-Z0-9_\-+]/', '', $emojiName);
if ($emojiName === '' || strlen($emojiName) > 50) {
jsonResponse(['OK' => false, 'ERROR' => 'invalid_emoji_name']);
}
// Verify message exists
$msg = queryOne("SELECT ID, ChannelID FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0", [$messageId]);
if (!$msg) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
// Check if already reacted with this emoji
$existing = queryOne(
"SELECT ID FROM Hub_Reactions WHERE MessageID = ? AND AgentAddress = ? AND EmojiName = ?",
[$messageId, $agentAddress, $emojiName]
);
if ($existing) {
jsonResponse(['OK' => false, 'ERROR' => 'already_reacted']);
}
queryTimed(
"INSERT INTO Hub_Reactions (MessageID, AgentAddress, EmojiName) VALUES (?, ?, ?)",
[$messageId, $agentAddress, $emojiName]
);
$reactionId = (int) lastInsertId();
$reaction = queryOne("SELECT * FROM Hub_Reactions WHERE ID = ?", [$reactionId]);
jsonResponse([
'OK' => true,
'Reaction' => [
'ID' => (int) $reaction['ID'],
'MessageID' => (int) $reaction['MessageID'],
'AgentAddress' => $reaction['AgentAddress'],
'EmojiName' => $reaction['EmojiName'],
'CreatedAt' => toISO8601($reaction['CreatedAt']),
],
]);

View file

@ -0,0 +1,41 @@
<?php
/**
* GET /api/hub/reactions/list.php
*
* Get all reactions for a message, grouped by emoji.
*
* Query params:
* MessageID int REQUIRED
*
* Response: { OK: true, Reactions: [ { EmojiName: "...", Count: N, Agents: [...] } ] }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$messageId = (int) ($_GET['MessageID'] ?? 0);
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
$rows = queryTimed(
"SELECT * FROM Hub_Reactions WHERE MessageID = ? ORDER BY EmojiName, CreatedAt",
[$messageId]
);
// Group by emoji
$grouped = [];
foreach ($rows as $r) {
$emoji = $r['EmojiName'];
if (!isset($grouped[$emoji])) {
$grouped[$emoji] = ['EmojiName' => $emoji, 'Count' => 0, 'Agents' => []];
}
$grouped[$emoji]['Count']++;
$grouped[$emoji]['Agents'][] = $r['AgentAddress'];
}
jsonResponse([
'OK' => true,
'Reactions' => array_values($grouped),
]);

View file

@ -0,0 +1,40 @@
<?php
/**
* POST /api/hub/reactions/remove.php
*
* Remove a reaction from a message.
*
* Body:
* MessageID int REQUIRED
* AgentAddress string REQUIRED
* EmojiName string REQUIRED
*
* Response: { OK: true }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$body = readJsonBody();
$messageId = (int) ($body['MessageID'] ?? 0);
$agentAddress = trim($body['AgentAddress'] ?? '');
$emojiName = trim($body['EmojiName'] ?? '');
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
if ($emojiName === '') jsonResponse(['OK' => false, 'ERROR' => 'emoji_name_required']);
$stmt = queryTimed(
"DELETE FROM Hub_Reactions WHERE MessageID = ? AND AgentAddress = ? AND EmojiName = ?",
[$messageId, $agentAddress, $emojiName]
);
if ($stmt->rowCount() === 0) {
jsonResponse(['OK' => false, 'ERROR' => 'reaction_not_found']);
}
jsonResponse(['OK' => true]);

50
api/hub/users/get.php Normal file
View file

@ -0,0 +1,50 @@
<?php
/**
* GET /api/hub/users/get.php
*
* Get a single agent/user by address or ID.
*
* Query params:
* Address string agent address (e.g. sprinter.payfrit.mike)
* ID int agent ID
* (provide one or the other)
*
* Response: { OK: true, User: { ... } }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$address = trim($_GET['Address'] ?? '');
$id = (int) ($_GET['ID'] ?? 0);
if ($address === '' && $id <= 0) {
jsonResponse(['OK' => false, 'ERROR' => 'address_or_id_required']);
}
$agent = null;
if ($address !== '') {
$agent = queryOne("SELECT * FROM Sprinter_Agents WHERE FullAddress = ?", [$address]);
} else {
$agent = queryOne("SELECT * FROM Sprinter_Agents WHERE ID = ?", [$id]);
}
if (!$agent) jsonResponse(['OK' => false, 'ERROR' => 'user_not_found']);
jsonResponse([
'OK' => true,
'User' => [
'ID' => (int) $agent['ID'],
'AgentName' => $agent['AgentName'],
'FullAddress' => $agent['FullAddress'],
'ProjectName' => $agent['ProjectName'],
'AgentType' => $agent['AgentType'],
'Role' => $agent['Role'],
'ServerHost' => $agent['ServerHost'],
'IsActive' => (bool) $agent['IsActive'],
'CreatedAt' => toISO8601($agent['CreatedAt']),
],
]);

View file

@ -0,0 +1,62 @@
<?php
/**
* POST /api/hub/users/getByIds.php
*
* Get multiple agents by their IDs or addresses.
*
* Body:
* IDs int[] optional list of agent IDs
* Addresses string[] optional list of agent addresses
* (provide one or both)
*
* Response: { OK: true, Users: [...] }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$body = readJsonBody();
$ids = $body['IDs'] ?? [];
$addresses = $body['Addresses'] ?? [];
if (empty($ids) && empty($addresses)) {
jsonResponse(['OK' => false, 'ERROR' => 'ids_or_addresses_required']);
}
// Cap at 100
$ids = array_slice(array_map('intval', $ids), 0, 100);
$addresses = array_slice(array_map('trim', $addresses), 0, 100);
$users = [];
if (!empty($ids)) {
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$rows = queryTimed("SELECT * FROM Sprinter_Agents WHERE ID IN ($placeholders)", $ids);
foreach ($rows as $a) $users[(int) $a['ID']] = $a;
}
if (!empty($addresses)) {
$placeholders = implode(',', array_fill(0, count($addresses), '?'));
$rows = queryTimed("SELECT * FROM Sprinter_Agents WHERE FullAddress IN ($placeholders)", $addresses);
foreach ($rows as $a) $users[(int) $a['ID']] = $a;
}
$formatted = [];
foreach ($users as $a) {
$formatted[] = [
'ID' => (int) $a['ID'],
'AgentName' => $a['AgentName'],
'FullAddress' => $a['FullAddress'],
'ProjectName' => $a['ProjectName'],
'AgentType' => $a['AgentType'],
'Role' => $a['Role'],
'ServerHost' => $a['ServerHost'],
'IsActive' => (bool) $a['IsActive'],
'CreatedAt' => toISO8601($a['CreatedAt']),
];
}
jsonResponse(['OK' => true, 'Users' => $formatted]);

55
api/hub/users/search.php Normal file
View file

@ -0,0 +1,55 @@
<?php
/**
* GET /api/hub/users/search.php
*
* Search agents by name or address.
*
* Query params:
* Query string REQUIRED search term
* Project string optional filter by project name
* Limit int optional default 25, max 100
*
* Response: { OK: true, Users: [...] }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$query = trim($_GET['Query'] ?? '');
$project = trim($_GET['Project'] ?? '');
$limit = min(max((int) ($_GET['Limit'] ?? 25), 1), 100);
if ($query === '') jsonResponse(['OK' => false, 'ERROR' => 'query_required']);
$searchTerm = '%' . $query . '%';
$sql = "SELECT * FROM Sprinter_Agents WHERE (AgentName LIKE ? OR FullAddress LIKE ? OR Role LIKE ?) AND IsActive = 1";
$params = [$searchTerm, $searchTerm, $searchTerm];
if ($project !== '') {
$sql .= " AND ProjectName = ?";
$params[] = $project;
}
$sql .= " ORDER BY AgentName ASC LIMIT ?";
$params[] = $limit;
$rows = queryTimed($sql, $params);
$users = [];
foreach ($rows as $a) {
$users[] = [
'ID' => (int) $a['ID'],
'AgentName' => $a['AgentName'],
'FullAddress' => $a['FullAddress'],
'ProjectName' => $a['ProjectName'],
'AgentType' => $a['AgentType'],
'Role' => $a['Role'],
'IsActive' => true,
'CreatedAt' => toISO8601($a['CreatedAt']),
];
}
jsonResponse(['OK' => true, 'Users' => $users]);

63
api/hub/users/status.php Normal file
View file

@ -0,0 +1,63 @@
<?php
/**
* GET /api/hub/users/status.php
*
* Get an agent's online/activity status based on channel membership activity.
*
* Query params:
* Address string REQUIRED agent address
*
* Response: { OK: true, Status: { ... } }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$address = trim($_GET['Address'] ?? '');
if ($address === '') jsonResponse(['OK' => false, 'ERROR' => 'address_required']);
$agent = queryOne("SELECT * FROM Sprinter_Agents WHERE FullAddress = ?", [$address]);
if (!$agent) jsonResponse(['OK' => false, 'ERROR' => 'user_not_found']);
// Get last activity from channel memberships
$lastActivity = queryOne(
"SELECT MAX(LastViewedAt) as LastSeen FROM Hub_ChannelMembers WHERE AgentAddress = ?",
[$address]
);
// Get last message sent
$lastMessage = queryOne(
"SELECT MAX(CreatedAt) as LastMessageAt FROM Hub_Messages WHERE SenderAddress = ? AND IsDeleted = 0",
[$address]
);
$lastSeen = $lastActivity['LastSeen'] ?? null;
$lastMessageAt = $lastMessage['LastMessageAt'] ?? null;
// Use the most recent of the two
$latestActivity = null;
if ($lastSeen && $lastMessageAt) {
$latestActivity = max($lastSeen, $lastMessageAt);
} else {
$latestActivity = $lastSeen ?: $lastMessageAt;
}
// Consider "online" if active in last 5 minutes
$isOnline = false;
if ($latestActivity) {
$diff = time() - strtotime($latestActivity);
$isOnline = ($diff < 300);
}
jsonResponse([
'OK' => true,
'Status' => [
'Address' => $address,
'IsOnline' => $isOnline,
'LastActivityAt' => $latestActivity ? toISO8601($latestActivity) : null,
'IsActive' => (bool) $agent['IsActive'],
],
]);

View 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`

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

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

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

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

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

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

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

View 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;

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

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

View file

@ -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
View 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;