Compare commits
18 commits
schwifty/f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cd373dd616 | |||
| c8ac6ae3fa | |||
| 1dacefcf70 | |||
| 4a9db0de0a | |||
| 629c7d2cef | |||
| 61c9bb8038 | |||
| 41aba807b4 | |||
| eb033b6d4f | |||
| f02a0d0adb | |||
| 8f3fc62b19 | |||
| 32c2cc1381 | |||
| d1630e69b2 | |||
| e3933ce0c8 | |||
| cc7d6f6b4f | |||
| 601245d969 | |||
| b25198b3f5 | |||
| fd3183035e | |||
| c05bbe684f |
55 changed files with 4134 additions and 16 deletions
|
|
@ -10,7 +10,48 @@ if ($headerSecret !== $secret) {
|
|||
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';
|
||||
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')
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
require_once __DIR__ . '/_cronUtils.php';
|
||||
// No runAuth() — this is a cron/public endpoint
|
||||
requireCronSecret();
|
||||
|
||||
/**
|
||||
* Process all due scheduled tasks.
|
||||
|
|
|
|||
|
|
@ -300,6 +300,30 @@ function sendSMS(string $to, string $body): array {
|
|||
return ['success' => false, 'message' => $errMsg];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CRON AUTH
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Require a valid X-Cron-Secret header for cron/scheduled task endpoints.
|
||||
* The secret is read from the PAYFRIT_CRON_SECRET environment variable.
|
||||
* Aborts with 403 if missing or mismatched.
|
||||
*/
|
||||
function requireCronSecret(): void {
|
||||
$expected = trim(getenv('PAYFRIT_CRON_SECRET') ?: '');
|
||||
if ($expected === '') {
|
||||
error_log('[cron_auth] PAYFRIT_CRON_SECRET env var is not set. Blocking request.');
|
||||
http_response_code(403);
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'cron_secret_not_configured'], 403);
|
||||
}
|
||||
|
||||
$provided = headerValue('X-Cron-Secret');
|
||||
if ($provided === '' || !hash_equals($expected, $provided)) {
|
||||
http_response_code(403);
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invalid_cron_secret'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AUTH MIDDLEWARE
|
||||
// ============================================
|
||||
|
|
@ -492,6 +516,59 @@ const PUBLIC_ROUTES = [
|
|||
'/api/tabs/pendingOrders.php',
|
||||
'/api/tabs/increaseAuth.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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -525,18 +602,14 @@ function runAuth(): void {
|
|||
$businessId = (int) $bizHeader;
|
||||
}
|
||||
|
||||
// Check if public route
|
||||
// Check if public route (exact match only)
|
||||
$isPublic = false;
|
||||
foreach (PUBLIC_ROUTES as $route) {
|
||||
if (str_contains($path, strtolower($route))) {
|
||||
if ($path === strtolower($route)) {
|
||||
$isPublic = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Also allow /api/admin/ paths
|
||||
if (str_contains($path, '/api/admin/')) {
|
||||
$isPublic = true;
|
||||
}
|
||||
|
||||
if (!$isPublic) {
|
||||
if ($userId <= 0) {
|
||||
|
|
|
|||
113
api/hub/auth/login.php
Normal file
113
api/hub/auth/login.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/auth/login.php
|
||||
*
|
||||
* Authenticate a user (human or bot) against the Hub.
|
||||
*
|
||||
* Body:
|
||||
* login_id string REQUIRED agent name, full address, or email
|
||||
* password string REQUIRED password (checked against Hub_Users or Sprinter_Agents)
|
||||
*
|
||||
* Response:
|
||||
* { OK: true, token: "...", user: { ... } }
|
||||
* Also sets the Token response header for the proxy layer.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$loginId = trim($body['login_id'] ?? '');
|
||||
$password = $body['password'] ?? '';
|
||||
|
||||
if ($loginId === '') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'login_id_required']);
|
||||
}
|
||||
if ($password === '') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'password_required']);
|
||||
}
|
||||
|
||||
// Resolve the agent — try by full address, then by name
|
||||
$agent = queryOne(
|
||||
"SELECT a.*, u.PasswordHash, u.Token, u.TokenExpiresAt
|
||||
FROM Sprinter_Agents a
|
||||
LEFT JOIN Hub_Users u ON u.AgentID = a.ID
|
||||
WHERE a.FullAddress = ? AND a.IsActive = 1",
|
||||
[$loginId]
|
||||
);
|
||||
|
||||
if (!$agent) {
|
||||
// Try by agent name (e.g. "john" → "sprinter.payfrit.john")
|
||||
$agent = queryOne(
|
||||
"SELECT a.*, u.PasswordHash, u.Token, u.TokenExpiresAt
|
||||
FROM Sprinter_Agents a
|
||||
LEFT JOIN Hub_Users u ON u.AgentID = a.ID
|
||||
WHERE a.AgentName = ? AND a.IsActive = 1",
|
||||
[$loginId]
|
||||
);
|
||||
}
|
||||
|
||||
if (!$agent) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invalid_credentials'], 401);
|
||||
}
|
||||
|
||||
// Check password
|
||||
$storedHash = $agent['PasswordHash'] ?? null;
|
||||
|
||||
if ($storedHash === null) {
|
||||
// No Hub_Users row yet — auto-provision on first login
|
||||
// For MVP: accept the password, hash it, create the row and token
|
||||
$hash = password_hash($password, PASSWORD_BCRYPT);
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expires = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_Users (AgentID, PasswordHash, Token, TokenExpiresAt)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE PasswordHash = VALUES(PasswordHash), Token = VALUES(Token), TokenExpiresAt = VALUES(TokenExpiresAt)",
|
||||
[(int)$agent['ID'], $hash, $token, $expires]
|
||||
);
|
||||
} else {
|
||||
// Verify password
|
||||
if (!password_verify($password, $storedHash)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invalid_credentials'], 401);
|
||||
}
|
||||
|
||||
// Refresh token if expired or missing
|
||||
$token = $agent['Token'] ?? '';
|
||||
$expiresAt = $agent['TokenExpiresAt'] ?? '';
|
||||
if ($token === '' || (strtotime($expiresAt) < time())) {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expires = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||
queryTimed(
|
||||
"UPDATE Hub_Users SET Token = ?, TokenExpiresAt = ? WHERE AgentID = ?",
|
||||
[$token, $expires, (int)$agent['ID']]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Return user info mapped for the web client
|
||||
$user = [
|
||||
'ID' => (int) $agent['ID'],
|
||||
'AgentName' => $agent['AgentName'],
|
||||
'FullAddress' => $agent['FullAddress'],
|
||||
'ProjectName' => $agent['ProjectName'],
|
||||
'AgentType' => $agent['AgentType'],
|
||||
'Role' => $agent['Role'],
|
||||
'ServerHost' => $agent['ServerHost'],
|
||||
'IsActive' => (bool) $agent['IsActive'],
|
||||
'CreatedAt' => toISO8601($agent['CreatedAt']),
|
||||
];
|
||||
|
||||
// Set Token header so hub-proxy.php forwards it to the client
|
||||
header('Token: ' . $token);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'token' => $token,
|
||||
'user' => $user,
|
||||
]);
|
||||
91
api/hub/channels/create.php
Normal file
91
api/hub/channels/create.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/channels/create.php
|
||||
*
|
||||
* Create a new channel.
|
||||
*
|
||||
* Body:
|
||||
* Name string REQUIRED unique slug (lowercase, hyphens, no spaces)
|
||||
* DisplayName string optional human-readable name (defaults to Name)
|
||||
* Purpose string optional channel description
|
||||
* ChannelType string optional public|private|direct (default: public)
|
||||
* CreatedBy string REQUIRED agent address (e.g. sprinter.payfrit.mike)
|
||||
*
|
||||
* Response: { OK: true, Channel: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
// --- Validate required fields ---
|
||||
$name = trim($body['Name'] ?? '');
|
||||
$createdBy = trim($body['CreatedBy'] ?? '');
|
||||
|
||||
if ($name === '') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'name_required']);
|
||||
}
|
||||
if ($createdBy === '') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'created_by_required']);
|
||||
}
|
||||
|
||||
// Sanitize name: lowercase, alphanumeric + hyphens only
|
||||
$name = strtolower(preg_replace('/[^a-zA-Z0-9\-]/', '', $name));
|
||||
if ($name === '' || strlen($name) > 100) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invalid_name']);
|
||||
}
|
||||
|
||||
$displayName = trim($body['DisplayName'] ?? '') ?: $name;
|
||||
$purpose = trim($body['Purpose'] ?? '');
|
||||
$channelType = strtolower(trim($body['ChannelType'] ?? 'public'));
|
||||
|
||||
if (!in_array($channelType, ['public', 'private', 'direct'], true)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invalid_channel_type']);
|
||||
}
|
||||
|
||||
// Enforce length limits
|
||||
if (strlen($displayName) > 200) $displayName = substr($displayName, 0, 200);
|
||||
if (strlen($purpose) > 500) $purpose = substr($purpose, 0, 500);
|
||||
|
||||
// --- Check uniqueness ---
|
||||
$existing = queryOne("SELECT ID FROM Hub_Channels WHERE Name = ?", [$name]);
|
||||
if ($existing) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'name_already_exists']);
|
||||
}
|
||||
|
||||
// --- Insert channel ---
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_Channels (Name, DisplayName, Purpose, ChannelType, CreatedBy)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
[$name, $displayName, $purpose, $channelType, $createdBy]
|
||||
);
|
||||
$channelId = (int) lastInsertId();
|
||||
|
||||
// --- Auto-add creator as owner ---
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_ChannelMembers (ChannelID, AgentAddress, Role) VALUES (?, ?, 'owner')",
|
||||
[$channelId, $createdBy]
|
||||
);
|
||||
|
||||
// --- Fetch and return ---
|
||||
$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$channelId]);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Channel' => [
|
||||
'ID' => (int) $channel['ID'],
|
||||
'Name' => $channel['Name'],
|
||||
'DisplayName' => $channel['DisplayName'],
|
||||
'Purpose' => $channel['Purpose'],
|
||||
'ChannelType' => $channel['ChannelType'],
|
||||
'CreatedBy' => $channel['CreatedBy'],
|
||||
'IsArchived' => (bool) $channel['IsArchived'],
|
||||
'CreatedAt' => toISO8601($channel['CreatedAt']),
|
||||
'UpdatedAt' => toISO8601($channel['UpdatedAt']),
|
||||
'MemberCount' => 1,
|
||||
],
|
||||
]);
|
||||
51
api/hub/channels/delete.php
Normal file
51
api/hub/channels/delete.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/channels/delete.php
|
||||
*
|
||||
* Delete (hard-delete) a channel. Only the owner can delete.
|
||||
* For soft-delete, use update.php with IsArchived=true instead.
|
||||
*
|
||||
* Body:
|
||||
* ID int REQUIRED
|
||||
* Agent string REQUIRED requesting agent (must be owner)
|
||||
*
|
||||
* Response: { OK: true }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$id = (int) ($body['ID'] ?? 0);
|
||||
$agent = trim($body['Agent'] ?? '');
|
||||
|
||||
if ($id <= 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'id_required']);
|
||||
}
|
||||
if ($agent === '') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'agent_required']);
|
||||
}
|
||||
|
||||
// Verify channel exists
|
||||
$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$id]);
|
||||
if (!$channel) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404);
|
||||
}
|
||||
|
||||
// Only owner can delete
|
||||
$membership = queryOne(
|
||||
"SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[$id, $agent]
|
||||
);
|
||||
if (!$membership || $membership['Role'] !== 'owner') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'not_authorized_owner_only'], 403);
|
||||
}
|
||||
|
||||
// Delete channel (FK cascade will remove members)
|
||||
queryTimed("DELETE FROM Hub_Channels WHERE ID = ?", [$id]);
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
74
api/hub/channels/get.php
Normal file
74
api/hub/channels/get.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/channels/get.php
|
||||
*
|
||||
* Get a single channel by ID or Name.
|
||||
*
|
||||
* Query params:
|
||||
* ID int get by ID
|
||||
* Name string get by name (if ID not provided)
|
||||
*
|
||||
* Response: { OK: true, Channel: { ... }, Members: [...] }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$id = (int) ($_GET['ID'] ?? 0);
|
||||
$name = trim($_GET['Name'] ?? '');
|
||||
|
||||
if ($id <= 0 && $name === '') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'id_or_name_required']);
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$id]);
|
||||
} else {
|
||||
$channel = queryOne("SELECT * FROM Hub_Channels WHERE Name = ?", [$name]);
|
||||
}
|
||||
|
||||
if (!$channel) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404);
|
||||
}
|
||||
|
||||
// Fetch members
|
||||
$members = queryTimed(
|
||||
"SELECT m.*, a.AgentName, a.AgentType, a.Role AS AgentRole
|
||||
FROM Hub_ChannelMembers m
|
||||
LEFT JOIN Sprinter_Agents a ON a.FullAddress = m.AgentAddress
|
||||
WHERE m.ChannelID = ?
|
||||
ORDER BY m.JoinedAt ASC",
|
||||
[(int) $channel['ID']]
|
||||
);
|
||||
|
||||
$memberList = [];
|
||||
foreach ($members as $m) {
|
||||
$memberList[] = [
|
||||
'AgentAddress' => $m['AgentAddress'],
|
||||
'AgentName' => $m['AgentName'] ?? '',
|
||||
'AgentType' => $m['AgentType'] ?? '',
|
||||
'Role' => $m['Role'],
|
||||
'JoinedAt' => toISO8601($m['JoinedAt']),
|
||||
'LastViewedAt' => $m['LastViewedAt'] ? toISO8601($m['LastViewedAt']) : null,
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Channel' => [
|
||||
'ID' => (int) $channel['ID'],
|
||||
'Name' => $channel['Name'],
|
||||
'DisplayName' => $channel['DisplayName'],
|
||||
'Purpose' => $channel['Purpose'],
|
||||
'ChannelType' => $channel['ChannelType'],
|
||||
'CreatedBy' => $channel['CreatedBy'],
|
||||
'IsArchived' => (bool) $channel['IsArchived'],
|
||||
'CreatedAt' => toISO8601($channel['CreatedAt']),
|
||||
'UpdatedAt' => toISO8601($channel['UpdatedAt']),
|
||||
'MemberCount' => count($memberList),
|
||||
],
|
||||
'Members' => $memberList,
|
||||
]);
|
||||
62
api/hub/channels/join.php
Normal file
62
api/hub/channels/join.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/channels/join.php
|
||||
*
|
||||
* Join a channel. Public channels are open to anyone.
|
||||
* Private channels require an invite (admin/owner must add via addMember).
|
||||
*
|
||||
* Body:
|
||||
* ChannelID int REQUIRED
|
||||
* Agent string REQUIRED agent address joining
|
||||
*
|
||||
* Response: { OK: true }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$channelId = (int) ($body['ChannelID'] ?? 0);
|
||||
$agent = trim($body['Agent'] ?? '');
|
||||
|
||||
if ($channelId <= 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
}
|
||||
if ($agent === '') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'agent_required']);
|
||||
}
|
||||
|
||||
// Verify channel exists and is not archived
|
||||
$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$channelId]);
|
||||
if (!$channel) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404);
|
||||
}
|
||||
if ((bool) $channel['IsArchived']) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_archived']);
|
||||
}
|
||||
|
||||
// Private/direct channels can't be self-joined
|
||||
if ($channel['ChannelType'] !== 'public') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_public']);
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
$existing = queryOne(
|
||||
"SELECT ID FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[$channelId, $agent]
|
||||
);
|
||||
if ($existing) {
|
||||
jsonResponse(['OK' => true, 'Note' => 'already_member']);
|
||||
}
|
||||
|
||||
// Add member
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_ChannelMembers (ChannelID, AgentAddress, Role) VALUES (?, ?, 'member')",
|
||||
[$channelId, $agent]
|
||||
);
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
51
api/hub/channels/leave.php
Normal file
51
api/hub/channels/leave.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/channels/leave.php
|
||||
*
|
||||
* Leave a channel. Owners cannot leave (must transfer ownership or delete).
|
||||
*
|
||||
* Body:
|
||||
* ChannelID int REQUIRED
|
||||
* Agent string REQUIRED agent address leaving
|
||||
*
|
||||
* Response: { OK: true }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$channelId = (int) ($body['ChannelID'] ?? 0);
|
||||
$agent = trim($body['Agent'] ?? '');
|
||||
|
||||
if ($channelId <= 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
}
|
||||
if ($agent === '') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'agent_required']);
|
||||
}
|
||||
|
||||
// Check membership
|
||||
$membership = queryOne(
|
||||
"SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[$channelId, $agent]
|
||||
);
|
||||
|
||||
if (!$membership) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'not_a_member']);
|
||||
}
|
||||
|
||||
if ($membership['Role'] === 'owner') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'owner_cannot_leave']);
|
||||
}
|
||||
|
||||
queryTimed(
|
||||
"DELETE FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[$channelId, $agent]
|
||||
);
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
77
api/hub/channels/list.php
Normal file
77
api/hub/channels/list.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/channels/list.php
|
||||
*
|
||||
* List channels with optional filters.
|
||||
*
|
||||
* Query params:
|
||||
* ChannelType string optional filter by type (public|private|direct)
|
||||
* Agent string optional only channels this agent is a member of
|
||||
* IncludeArchived 1|0 optional include archived channels (default: 0)
|
||||
* Limit int optional max results (default: 50, max: 200)
|
||||
* Offset int optional pagination offset (default: 0)
|
||||
*
|
||||
* Response: { OK: true, Channels: [...], Total: int }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$channelType = trim($_GET['ChannelType'] ?? '');
|
||||
$agent = trim($_GET['Agent'] ?? '');
|
||||
$includeArchived = ($_GET['IncludeArchived'] ?? '0') === '1';
|
||||
$limit = min(max((int) ($_GET['Limit'] ?? 50), 1), 200);
|
||||
$offset = max((int) ($_GET['Offset'] ?? 0), 0);
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if (!$includeArchived) {
|
||||
$where[] = 'c.IsArchived = 0';
|
||||
}
|
||||
|
||||
if ($channelType !== '' && in_array($channelType, ['public', 'private', 'direct'], true)) {
|
||||
$where[] = 'c.ChannelType = ?';
|
||||
$params[] = $channelType;
|
||||
}
|
||||
|
||||
$join = '';
|
||||
if ($agent !== '') {
|
||||
$join = 'INNER JOIN Hub_ChannelMembers m ON m.ChannelID = c.ID AND m.AgentAddress = ?';
|
||||
array_unshift($params, $agent);
|
||||
}
|
||||
|
||||
$whereClause = count($where) > 0 ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
// Count total
|
||||
$countSql = "SELECT COUNT(*) AS cnt FROM Hub_Channels c $join $whereClause";
|
||||
$countRow = queryOne($countSql, $params);
|
||||
$total = (int) ($countRow['cnt'] ?? 0);
|
||||
|
||||
// Fetch page
|
||||
$dataSql = "SELECT c.*, (SELECT COUNT(*) FROM Hub_ChannelMembers WHERE ChannelID = c.ID) AS MemberCount
|
||||
FROM Hub_Channels c $join $whereClause
|
||||
ORDER BY c.CreatedAt DESC LIMIT ? OFFSET ?";
|
||||
$dataParams = array_merge($params, [$limit, $offset]);
|
||||
$rows = queryTimed($dataSql, $dataParams);
|
||||
|
||||
$channels = [];
|
||||
foreach ($rows as $r) {
|
||||
$channels[] = [
|
||||
'ID' => (int) $r['ID'],
|
||||
'Name' => $r['Name'],
|
||||
'DisplayName' => $r['DisplayName'],
|
||||
'Purpose' => $r['Purpose'],
|
||||
'ChannelType' => $r['ChannelType'],
|
||||
'CreatedBy' => $r['CreatedBy'],
|
||||
'IsArchived' => (bool) $r['IsArchived'],
|
||||
'CreatedAt' => toISO8601($r['CreatedAt']),
|
||||
'UpdatedAt' => toISO8601($r['UpdatedAt']),
|
||||
'MemberCount' => (int) $r['MemberCount'],
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse(['OK' => true, 'Channels' => $channels, 'Total' => $total]);
|
||||
54
api/hub/channels/members.php
Normal file
54
api/hub/channels/members.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/channels/members.php
|
||||
*
|
||||
* List members of a channel.
|
||||
*
|
||||
* Query params:
|
||||
* ChannelID int REQUIRED
|
||||
*
|
||||
* Response: { OK: true, Members: [...] }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$channelId = (int) ($_GET['ChannelID'] ?? 0);
|
||||
if ($channelId <= 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
}
|
||||
|
||||
// Verify channel exists
|
||||
$channel = queryOne("SELECT ID FROM Hub_Channels WHERE ID = ?", [$channelId]);
|
||||
if (!$channel) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404);
|
||||
}
|
||||
|
||||
$members = queryTimed(
|
||||
"SELECT m.AgentAddress, m.Role, m.JoinedAt, m.LastViewedAt,
|
||||
a.AgentName, a.AgentType, a.Role AS AgentRole, a.IsActive
|
||||
FROM Hub_ChannelMembers m
|
||||
LEFT JOIN Sprinter_Agents a ON a.FullAddress = m.AgentAddress
|
||||
WHERE m.ChannelID = ?
|
||||
ORDER BY m.JoinedAt ASC",
|
||||
[$channelId]
|
||||
);
|
||||
|
||||
$list = [];
|
||||
foreach ($members as $m) {
|
||||
$list[] = [
|
||||
'AgentAddress' => $m['AgentAddress'],
|
||||
'AgentName' => $m['AgentName'] ?? '',
|
||||
'AgentType' => $m['AgentType'] ?? '',
|
||||
'AgentRole' => $m['AgentRole'] ?? '',
|
||||
'IsActive' => isset($m['IsActive']) ? (bool) $m['IsActive'] : null,
|
||||
'ChannelRole' => $m['Role'],
|
||||
'JoinedAt' => toISO8601($m['JoinedAt']),
|
||||
'LastViewedAt' => $m['LastViewedAt'] ? toISO8601($m['LastViewedAt']) : null,
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse(['OK' => true, 'Members' => $list, 'Total' => count($list)]);
|
||||
32
api/hub/channels/schema.sql
Normal file
32
api/hub/channels/schema.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
-- Hub Channels Schema
|
||||
-- Part of Sprinter Hub Migration (Task #51, Sub-task #59)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Hub_Channels (
|
||||
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
Name VARCHAR(100) NOT NULL,
|
||||
DisplayName VARCHAR(200) NOT NULL DEFAULT '',
|
||||
Purpose VARCHAR(500) DEFAULT '',
|
||||
ChannelType ENUM('public','private','direct') NOT NULL DEFAULT 'public',
|
||||
CreatedBy VARCHAR(150) NOT NULL COMMENT 'Sprinter agent address',
|
||||
IsArchived TINYINT(1) NOT NULL DEFAULT 0,
|
||||
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE KEY uq_channel_name (Name),
|
||||
INDEX idx_type (ChannelType),
|
||||
INDEX idx_created_by (CreatedBy),
|
||||
INDEX idx_archived (IsArchived)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Hub_ChannelMembers (
|
||||
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
ChannelID INT UNSIGNED NOT NULL,
|
||||
AgentAddress VARCHAR(150) NOT NULL COMMENT 'Sprinter agent address',
|
||||
Role ENUM('member','admin','owner') NOT NULL DEFAULT 'member',
|
||||
JoinedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
LastViewedAt DATETIME DEFAULT NULL,
|
||||
|
||||
UNIQUE KEY uq_channel_agent (ChannelID, AgentAddress),
|
||||
INDEX idx_agent (AgentAddress),
|
||||
CONSTRAINT fk_member_channel FOREIGN KEY (ChannelID) REFERENCES Hub_Channels(ID) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
70
api/hub/channels/stats.php
Normal file
70
api/hub/channels/stats.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/channels/stats.php
|
||||
*
|
||||
* Get channel statistics: member count, message count, pinned count, unread count.
|
||||
*
|
||||
* Query params:
|
||||
* ChannelID int REQUIRED
|
||||
* AgentAddress string optional if provided, includes unread count for this agent
|
||||
*
|
||||
* Response: { OK: true, Stats: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$channelId = (int) ($_GET['ChannelID'] ?? 0);
|
||||
$agentAddress = trim($_GET['AgentAddress'] ?? '');
|
||||
|
||||
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
|
||||
$channel = queryOne("SELECT ID FROM Hub_Channels WHERE ID = ?", [$channelId]);
|
||||
if (!$channel) jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found']);
|
||||
|
||||
$memberCount = (int) (queryOne(
|
||||
"SELECT COUNT(*) as cnt FROM Hub_ChannelMembers WHERE ChannelID = ?",
|
||||
[$channelId]
|
||||
)['cnt'] ?? 0);
|
||||
|
||||
$messageCount = (int) (queryOne(
|
||||
"SELECT COUNT(*) as cnt FROM Hub_Messages WHERE ChannelID = ? AND IsDeleted = 0",
|
||||
[$channelId]
|
||||
)['cnt'] ?? 0);
|
||||
|
||||
$pinnedCount = (int) (queryOne(
|
||||
"SELECT COUNT(*) as cnt FROM Hub_PinnedPosts WHERE ChannelID = ?",
|
||||
[$channelId]
|
||||
)['cnt'] ?? 0);
|
||||
|
||||
$unreadCount = 0;
|
||||
if ($agentAddress !== '') {
|
||||
$membership = queryOne(
|
||||
"SELECT LastViewedAt FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[$channelId, $agentAddress]
|
||||
);
|
||||
if ($membership && $membership['LastViewedAt']) {
|
||||
$unreadCount = (int) (queryOne(
|
||||
"SELECT COUNT(*) as cnt FROM Hub_Messages
|
||||
WHERE ChannelID = ? AND IsDeleted = 0 AND CreatedAt > ?",
|
||||
[$channelId, $membership['LastViewedAt']]
|
||||
)['cnt'] ?? 0);
|
||||
} elseif ($membership) {
|
||||
// Never viewed — all messages are unread
|
||||
$unreadCount = $messageCount;
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Stats' => [
|
||||
'ChannelID' => $channelId,
|
||||
'MemberCount' => $memberCount,
|
||||
'MessageCount' => $messageCount,
|
||||
'PinnedCount' => $pinnedCount,
|
||||
'UnreadCount' => $unreadCount,
|
||||
],
|
||||
]);
|
||||
98
api/hub/channels/update.php
Normal file
98
api/hub/channels/update.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/channels/update.php
|
||||
*
|
||||
* Update a channel's metadata.
|
||||
*
|
||||
* Body:
|
||||
* ID int REQUIRED
|
||||
* DisplayName string optional
|
||||
* Purpose string optional
|
||||
* IsArchived bool optional
|
||||
* Agent string REQUIRED requesting agent (must be admin/owner)
|
||||
*
|
||||
* Response: { OK: true, Channel: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$id = (int) ($body['ID'] ?? 0);
|
||||
$agent = trim($body['Agent'] ?? '');
|
||||
|
||||
if ($id <= 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'id_required']);
|
||||
}
|
||||
if ($agent === '') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'agent_required']);
|
||||
}
|
||||
|
||||
// Verify channel exists
|
||||
$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$id]);
|
||||
if (!$channel) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404);
|
||||
}
|
||||
|
||||
// Check permissions: must be admin or owner
|
||||
$membership = queryOne(
|
||||
"SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[$id, $agent]
|
||||
);
|
||||
if (!$membership || !in_array($membership['Role'], ['admin', 'owner'], true)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'not_authorized'], 403);
|
||||
}
|
||||
|
||||
// Build update fields
|
||||
$sets = [];
|
||||
$params = [];
|
||||
|
||||
if (isset($body['DisplayName'])) {
|
||||
$val = trim($body['DisplayName']);
|
||||
if (strlen($val) > 200) $val = substr($val, 0, 200);
|
||||
$sets[] = 'DisplayName = ?';
|
||||
$params[] = $val;
|
||||
}
|
||||
|
||||
if (isset($body['Purpose'])) {
|
||||
$val = trim($body['Purpose']);
|
||||
if (strlen($val) > 500) $val = substr($val, 0, 500);
|
||||
$sets[] = 'Purpose = ?';
|
||||
$params[] = $val;
|
||||
}
|
||||
|
||||
if (isset($body['IsArchived'])) {
|
||||
$sets[] = 'IsArchived = ?';
|
||||
$params[] = $body['IsArchived'] ? 1 : 0;
|
||||
}
|
||||
|
||||
if (count($sets) === 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'nothing_to_update']);
|
||||
}
|
||||
|
||||
$params[] = $id;
|
||||
queryTimed("UPDATE Hub_Channels SET " . implode(', ', $sets) . " WHERE ID = ?", $params);
|
||||
|
||||
// Fetch updated
|
||||
$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$id]);
|
||||
$memberCount = queryOne("SELECT COUNT(*) AS cnt FROM Hub_ChannelMembers WHERE ChannelID = ?", [$id]);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Channel' => [
|
||||
'ID' => (int) $channel['ID'],
|
||||
'Name' => $channel['Name'],
|
||||
'DisplayName' => $channel['DisplayName'],
|
||||
'Purpose' => $channel['Purpose'],
|
||||
'ChannelType' => $channel['ChannelType'],
|
||||
'CreatedBy' => $channel['CreatedBy'],
|
||||
'IsArchived' => (bool) $channel['IsArchived'],
|
||||
'CreatedAt' => toISO8601($channel['CreatedAt']),
|
||||
'UpdatedAt' => toISO8601($channel['UpdatedAt']),
|
||||
'MemberCount' => (int) ($memberCount['cnt'] ?? 0),
|
||||
],
|
||||
]);
|
||||
45
api/hub/files/download.php
Normal file
45
api/hub/files/download.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/files/download.php
|
||||
*
|
||||
* Download a file by ID.
|
||||
*
|
||||
* Query params:
|
||||
* FileID int REQUIRED
|
||||
* Thumb int optional 1 = return thumbnail instead
|
||||
*
|
||||
* Response: binary file stream
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$fileId = (int) ($_GET['FileID'] ?? 0);
|
||||
$thumb = (int) ($_GET['Thumb'] ?? 0);
|
||||
|
||||
if ($fileId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'file_id_required']);
|
||||
|
||||
$record = queryOne("SELECT * FROM Hub_Files WHERE ID = ?", [$fileId]);
|
||||
if (!$record) jsonResponse(['OK' => false, 'ERROR' => 'file_not_found']);
|
||||
|
||||
$path = ($thumb && $record['ThumbnailPath'])
|
||||
? appRoot() . '/' . $record['ThumbnailPath']
|
||||
: appRoot() . '/' . $record['StoragePath'];
|
||||
|
||||
if (!file_exists($path)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'file_missing_from_disk']);
|
||||
}
|
||||
|
||||
$mimeType = $thumb ? 'image/jpeg' : $record['MimeType'];
|
||||
$fileName = $thumb ? 'thumb_' . $record['FileName'] : $record['FileName'];
|
||||
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Disposition: inline; filename="' . addslashes($fileName) . '"');
|
||||
header('Content-Length: ' . filesize($path));
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
|
||||
readfile($path);
|
||||
exit;
|
||||
39
api/hub/files/info.php
Normal file
39
api/hub/files/info.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/files/info.php
|
||||
*
|
||||
* Get file metadata by ID.
|
||||
*
|
||||
* Query params:
|
||||
* FileID int REQUIRED
|
||||
*
|
||||
* Response: { OK: true, File: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$fileId = (int) ($_GET['FileID'] ?? 0);
|
||||
if ($fileId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'file_id_required']);
|
||||
|
||||
$record = queryOne("SELECT * FROM Hub_Files WHERE ID = ?", [$fileId]);
|
||||
if (!$record) jsonResponse(['OK' => false, 'ERROR' => 'file_not_found']);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'File' => [
|
||||
'ID' => (int) $record['ID'],
|
||||
'MessageID' => $record['MessageID'] ? (int) $record['MessageID'] : null,
|
||||
'ChannelID' => (int) $record['ChannelID'],
|
||||
'UploaderAddress' => $record['UploaderAddress'],
|
||||
'FileName' => $record['FileName'],
|
||||
'FileSize' => (int) $record['FileSize'],
|
||||
'MimeType' => $record['MimeType'],
|
||||
'DownloadURL' => baseUrl() . '/' . $record['StoragePath'],
|
||||
'ThumbnailURL' => $record['ThumbnailPath'] ? baseUrl() . '/' . $record['ThumbnailPath'] : null,
|
||||
'CreatedAt' => toISO8601($record['CreatedAt']),
|
||||
],
|
||||
]);
|
||||
57
api/hub/files/list.php
Normal file
57
api/hub/files/list.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/files/list.php
|
||||
*
|
||||
* List files in a channel.
|
||||
*
|
||||
* Query params:
|
||||
* ChannelID int REQUIRED
|
||||
* Limit int optional default 50, max 200
|
||||
* Offset int optional default 0
|
||||
*
|
||||
* Response: { OK: true, Files: [...], Total: int }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$channelId = (int) ($_GET['ChannelID'] ?? 0);
|
||||
$limit = min(max((int) ($_GET['Limit'] ?? 50), 1), 200);
|
||||
$offset = max((int) ($_GET['Offset'] ?? 0), 0);
|
||||
|
||||
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
|
||||
$total = (int) (queryOne(
|
||||
"SELECT COUNT(*) as cnt FROM Hub_Files WHERE ChannelID = ?",
|
||||
[$channelId]
|
||||
)['cnt'] ?? 0);
|
||||
|
||||
$rows = queryTimed(
|
||||
"SELECT * FROM Hub_Files WHERE ChannelID = ? ORDER BY CreatedAt DESC LIMIT ? OFFSET ?",
|
||||
[$channelId, $limit, $offset]
|
||||
);
|
||||
|
||||
$files = [];
|
||||
foreach ($rows as $r) {
|
||||
$files[] = [
|
||||
'ID' => (int) $r['ID'],
|
||||
'MessageID' => $r['MessageID'] ? (int) $r['MessageID'] : null,
|
||||
'ChannelID' => (int) $r['ChannelID'],
|
||||
'UploaderAddress' => $r['UploaderAddress'],
|
||||
'FileName' => $r['FileName'],
|
||||
'FileSize' => (int) $r['FileSize'],
|
||||
'MimeType' => $r['MimeType'],
|
||||
'DownloadURL' => baseUrl() . '/' . $r['StoragePath'],
|
||||
'ThumbnailURL' => $r['ThumbnailPath'] ? baseUrl() . '/' . $r['ThumbnailPath'] : null,
|
||||
'CreatedAt' => toISO8601($r['CreatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Files' => $files,
|
||||
'Total' => $total,
|
||||
]);
|
||||
124
api/hub/files/upload.php
Normal file
124
api/hub/files/upload.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/files/upload.php
|
||||
*
|
||||
* Upload a file (multipart/form-data).
|
||||
*
|
||||
* Form fields:
|
||||
* ChannelID int REQUIRED
|
||||
* UploaderAddress string REQUIRED agent address
|
||||
* MessageID int optional attach to an existing message
|
||||
* file file REQUIRED the uploaded file
|
||||
*
|
||||
* Response: { OK: true, File: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$channelId = (int) ($_POST['ChannelID'] ?? 0);
|
||||
$uploaderAddress = trim($_POST['UploaderAddress'] ?? '');
|
||||
$messageId = isset($_POST['MessageID']) ? (int) $_POST['MessageID'] : null;
|
||||
|
||||
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
if ($uploaderAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'uploader_address_required']);
|
||||
|
||||
// Verify channel exists
|
||||
$channel = queryOne("SELECT ID FROM Hub_Channels WHERE ID = ?", [$channelId]);
|
||||
if (!$channel) jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found']);
|
||||
|
||||
// Check file upload
|
||||
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||
$errCode = $_FILES['file']['error'] ?? -1;
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'file_upload_failed', 'Code' => $errCode]);
|
||||
}
|
||||
|
||||
$file = $_FILES['file'];
|
||||
$maxSize = 50 * 1024 * 1024; // 50MB
|
||||
if ($file['size'] > $maxSize) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'file_too_large', 'MaxBytes' => $maxSize]);
|
||||
}
|
||||
|
||||
// Sanitize filename
|
||||
$originalName = basename($file['name']);
|
||||
$originalName = preg_replace('/[^a-zA-Z0-9._\-]/', '_', $originalName);
|
||||
if ($originalName === '' || $originalName === '.') $originalName = 'upload';
|
||||
|
||||
$mimeType = $file['type'] ?: 'application/octet-stream';
|
||||
|
||||
// Storage path: /uploads/hub/{channelId}/{uuid}_{filename}
|
||||
$uuid = generateUUID();
|
||||
$storageDir = uploadsRoot() . '/hub/' . $channelId;
|
||||
if (!is_dir($storageDir)) {
|
||||
mkdir($storageDir, 0755, true);
|
||||
}
|
||||
|
||||
$storageName = $uuid . '_' . $originalName;
|
||||
$storagePath = $storageDir . '/' . $storageName;
|
||||
$relPath = 'uploads/hub/' . $channelId . '/' . $storageName;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $storagePath)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'file_save_failed']);
|
||||
}
|
||||
|
||||
// Generate thumbnail for images
|
||||
$thumbnailRelPath = null;
|
||||
if (str_starts_with($mimeType, 'image/') && extension_loaded('gd')) {
|
||||
$thumbDir = $storageDir . '/thumbs';
|
||||
if (!is_dir($thumbDir)) mkdir($thumbDir, 0755, true);
|
||||
|
||||
$thumbName = $uuid . '_thumb.jpg';
|
||||
$thumbPath = $thumbDir . '/' . $thumbName;
|
||||
|
||||
$src = null;
|
||||
if ($mimeType === 'image/jpeg') $src = @imagecreatefromjpeg($storagePath);
|
||||
elseif ($mimeType === 'image/png') $src = @imagecreatefrompng($storagePath);
|
||||
elseif ($mimeType === 'image/gif') $src = @imagecreatefromgif($storagePath);
|
||||
elseif ($mimeType === 'image/webp') $src = @imagecreatefromwebp($storagePath);
|
||||
|
||||
if ($src) {
|
||||
$origW = imagesx($src);
|
||||
$origH = imagesy($src);
|
||||
$maxThumb = 200;
|
||||
$ratio = min($maxThumb / $origW, $maxThumb / $origH, 1);
|
||||
$newW = (int) ($origW * $ratio);
|
||||
$newH = (int) ($origH * $ratio);
|
||||
|
||||
$thumb = imagecreatetruecolor($newW, $newH);
|
||||
imagecopyresampled($thumb, $src, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
|
||||
imagejpeg($thumb, $thumbPath, 80);
|
||||
imagedestroy($src);
|
||||
imagedestroy($thumb);
|
||||
|
||||
$thumbnailRelPath = 'uploads/hub/' . $channelId . '/thumbs/' . $thumbName;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert to DB
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_Files (MessageID, ChannelID, UploaderAddress, FileName, FileSize, MimeType, StoragePath, ThumbnailPath)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[$messageId, $channelId, $uploaderAddress, $originalName, $file['size'], $mimeType, $relPath, $thumbnailRelPath]
|
||||
);
|
||||
$fileId = (int) lastInsertId();
|
||||
|
||||
$record = queryOne("SELECT * FROM Hub_Files WHERE ID = ?", [$fileId]);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'File' => [
|
||||
'ID' => (int) $record['ID'],
|
||||
'MessageID' => $record['MessageID'] ? (int) $record['MessageID'] : null,
|
||||
'ChannelID' => (int) $record['ChannelID'],
|
||||
'UploaderAddress' => $record['UploaderAddress'],
|
||||
'FileName' => $record['FileName'],
|
||||
'FileSize' => (int) $record['FileSize'],
|
||||
'MimeType' => $record['MimeType'],
|
||||
'DownloadURL' => baseUrl() . '/' . $record['StoragePath'],
|
||||
'ThumbnailURL' => $record['ThumbnailPath'] ? baseUrl() . '/' . $record['ThumbnailPath'] : null,
|
||||
'CreatedAt' => toISO8601($record['CreatedAt']),
|
||||
],
|
||||
]);
|
||||
47
api/hub/messages/delete.php
Normal file
47
api/hub/messages/delete.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/messages/delete.php
|
||||
*
|
||||
* Soft-delete a message. Only the sender or channel admin/owner can delete.
|
||||
*
|
||||
* Body:
|
||||
* MessageID int REQUIRED
|
||||
* AgentAddress string REQUIRED who is requesting the delete
|
||||
*
|
||||
* Response: { OK: true }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$messageId = (int) ($body['MessageID'] ?? 0);
|
||||
$agentAddress = trim($body['AgentAddress'] ?? '');
|
||||
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
|
||||
|
||||
$msg = queryOne("SELECT * FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0", [$messageId]);
|
||||
if (!$msg) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
|
||||
|
||||
// Check permission: sender can delete their own, admins/owners can delete any
|
||||
$allowed = ($msg['SenderAddress'] === $agentAddress);
|
||||
if (!$allowed) {
|
||||
$membership = queryOne(
|
||||
"SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[(int) $msg['ChannelID'], $agentAddress]
|
||||
);
|
||||
if ($membership && in_array($membership['Role'], ['admin', 'owner'], true)) {
|
||||
$allowed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$allowed) jsonResponse(['OK' => false, 'ERROR' => 'permission_denied']);
|
||||
|
||||
queryTimed("UPDATE Hub_Messages SET IsDeleted = 1 WHERE ID = ?", [$messageId]);
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
61
api/hub/messages/edit.php
Normal file
61
api/hub/messages/edit.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/messages/edit.php
|
||||
*
|
||||
* Edit a message. Only the original sender can edit.
|
||||
*
|
||||
* Body:
|
||||
* MessageID int REQUIRED
|
||||
* SenderAddress string REQUIRED must match original sender
|
||||
* Content string REQUIRED new content
|
||||
*
|
||||
* Response: { OK: true, Message: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$messageId = (int) ($body['MessageID'] ?? 0);
|
||||
$senderAddress = trim($body['SenderAddress'] ?? '');
|
||||
$content = trim($body['Content'] ?? '');
|
||||
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
if ($senderAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'sender_address_required']);
|
||||
if ($content === '') jsonResponse(['OK' => false, 'ERROR' => 'content_required']);
|
||||
|
||||
// Verify message exists and belongs to sender
|
||||
$msg = queryOne(
|
||||
"SELECT * FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0",
|
||||
[$messageId]
|
||||
);
|
||||
if (!$msg) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
|
||||
if ($msg['SenderAddress'] !== $senderAddress) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'not_message_owner']);
|
||||
}
|
||||
|
||||
queryTimed(
|
||||
"UPDATE Hub_Messages SET Content = ?, IsEdited = 1 WHERE ID = ?",
|
||||
[$content, $messageId]
|
||||
);
|
||||
|
||||
$updated = queryOne("SELECT * FROM Hub_Messages WHERE ID = ?", [$messageId]);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Message' => [
|
||||
'ID' => (int) $updated['ID'],
|
||||
'ChannelID' => (int) $updated['ChannelID'],
|
||||
'SenderAddress' => $updated['SenderAddress'],
|
||||
'Content' => $updated['Content'],
|
||||
'ParentID' => $updated['ParentID'] ? (int) $updated['ParentID'] : null,
|
||||
'IsEdited' => true,
|
||||
'IsDeleted' => false,
|
||||
'CreatedAt' => toISO8601($updated['CreatedAt']),
|
||||
'UpdatedAt' => toISO8601($updated['UpdatedAt']),
|
||||
],
|
||||
]);
|
||||
87
api/hub/messages/list.php
Normal file
87
api/hub/messages/list.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/messages/list.php
|
||||
*
|
||||
* Get messages for a channel, paginated, newest first.
|
||||
*
|
||||
* Query params:
|
||||
* ChannelID int REQUIRED
|
||||
* Limit int optional default 50, max 200
|
||||
* Before int optional message ID — get messages before this ID (cursor pagination)
|
||||
* After int optional message ID — get messages after this ID
|
||||
*
|
||||
* Response: { OK: true, Messages: [...], HasMore: bool }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$channelId = (int) ($_GET['ChannelID'] ?? 0);
|
||||
$limit = min(max((int) ($_GET['Limit'] ?? 50), 1), 200);
|
||||
$before = isset($_GET['Before']) ? (int) $_GET['Before'] : null;
|
||||
$after = isset($_GET['After']) ? (int) $_GET['After'] : null;
|
||||
|
||||
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
|
||||
// Verify channel exists
|
||||
$channel = queryOne("SELECT ID FROM Hub_Channels WHERE ID = ?", [$channelId]);
|
||||
if (!$channel) jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found']);
|
||||
|
||||
// Build query
|
||||
$sql = "SELECT * FROM Hub_Messages WHERE ChannelID = ? AND IsDeleted = 0 AND ParentID IS NULL";
|
||||
$params = [$channelId];
|
||||
|
||||
if ($before !== null) {
|
||||
$sql .= " AND ID < ?";
|
||||
$params[] = $before;
|
||||
}
|
||||
if ($after !== null) {
|
||||
$sql .= " AND ID > ?";
|
||||
$params[] = $after;
|
||||
}
|
||||
|
||||
if ($after !== null) {
|
||||
$sql .= " ORDER BY ID ASC LIMIT ?";
|
||||
} else {
|
||||
$sql .= " ORDER BY ID DESC LIMIT ?";
|
||||
}
|
||||
$params[] = $limit + 1; // fetch one extra to determine HasMore
|
||||
|
||||
$rows = queryTimed($sql, $params);
|
||||
$hasMore = count($rows) > $limit;
|
||||
if ($hasMore) array_pop($rows);
|
||||
|
||||
// If we used ASC (After), reverse for consistent newest-first output
|
||||
if ($after !== null) {
|
||||
$rows = array_reverse($rows);
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
foreach ($rows as $row) {
|
||||
// Get reply count for each root message
|
||||
$replyCount = queryOne(
|
||||
"SELECT COUNT(*) as cnt FROM Hub_Messages WHERE ParentID = ? AND IsDeleted = 0",
|
||||
[(int) $row['ID']]
|
||||
);
|
||||
|
||||
$messages[] = [
|
||||
'ID' => (int) $row['ID'],
|
||||
'ChannelID' => (int) $row['ChannelID'],
|
||||
'SenderAddress' => $row['SenderAddress'],
|
||||
'Content' => $row['Content'],
|
||||
'ParentID' => null,
|
||||
'IsEdited' => (bool) $row['IsEdited'],
|
||||
'ReplyCount' => (int) ($replyCount['cnt'] ?? 0),
|
||||
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||
'UpdatedAt' => toISO8601($row['UpdatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Messages' => $messages,
|
||||
'HasMore' => $hasMore,
|
||||
]);
|
||||
79
api/hub/messages/search.php
Normal file
79
api/hub/messages/search.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/messages/search.php
|
||||
*
|
||||
* Search messages across channels or within a specific channel.
|
||||
*
|
||||
* Query params:
|
||||
* Query string REQUIRED search term
|
||||
* ChannelID int optional limit to a specific channel
|
||||
* Limit int optional default 25, max 100
|
||||
* Offset int optional default 0
|
||||
*
|
||||
* Response: { OK: true, Messages: [...], Total: int }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$query = trim($_GET['Query'] ?? '');
|
||||
$channelId = isset($_GET['ChannelID']) ? (int) $_GET['ChannelID'] : null;
|
||||
$limit = min(max((int) ($_GET['Limit'] ?? 25), 1), 100);
|
||||
$offset = max((int) ($_GET['Offset'] ?? 0), 0);
|
||||
|
||||
if ($query === '') jsonResponse(['OK' => false, 'ERROR' => 'query_required']);
|
||||
|
||||
$searchTerm = '%' . $query . '%';
|
||||
|
||||
// Count total
|
||||
$countSql = "SELECT COUNT(*) as cnt FROM Hub_Messages WHERE Content LIKE ? AND IsDeleted = 0";
|
||||
$countParams = [$searchTerm];
|
||||
|
||||
if ($channelId !== null) {
|
||||
$countSql .= " AND ChannelID = ?";
|
||||
$countParams[] = $channelId;
|
||||
}
|
||||
|
||||
$total = (int) (queryOne($countSql, $countParams)['cnt'] ?? 0);
|
||||
|
||||
// Fetch results
|
||||
$sql = "SELECT m.*, c.Name AS ChannelName, c.DisplayName AS ChannelDisplayName
|
||||
FROM Hub_Messages m
|
||||
JOIN Hub_Channels c ON c.ID = m.ChannelID
|
||||
WHERE m.Content LIKE ? AND m.IsDeleted = 0";
|
||||
$params = [$searchTerm];
|
||||
|
||||
if ($channelId !== null) {
|
||||
$sql .= " AND m.ChannelID = ?";
|
||||
$params[] = $channelId;
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY m.CreatedAt DESC LIMIT ? OFFSET ?";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$rows = queryTimed($sql, $params);
|
||||
|
||||
$messages = [];
|
||||
foreach ($rows as $row) {
|
||||
$messages[] = [
|
||||
'ID' => (int) $row['ID'],
|
||||
'ChannelID' => (int) $row['ChannelID'],
|
||||
'ChannelName' => $row['ChannelName'],
|
||||
'ChannelDisplayName' => $row['ChannelDisplayName'],
|
||||
'SenderAddress' => $row['SenderAddress'],
|
||||
'Content' => $row['Content'],
|
||||
'ParentID' => $row['ParentID'] ? (int) $row['ParentID'] : null,
|
||||
'IsEdited' => (bool) $row['IsEdited'],
|
||||
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Messages' => $messages,
|
||||
'Total' => $total,
|
||||
]);
|
||||
82
api/hub/messages/send.php
Normal file
82
api/hub/messages/send.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/messages/send.php
|
||||
*
|
||||
* Send a message to a channel.
|
||||
*
|
||||
* Body:
|
||||
* ChannelID int REQUIRED
|
||||
* SenderAddress string REQUIRED agent address
|
||||
* Content string REQUIRED message text
|
||||
* ParentID int optional for threaded replies
|
||||
*
|
||||
* Response: { OK: true, Message: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$channelId = (int) ($body['ChannelID'] ?? 0);
|
||||
$senderAddress = trim($body['SenderAddress'] ?? '');
|
||||
$content = trim($body['Content'] ?? '');
|
||||
$parentId = isset($body['ParentID']) ? (int) $body['ParentID'] : null;
|
||||
|
||||
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
if ($senderAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'sender_address_required']);
|
||||
if ($content === '') jsonResponse(['OK' => false, 'ERROR' => 'content_required']);
|
||||
|
||||
// Verify channel exists and is not archived
|
||||
$channel = queryOne("SELECT ID, IsArchived FROM Hub_Channels WHERE ID = ?", [$channelId]);
|
||||
if (!$channel) jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found']);
|
||||
if ((bool) $channel['IsArchived']) jsonResponse(['OK' => false, 'ERROR' => 'channel_archived']);
|
||||
|
||||
// Verify sender is a member of the channel
|
||||
$member = queryOne(
|
||||
"SELECT ID FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[$channelId, $senderAddress]
|
||||
);
|
||||
if (!$member) jsonResponse(['OK' => false, 'ERROR' => 'not_a_member']);
|
||||
|
||||
// Verify parent message exists and belongs to same channel (if threaded)
|
||||
if ($parentId !== null) {
|
||||
$parent = queryOne(
|
||||
"SELECT ID FROM Hub_Messages WHERE ID = ? AND ChannelID = ? AND IsDeleted = 0",
|
||||
[$parentId, $channelId]
|
||||
);
|
||||
if (!$parent) jsonResponse(['OK' => false, 'ERROR' => 'parent_message_not_found']);
|
||||
}
|
||||
|
||||
// Insert message
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_Messages (ChannelID, SenderAddress, Content, ParentID) VALUES (?, ?, ?, ?)",
|
||||
[$channelId, $senderAddress, $content, $parentId]
|
||||
);
|
||||
$messageId = (int) lastInsertId();
|
||||
|
||||
// Update member's last viewed
|
||||
queryTimed(
|
||||
"UPDATE Hub_ChannelMembers SET LastViewedAt = NOW() WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[$channelId, $senderAddress]
|
||||
);
|
||||
|
||||
$msg = queryOne("SELECT * FROM Hub_Messages WHERE ID = ?", [$messageId]);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Message' => [
|
||||
'ID' => (int) $msg['ID'],
|
||||
'ChannelID' => (int) $msg['ChannelID'],
|
||||
'SenderAddress' => $msg['SenderAddress'],
|
||||
'Content' => $msg['Content'],
|
||||
'ParentID' => $msg['ParentID'] ? (int) $msg['ParentID'] : null,
|
||||
'IsEdited' => (bool) $msg['IsEdited'],
|
||||
'IsDeleted' => false,
|
||||
'CreatedAt' => toISO8601($msg['CreatedAt']),
|
||||
'UpdatedAt' => toISO8601($msg['UpdatedAt']),
|
||||
],
|
||||
]);
|
||||
48
api/hub/messages/thread.php
Normal file
48
api/hub/messages/thread.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/messages/thread.php
|
||||
*
|
||||
* Get all replies in a thread (by ParentID), plus the root message.
|
||||
*
|
||||
* Query params:
|
||||
* MessageID int REQUIRED the root message ID
|
||||
*
|
||||
* Response: { OK: true, RootMessage: {...}, Replies: [...] }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$messageId = (int) ($_GET['MessageID'] ?? 0);
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
|
||||
$root = queryOne("SELECT * FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0", [$messageId]);
|
||||
if (!$root) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
|
||||
|
||||
$replies = queryTimed(
|
||||
"SELECT * FROM Hub_Messages WHERE ParentID = ? AND IsDeleted = 0 ORDER BY CreatedAt ASC",
|
||||
[$messageId]
|
||||
);
|
||||
|
||||
$formatMsg = function (array $row): array {
|
||||
return [
|
||||
'ID' => (int) $row['ID'],
|
||||
'ChannelID' => (int) $row['ChannelID'],
|
||||
'SenderAddress' => $row['SenderAddress'],
|
||||
'Content' => $row['Content'],
|
||||
'ParentID' => $row['ParentID'] ? (int) $row['ParentID'] : null,
|
||||
'IsEdited' => (bool) $row['IsEdited'],
|
||||
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||
'UpdatedAt' => toISO8601($row['UpdatedAt']),
|
||||
];
|
||||
};
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'RootMessage' => $formatMsg($root),
|
||||
'Replies' => array_map($formatMsg, $replies),
|
||||
'ReplyCount' => count($replies),
|
||||
]);
|
||||
48
api/hub/pins/list.php
Normal file
48
api/hub/pins/list.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/pins/list.php
|
||||
*
|
||||
* Get all pinned messages in a channel.
|
||||
*
|
||||
* Query params:
|
||||
* ChannelID int REQUIRED
|
||||
*
|
||||
* Response: { OK: true, PinnedPosts: [...] }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$channelId = (int) ($_GET['ChannelID'] ?? 0);
|
||||
if ($channelId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
|
||||
|
||||
$rows = queryTimed(
|
||||
"SELECT p.*, m.SenderAddress, m.Content, m.CreatedAt AS MessageCreatedAt, m.IsEdited
|
||||
FROM Hub_PinnedPosts p
|
||||
JOIN Hub_Messages m ON m.ID = p.MessageID
|
||||
WHERE p.ChannelID = ? AND m.IsDeleted = 0
|
||||
ORDER BY p.CreatedAt DESC",
|
||||
[$channelId]
|
||||
);
|
||||
|
||||
$pins = [];
|
||||
foreach ($rows as $r) {
|
||||
$pins[] = [
|
||||
'PinID' => (int) $r['ID'],
|
||||
'MessageID' => (int) $r['MessageID'],
|
||||
'ChannelID' => (int) $r['ChannelID'],
|
||||
'PinnedBy' => $r['PinnedBy'],
|
||||
'PinnedAt' => toISO8601($r['CreatedAt']),
|
||||
'Message' => [
|
||||
'SenderAddress' => $r['SenderAddress'],
|
||||
'Content' => $r['Content'],
|
||||
'IsEdited' => (bool) $r['IsEdited'],
|
||||
'CreatedAt' => toISO8601($r['MessageCreatedAt']),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse(['OK' => true, 'PinnedPosts' => $pins]);
|
||||
53
api/hub/pins/pin.php
Normal file
53
api/hub/pins/pin.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/pins/pin.php
|
||||
*
|
||||
* Pin a message in a channel.
|
||||
*
|
||||
* Body:
|
||||
* MessageID int REQUIRED
|
||||
* AgentAddress string REQUIRED who is pinning
|
||||
*
|
||||
* Response: { OK: true }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$messageId = (int) ($body['MessageID'] ?? 0);
|
||||
$agentAddress = trim($body['AgentAddress'] ?? '');
|
||||
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
|
||||
|
||||
// Verify message exists
|
||||
$msg = queryOne("SELECT ID, ChannelID FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0", [$messageId]);
|
||||
if (!$msg) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
|
||||
|
||||
$channelId = (int) $msg['ChannelID'];
|
||||
|
||||
// Verify agent is a member with admin/owner role
|
||||
$membership = queryOne(
|
||||
"SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
|
||||
[$channelId, $agentAddress]
|
||||
);
|
||||
if (!$membership) jsonResponse(['OK' => false, 'ERROR' => 'not_a_member']);
|
||||
if (!in_array($membership['Role'], ['admin', 'owner'], true)) {
|
||||
// Allow any member to pin for now — can restrict later
|
||||
}
|
||||
|
||||
// Check if already pinned
|
||||
$existing = queryOne("SELECT ID FROM Hub_PinnedPosts WHERE MessageID = ?", [$messageId]);
|
||||
if ($existing) jsonResponse(['OK' => false, 'ERROR' => 'already_pinned']);
|
||||
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_PinnedPosts (MessageID, ChannelID, PinnedBy) VALUES (?, ?, ?)",
|
||||
[$messageId, $channelId, $agentAddress]
|
||||
);
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
34
api/hub/pins/unpin.php
Normal file
34
api/hub/pins/unpin.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/pins/unpin.php
|
||||
*
|
||||
* Unpin a message.
|
||||
*
|
||||
* Body:
|
||||
* MessageID int REQUIRED
|
||||
* AgentAddress string REQUIRED
|
||||
*
|
||||
* Response: { OK: true }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$messageId = (int) ($body['MessageID'] ?? 0);
|
||||
$agentAddress = trim($body['AgentAddress'] ?? '');
|
||||
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
|
||||
|
||||
$stmt = queryTimed("DELETE FROM Hub_PinnedPosts WHERE MessageID = ?", [$messageId]);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'not_pinned']);
|
||||
}
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
67
api/hub/reactions/add.php
Normal file
67
api/hub/reactions/add.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/reactions/add.php
|
||||
*
|
||||
* Add a reaction to a message.
|
||||
*
|
||||
* Body:
|
||||
* MessageID int REQUIRED
|
||||
* AgentAddress string REQUIRED
|
||||
* EmojiName string REQUIRED e.g. "+1", "heart", "fire"
|
||||
*
|
||||
* Response: { OK: true, Reaction: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$messageId = (int) ($body['MessageID'] ?? 0);
|
||||
$agentAddress = trim($body['AgentAddress'] ?? '');
|
||||
$emojiName = trim($body['EmojiName'] ?? '');
|
||||
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
|
||||
if ($emojiName === '') jsonResponse(['OK' => false, 'ERROR' => 'emoji_name_required']);
|
||||
|
||||
// Sanitize emoji name
|
||||
$emojiName = preg_replace('/[^a-zA-Z0-9_\-+]/', '', $emojiName);
|
||||
if ($emojiName === '' || strlen($emojiName) > 50) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invalid_emoji_name']);
|
||||
}
|
||||
|
||||
// Verify message exists
|
||||
$msg = queryOne("SELECT ID, ChannelID FROM Hub_Messages WHERE ID = ? AND IsDeleted = 0", [$messageId]);
|
||||
if (!$msg) jsonResponse(['OK' => false, 'ERROR' => 'message_not_found']);
|
||||
|
||||
// Check if already reacted with this emoji
|
||||
$existing = queryOne(
|
||||
"SELECT ID FROM Hub_Reactions WHERE MessageID = ? AND AgentAddress = ? AND EmojiName = ?",
|
||||
[$messageId, $agentAddress, $emojiName]
|
||||
);
|
||||
if ($existing) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'already_reacted']);
|
||||
}
|
||||
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_Reactions (MessageID, AgentAddress, EmojiName) VALUES (?, ?, ?)",
|
||||
[$messageId, $agentAddress, $emojiName]
|
||||
);
|
||||
$reactionId = (int) lastInsertId();
|
||||
|
||||
$reaction = queryOne("SELECT * FROM Hub_Reactions WHERE ID = ?", [$reactionId]);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Reaction' => [
|
||||
'ID' => (int) $reaction['ID'],
|
||||
'MessageID' => (int) $reaction['MessageID'],
|
||||
'AgentAddress' => $reaction['AgentAddress'],
|
||||
'EmojiName' => $reaction['EmojiName'],
|
||||
'CreatedAt' => toISO8601($reaction['CreatedAt']),
|
||||
],
|
||||
]);
|
||||
41
api/hub/reactions/list.php
Normal file
41
api/hub/reactions/list.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/reactions/list.php
|
||||
*
|
||||
* Get all reactions for a message, grouped by emoji.
|
||||
*
|
||||
* Query params:
|
||||
* MessageID int REQUIRED
|
||||
*
|
||||
* Response: { OK: true, Reactions: [ { EmojiName: "...", Count: N, Agents: [...] } ] }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$messageId = (int) ($_GET['MessageID'] ?? 0);
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
|
||||
$rows = queryTimed(
|
||||
"SELECT * FROM Hub_Reactions WHERE MessageID = ? ORDER BY EmojiName, CreatedAt",
|
||||
[$messageId]
|
||||
);
|
||||
|
||||
// Group by emoji
|
||||
$grouped = [];
|
||||
foreach ($rows as $r) {
|
||||
$emoji = $r['EmojiName'];
|
||||
if (!isset($grouped[$emoji])) {
|
||||
$grouped[$emoji] = ['EmojiName' => $emoji, 'Count' => 0, 'Agents' => []];
|
||||
}
|
||||
$grouped[$emoji]['Count']++;
|
||||
$grouped[$emoji]['Agents'][] = $r['AgentAddress'];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Reactions' => array_values($grouped),
|
||||
]);
|
||||
40
api/hub/reactions/remove.php
Normal file
40
api/hub/reactions/remove.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/reactions/remove.php
|
||||
*
|
||||
* Remove a reaction from a message.
|
||||
*
|
||||
* Body:
|
||||
* MessageID int REQUIRED
|
||||
* AgentAddress string REQUIRED
|
||||
* EmojiName string REQUIRED
|
||||
*
|
||||
* Response: { OK: true }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$messageId = (int) ($body['MessageID'] ?? 0);
|
||||
$agentAddress = trim($body['AgentAddress'] ?? '');
|
||||
$emojiName = trim($body['EmojiName'] ?? '');
|
||||
|
||||
if ($messageId <= 0) jsonResponse(['OK' => false, 'ERROR' => 'message_id_required']);
|
||||
if ($agentAddress === '') jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required']);
|
||||
if ($emojiName === '') jsonResponse(['OK' => false, 'ERROR' => 'emoji_name_required']);
|
||||
|
||||
$stmt = queryTimed(
|
||||
"DELETE FROM Hub_Reactions WHERE MessageID = ? AND AgentAddress = ? AND EmojiName = ?",
|
||||
[$messageId, $agentAddress, $emojiName]
|
||||
);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'reaction_not_found']);
|
||||
}
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
50
api/hub/users/get.php
Normal file
50
api/hub/users/get.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/users/get.php
|
||||
*
|
||||
* Get a single agent/user by address or ID.
|
||||
*
|
||||
* Query params:
|
||||
* Address string agent address (e.g. sprinter.payfrit.mike)
|
||||
* ID int agent ID
|
||||
* (provide one or the other)
|
||||
*
|
||||
* Response: { OK: true, User: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$address = trim($_GET['Address'] ?? '');
|
||||
$id = (int) ($_GET['ID'] ?? 0);
|
||||
|
||||
if ($address === '' && $id <= 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'address_or_id_required']);
|
||||
}
|
||||
|
||||
$agent = null;
|
||||
if ($address !== '') {
|
||||
$agent = queryOne("SELECT * FROM Sprinter_Agents WHERE FullAddress = ?", [$address]);
|
||||
} else {
|
||||
$agent = queryOne("SELECT * FROM Sprinter_Agents WHERE ID = ?", [$id]);
|
||||
}
|
||||
|
||||
if (!$agent) jsonResponse(['OK' => false, 'ERROR' => 'user_not_found']);
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'User' => [
|
||||
'ID' => (int) $agent['ID'],
|
||||
'AgentName' => $agent['AgentName'],
|
||||
'FullAddress' => $agent['FullAddress'],
|
||||
'ProjectName' => $agent['ProjectName'],
|
||||
'AgentType' => $agent['AgentType'],
|
||||
'Role' => $agent['Role'],
|
||||
'ServerHost' => $agent['ServerHost'],
|
||||
'IsActive' => (bool) $agent['IsActive'],
|
||||
'CreatedAt' => toISO8601($agent['CreatedAt']),
|
||||
],
|
||||
]);
|
||||
62
api/hub/users/getByIds.php
Normal file
62
api/hub/users/getByIds.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/users/getByIds.php
|
||||
*
|
||||
* Get multiple agents by their IDs or addresses.
|
||||
*
|
||||
* Body:
|
||||
* IDs int[] optional list of agent IDs
|
||||
* Addresses string[] optional list of agent addresses
|
||||
* (provide one or both)
|
||||
*
|
||||
* Response: { OK: true, Users: [...] }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
$ids = $body['IDs'] ?? [];
|
||||
$addresses = $body['Addresses'] ?? [];
|
||||
|
||||
if (empty($ids) && empty($addresses)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'ids_or_addresses_required']);
|
||||
}
|
||||
|
||||
// Cap at 100
|
||||
$ids = array_slice(array_map('intval', $ids), 0, 100);
|
||||
$addresses = array_slice(array_map('trim', $addresses), 0, 100);
|
||||
|
||||
$users = [];
|
||||
|
||||
if (!empty($ids)) {
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$rows = queryTimed("SELECT * FROM Sprinter_Agents WHERE ID IN ($placeholders)", $ids);
|
||||
foreach ($rows as $a) $users[(int) $a['ID']] = $a;
|
||||
}
|
||||
|
||||
if (!empty($addresses)) {
|
||||
$placeholders = implode(',', array_fill(0, count($addresses), '?'));
|
||||
$rows = queryTimed("SELECT * FROM Sprinter_Agents WHERE FullAddress IN ($placeholders)", $addresses);
|
||||
foreach ($rows as $a) $users[(int) $a['ID']] = $a;
|
||||
}
|
||||
|
||||
$formatted = [];
|
||||
foreach ($users as $a) {
|
||||
$formatted[] = [
|
||||
'ID' => (int) $a['ID'],
|
||||
'AgentName' => $a['AgentName'],
|
||||
'FullAddress' => $a['FullAddress'],
|
||||
'ProjectName' => $a['ProjectName'],
|
||||
'AgentType' => $a['AgentType'],
|
||||
'Role' => $a['Role'],
|
||||
'ServerHost' => $a['ServerHost'],
|
||||
'IsActive' => (bool) $a['IsActive'],
|
||||
'CreatedAt' => toISO8601($a['CreatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse(['OK' => true, 'Users' => $formatted]);
|
||||
55
api/hub/users/search.php
Normal file
55
api/hub/users/search.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/users/search.php
|
||||
*
|
||||
* Search agents by name or address.
|
||||
*
|
||||
* Query params:
|
||||
* Query string REQUIRED search term
|
||||
* Project string optional filter by project name
|
||||
* Limit int optional default 25, max 100
|
||||
*
|
||||
* Response: { OK: true, Users: [...] }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$query = trim($_GET['Query'] ?? '');
|
||||
$project = trim($_GET['Project'] ?? '');
|
||||
$limit = min(max((int) ($_GET['Limit'] ?? 25), 1), 100);
|
||||
|
||||
if ($query === '') jsonResponse(['OK' => false, 'ERROR' => 'query_required']);
|
||||
|
||||
$searchTerm = '%' . $query . '%';
|
||||
$sql = "SELECT * FROM Sprinter_Agents WHERE (AgentName LIKE ? OR FullAddress LIKE ? OR Role LIKE ?) AND IsActive = 1";
|
||||
$params = [$searchTerm, $searchTerm, $searchTerm];
|
||||
|
||||
if ($project !== '') {
|
||||
$sql .= " AND ProjectName = ?";
|
||||
$params[] = $project;
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY AgentName ASC LIMIT ?";
|
||||
$params[] = $limit;
|
||||
|
||||
$rows = queryTimed($sql, $params);
|
||||
|
||||
$users = [];
|
||||
foreach ($rows as $a) {
|
||||
$users[] = [
|
||||
'ID' => (int) $a['ID'],
|
||||
'AgentName' => $a['AgentName'],
|
||||
'FullAddress' => $a['FullAddress'],
|
||||
'ProjectName' => $a['ProjectName'],
|
||||
'AgentType' => $a['AgentType'],
|
||||
'Role' => $a['Role'],
|
||||
'IsActive' => true,
|
||||
'CreatedAt' => toISO8601($a['CreatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse(['OK' => true, 'Users' => $users]);
|
||||
63
api/hub/users/status.php
Normal file
63
api/hub/users/status.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/users/status.php
|
||||
*
|
||||
* Get an agent's online/activity status based on channel membership activity.
|
||||
*
|
||||
* Query params:
|
||||
* Address string REQUIRED agent address
|
||||
*
|
||||
* Response: { OK: true, Status: { ... } }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$address = trim($_GET['Address'] ?? '');
|
||||
if ($address === '') jsonResponse(['OK' => false, 'ERROR' => 'address_required']);
|
||||
|
||||
$agent = queryOne("SELECT * FROM Sprinter_Agents WHERE FullAddress = ?", [$address]);
|
||||
if (!$agent) jsonResponse(['OK' => false, 'ERROR' => 'user_not_found']);
|
||||
|
||||
// Get last activity from channel memberships
|
||||
$lastActivity = queryOne(
|
||||
"SELECT MAX(LastViewedAt) as LastSeen FROM Hub_ChannelMembers WHERE AgentAddress = ?",
|
||||
[$address]
|
||||
);
|
||||
|
||||
// Get last message sent
|
||||
$lastMessage = queryOne(
|
||||
"SELECT MAX(CreatedAt) as LastMessageAt FROM Hub_Messages WHERE SenderAddress = ? AND IsDeleted = 0",
|
||||
[$address]
|
||||
);
|
||||
|
||||
$lastSeen = $lastActivity['LastSeen'] ?? null;
|
||||
$lastMessageAt = $lastMessage['LastMessageAt'] ?? null;
|
||||
|
||||
// Use the most recent of the two
|
||||
$latestActivity = null;
|
||||
if ($lastSeen && $lastMessageAt) {
|
||||
$latestActivity = max($lastSeen, $lastMessageAt);
|
||||
} else {
|
||||
$latestActivity = $lastSeen ?: $lastMessageAt;
|
||||
}
|
||||
|
||||
// Consider "online" if active in last 5 minutes
|
||||
$isOnline = false;
|
||||
if ($latestActivity) {
|
||||
$diff = time() - strtotime($latestActivity);
|
||||
$isOnline = ($diff < 300);
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Status' => [
|
||||
'Address' => $address,
|
||||
'IsOnline' => $isOnline,
|
||||
'LastActivityAt' => $latestActivity ? toISO8601($latestActivity) : null,
|
||||
'IsActive' => (bool) $agent['IsActive'],
|
||||
],
|
||||
]);
|
||||
337
api/hub/vcgateway/API-CONTRACT.md
Normal file
337
api/hub/vcgateway/API-CONTRACT.md
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
# VC Gateway — API Contract
|
||||
|
||||
**Module:** `/api/hub/vcgateway/`
|
||||
**Owner:** @mike (backend), @netasha (frontend)
|
||||
**Status:** Implemented on dev (2026-03-27)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The VC Gateway enables anonymous visitors (e.g. investors) to:
|
||||
1. Authenticate via a shareable invite link (no account needed)
|
||||
2. Read messages from scoped channels (read-only)
|
||||
3. Send DMs to the host (rate-limited)
|
||||
|
||||
Two auth models:
|
||||
- **Agent auth** (`X-Agent-Address` header) — for team members managing invite links
|
||||
- **Visitor auth** (`X-Visitor-Token` header) — for anonymous visitors using the gateway
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Invite Management (Agent Auth)
|
||||
|
||||
#### `POST /api/hub/vcgateway/invites/create.php`
|
||||
|
||||
Create an invite link.
|
||||
|
||||
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"AllowedChannels": [1, 5, 12],
|
||||
"HostAddress": "sprinter.payfrit.john",
|
||||
"Label": "Sequoia - Partner X",
|
||||
"ExpiresAt": "2026-04-15T00:00:00Z",
|
||||
"MaxUses": 3
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| AllowedChannels | int[] | ✅ | Hub channel IDs visitor can read |
|
||||
| HostAddress | string | ✅ | Sprinter agent address for DM target |
|
||||
| Label | string | ❌ | Human label (max 255 chars) |
|
||||
| ExpiresAt | string | ❌ | ISO8601 datetime; null = never |
|
||||
| MaxUses | int | ❌ | 0 = unlimited (default) |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"OK": true,
|
||||
"ID": 1,
|
||||
"Token": "a1b2c3d4e5f6...",
|
||||
"InviteURL": "https://dev.payfrit.com/vc/a1b2c3d4e5f6..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `POST /api/hub/vcgateway/invites/revoke.php`
|
||||
|
||||
Revoke an invite link. All visitor sessions become invalid.
|
||||
|
||||
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{ "ID": 1 }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "OK": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `GET /api/hub/vcgateway/invites/list.php`
|
||||
|
||||
List invite links with optional status filter.
|
||||
|
||||
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Type | Default | Options |
|
||||
|-------|------|---------|---------|
|
||||
| Status | string | all | `active`, `revoked`, `expired`, `all` |
|
||||
| Limit | int | 50 | max 200 |
|
||||
| Offset | int | 0 | |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"OK": true,
|
||||
"Links": [
|
||||
{
|
||||
"ID": 1,
|
||||
"Token": "a1b2c3...",
|
||||
"Label": "Sequoia - Partner X",
|
||||
"AllowedChannels": [1, 5, 12],
|
||||
"HostAddress": "sprinter.payfrit.john",
|
||||
"ExpiresAt": "2026-04-15T00:00:00Z",
|
||||
"MaxUses": 3,
|
||||
"UseCount": 1,
|
||||
"VisitorCount": 1,
|
||||
"Status": "active",
|
||||
"CreatedBy": "sprinter.payfrit.john",
|
||||
"CreatedAt": "2026-03-27T20:00:00Z"
|
||||
}
|
||||
],
|
||||
"Total": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Status values:** `active`, `revoked`, `expired`, `exhausted`
|
||||
|
||||
---
|
||||
|
||||
#### `GET /api/hub/vcgateway/invites/get.php`
|
||||
|
||||
Get a single invite link with its visitors.
|
||||
|
||||
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
|
||||
|
||||
**Query params:** `ID=1` or `Token=a1b2c3...`
|
||||
|
||||
**Response:** Same as list item, plus a `Visitors` array:
|
||||
```json
|
||||
{
|
||||
"OK": true,
|
||||
"Link": {
|
||||
"...": "same fields as list",
|
||||
"Visitors": [
|
||||
{
|
||||
"ID": 1,
|
||||
"DisplayName": "Alex Chen",
|
||||
"CreatedAt": "2026-03-27T21:00:00Z",
|
||||
"LastActiveAt": "2026-03-27T21:15:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Visitor Endpoints (Visitor Auth)
|
||||
|
||||
#### `POST /api/hub/vcgateway/visitor/auth.php`
|
||||
|
||||
Authenticate as a visitor. No auth header needed — uses the invite token.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"InviteToken": "a1b2c3d4e5f6...",
|
||||
"DisplayName": "Alex Chen"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| InviteToken | string | ✅ | Token from invite URL |
|
||||
| DisplayName | string | ❌ | Default: "Visitor" (max 100 chars) |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"OK": true,
|
||||
"VisitorToken": "x9y8z7...",
|
||||
"VisitorID": 1,
|
||||
"DisplayName": "Alex Chen",
|
||||
"AllowedChannels": [
|
||||
{
|
||||
"ID": 1,
|
||||
"Name": "general",
|
||||
"DisplayName": "General",
|
||||
"Purpose": "Team discussion",
|
||||
"ChannelType": "public"
|
||||
}
|
||||
],
|
||||
"HostAddress": "sprinter.payfrit.john"
|
||||
}
|
||||
```
|
||||
|
||||
**Save `VisitorToken` — use it as `X-Visitor-Token` header for all subsequent calls.**
|
||||
|
||||
**Error codes:** `invalid_invite_token`, `invite_revoked`, `invite_expired`, `invite_exhausted`
|
||||
|
||||
---
|
||||
|
||||
#### `GET /api/hub/vcgateway/visitor/feed.php`
|
||||
|
||||
Read-only message feed for an allowed channel.
|
||||
|
||||
**Headers:** `X-Visitor-Token: x9y8z7...`
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| ChannelID | int | ✅ | Must be in visitor's AllowedChannels |
|
||||
| Before | int | ❌ | Cursor: messages before this ID |
|
||||
| After | int | ❌ | Cursor: messages after this ID |
|
||||
| Limit | int | ❌ | Default 50, max 100 |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"OK": true,
|
||||
"Channel": {
|
||||
"ID": 1,
|
||||
"Name": "general",
|
||||
"DisplayName": "General",
|
||||
"Purpose": "Team discussion"
|
||||
},
|
||||
"Messages": [
|
||||
{
|
||||
"ID": 100,
|
||||
"SenderAddress": "sprinter.payfrit.john",
|
||||
"SenderName": "john",
|
||||
"Content": "Let's discuss the roadmap",
|
||||
"ParentID": null,
|
||||
"IsEdited": false,
|
||||
"CreatedAt": "2026-03-27T20:30:00Z"
|
||||
}
|
||||
],
|
||||
"HasMore": true
|
||||
}
|
||||
```
|
||||
|
||||
**Error codes:** `channel_id_required`, `channel_not_allowed`, `channel_not_found`
|
||||
|
||||
---
|
||||
|
||||
### DM Endpoints (Visitor Auth)
|
||||
|
||||
#### `POST /api/hub/vcgateway/dm/send.php`
|
||||
|
||||
Send a DM from visitor to host. Rate-limited (10 messages per 5 minutes).
|
||||
|
||||
**Headers:** `X-Visitor-Token: x9y8z7...`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{ "Content": "Hi, I had a question about your Series A terms." }
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| Content | string | ✅ | Max 4000 chars |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"OK": true,
|
||||
"MessageID": 201,
|
||||
"DMChannelID": 15,
|
||||
"CreatedAt": "2026-03-27T21:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Rate limit error (HTTP 429):**
|
||||
```json
|
||||
{
|
||||
"OK": false,
|
||||
"ERROR": "rate_limit_exceeded",
|
||||
"Limit": 10,
|
||||
"WindowSeconds": 300,
|
||||
"ResetsAt": "2026-03-27T21:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
The DM channel is auto-created on first message. The host sees DMs in their normal Hub message list (channel type: `direct`).
|
||||
|
||||
---
|
||||
|
||||
#### `GET /api/hub/vcgateway/dm/messages.php`
|
||||
|
||||
List messages in the visitor's DM channel.
|
||||
|
||||
**Headers:** `X-Visitor-Token: x9y8z7...`
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| Before | int | ❌ | Cursor: messages before this ID |
|
||||
| After | int | ❌ | Cursor: messages after this ID |
|
||||
| Limit | int | ❌ | Default 50, max 100 |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"OK": true,
|
||||
"Messages": [
|
||||
{
|
||||
"ID": 201,
|
||||
"SenderAddress": "visitor:1:Alex Chen",
|
||||
"SenderName": "Alex Chen",
|
||||
"IsVisitor": true,
|
||||
"Content": "Hi, I had a question about your Series A terms.",
|
||||
"IsEdited": false,
|
||||
"CreatedAt": "2026-03-27T21:00:00Z"
|
||||
},
|
||||
{
|
||||
"ID": 205,
|
||||
"SenderAddress": "sprinter.payfrit.john",
|
||||
"SenderName": "john",
|
||||
"IsVisitor": false,
|
||||
"Content": "Sure, happy to discuss. What are you thinking?",
|
||||
"IsEdited": false,
|
||||
"CreatedAt": "2026-03-27T21:02:00Z"
|
||||
}
|
||||
],
|
||||
"DMChannelID": 15,
|
||||
"HasMore": false
|
||||
}
|
||||
```
|
||||
|
||||
Returns empty `Messages` array if no DM channel exists yet.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **Tables:** `Hub_InviteLinks`, `Hub_Visitors`, `Hub_VisitorRateLimit`
|
||||
- **DM channels** use existing `Hub_Channels` (type: `direct`) and `Hub_Messages`
|
||||
- **Rate limiting:** 10 messages per 5-minute sliding window, stored in `Hub_VisitorRateLimit`
|
||||
- **Visitor sender format:** `visitor:{id}:{displayName}` in `SenderAddress`
|
||||
- **Host replies** use normal Hub message send (existing endpoint), targeting the DM channel
|
||||
- **Revoking a link** invalidates all visitor sessions derived from it
|
||||
- **Schema:** `/api/hub/vcgateway/schema.sql`
|
||||
110
api/hub/vcgateway/dm/messages.php
Normal file
110
api/hub/vcgateway/dm/messages.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/vcgateway/dm/messages.php
|
||||
*
|
||||
* List messages in the visitor's DM channel with the host.
|
||||
* Authenticated via X-Visitor-Token header.
|
||||
*
|
||||
* Query params:
|
||||
* Before int optional Cursor: messages before this ID
|
||||
* After int optional Cursor: messages after this ID
|
||||
* Limit int optional Max messages (default: 50, max: 100)
|
||||
*
|
||||
* Response:
|
||||
* OK, Messages[], DMChannelID, HasMore
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$visitor = requireVisitorAuth();
|
||||
|
||||
$dmChannelId = $visitor['DMChannelID'] ? (int)$visitor['DMChannelID'] : null;
|
||||
|
||||
// No DM channel yet = no messages
|
||||
if (!$dmChannelId) {
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Messages' => [],
|
||||
'DMChannelID' => null,
|
||||
'HasMore' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
// Pagination
|
||||
$before = (int)($_GET['Before'] ?? 0);
|
||||
$after = (int)($_GET['After'] ?? 0);
|
||||
$limit = min(100, max(1, (int)($_GET['Limit'] ?? 50)));
|
||||
|
||||
$where = ['m.ChannelID = ?', 'm.IsDeleted = 0'];
|
||||
$params = [$dmChannelId];
|
||||
|
||||
if ($before > 0) {
|
||||
$where[] = 'm.ID < ?';
|
||||
$params[] = $before;
|
||||
}
|
||||
if ($after > 0) {
|
||||
$where[] = 'm.ID > ?';
|
||||
$params[] = $after;
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $where);
|
||||
$fetchLimit = $limit + 1;
|
||||
$order = ($after > 0) ? 'ASC' : 'DESC';
|
||||
|
||||
$rows = queryTimed(
|
||||
"SELECT m.ID, m.SenderAddress, m.Content, m.IsEdited, m.CreatedAt
|
||||
FROM Hub_Messages m
|
||||
WHERE $whereClause
|
||||
ORDER BY m.ID $order
|
||||
LIMIT $fetchLimit",
|
||||
$params
|
||||
);
|
||||
|
||||
$hasMore = count($rows) > $limit;
|
||||
if ($hasMore) {
|
||||
array_pop($rows);
|
||||
}
|
||||
|
||||
if ($after > 0) {
|
||||
$rows = array_reverse($rows);
|
||||
}
|
||||
|
||||
$visitorId = (int)$visitor['ID'];
|
||||
$messages = [];
|
||||
foreach ($rows as $row) {
|
||||
// Determine if sender is visitor or host
|
||||
$isVisitor = str_starts_with($row['SenderAddress'], 'visitor:');
|
||||
$senderName = $isVisitor ? $visitor['DisplayName'] : $row['SenderAddress'];
|
||||
|
||||
if (!$isVisitor) {
|
||||
// Resolve agent name
|
||||
$agent = queryOne(
|
||||
"SELECT AgentName FROM Sprinter_Agents WHERE FullAddress = ? LIMIT 1",
|
||||
[$row['SenderAddress']]
|
||||
);
|
||||
if ($agent) {
|
||||
$senderName = $agent['AgentName'];
|
||||
}
|
||||
}
|
||||
|
||||
$messages[] = [
|
||||
'ID' => (int)$row['ID'],
|
||||
'SenderAddress' => $row['SenderAddress'],
|
||||
'SenderName' => $senderName,
|
||||
'IsVisitor' => $isVisitor,
|
||||
'Content' => $row['Content'],
|
||||
'IsEdited' => (bool)$row['IsEdited'],
|
||||
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Messages' => $messages,
|
||||
'DMChannelID' => $dmChannelId,
|
||||
'HasMore' => $hasMore,
|
||||
]);
|
||||
89
api/hub/vcgateway/dm/send.php
Normal file
89
api/hub/vcgateway/dm/send.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/vcgateway/dm/send.php
|
||||
*
|
||||
* Send a DM from visitor to host. Rate-limited.
|
||||
* Creates the DM channel on first message.
|
||||
* Authenticated via X-Visitor-Token header.
|
||||
*
|
||||
* Body:
|
||||
* Content string required Message text (max 4000 chars)
|
||||
*
|
||||
* Response:
|
||||
* OK, MessageID, DMChannelID, CreatedAt
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$visitor = requireVisitorAuth();
|
||||
$body = readJsonBody();
|
||||
|
||||
$content = trim($body['Content'] ?? '');
|
||||
if (empty($content)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'content_required'], 400);
|
||||
}
|
||||
if (strlen($content) > 4000) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'content_too_long', 'MaxLength' => 4000], 400);
|
||||
}
|
||||
|
||||
// Rate limit check
|
||||
checkVisitorRateLimit((int)$visitor['ID']);
|
||||
|
||||
$visitorId = (int)$visitor['ID'];
|
||||
$hostAddress = $visitor['HostAddress'];
|
||||
$dmChannelId = $visitor['DMChannelID'] ? (int)$visitor['DMChannelID'] : null;
|
||||
|
||||
// Create DM channel if it doesn't exist yet
|
||||
if (!$dmChannelId) {
|
||||
$channelName = 'vc-dm-visitor-' . $visitorId;
|
||||
$displayName = 'VC DM: ' . $visitor['DisplayName'];
|
||||
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_Channels (Name, DisplayName, Purpose, ChannelType, CreatedBy)
|
||||
VALUES (?, ?, ?, 'direct', ?)",
|
||||
[
|
||||
$channelName,
|
||||
$displayName,
|
||||
'VC Gateway DM between visitor and host',
|
||||
$hostAddress,
|
||||
]
|
||||
);
|
||||
|
||||
$dmChannelId = (int)lastInsertId();
|
||||
|
||||
// Add the host as a member
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_ChannelMembers (ChannelID, AgentAddress, Role)
|
||||
VALUES (?, ?, 'owner')",
|
||||
[$dmChannelId, $hostAddress]
|
||||
);
|
||||
|
||||
// Link the DM channel to the visitor
|
||||
queryTimed(
|
||||
"UPDATE Hub_Visitors SET DMChannelID = ? WHERE ID = ?",
|
||||
[$dmChannelId, $visitorId]
|
||||
);
|
||||
}
|
||||
|
||||
// Send the message
|
||||
// Visitor sender address format: "visitor:{visitorId}:{displayName}"
|
||||
$senderAddress = 'visitor:' . $visitorId . ':' . $visitor['DisplayName'];
|
||||
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_Messages (ChannelID, SenderAddress, Content)
|
||||
VALUES (?, ?, ?)",
|
||||
[$dmChannelId, $senderAddress, $content]
|
||||
);
|
||||
|
||||
$messageId = (int)lastInsertId();
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'MessageID' => $messageId,
|
||||
'DMChannelID' => $dmChannelId,
|
||||
'CreatedAt' => toISO8601(date('Y-m-d H:i:s')),
|
||||
]);
|
||||
127
api/hub/vcgateway/helpers.php
Normal file
127
api/hub/vcgateway/helpers.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
/**
|
||||
* VC Gateway Helpers
|
||||
*
|
||||
* Shared functions for the VC Gateway module.
|
||||
* Handles visitor authentication, rate limiting, and invite link validation.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
// Rate limit: max messages per window
|
||||
define('VCGW_RATE_LIMIT_MAX', 10); // 10 messages
|
||||
define('VCGW_RATE_LIMIT_WINDOW_SEC', 300); // per 5-minute window
|
||||
|
||||
/**
|
||||
* Authenticate a visitor via X-Visitor-Token header.
|
||||
* Returns the visitor row (with invite link data) or aborts with 401.
|
||||
*/
|
||||
function requireVisitorAuth(): array {
|
||||
$token = headerValue('X-Visitor-Token');
|
||||
if (empty($token)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'visitor_token_required'], 401);
|
||||
}
|
||||
|
||||
$visitor = queryOne(
|
||||
"SELECT v.*, il.Token AS InviteToken, il.AllowedChannels, il.HostAddress,
|
||||
il.IsRevoked AS InviteRevoked, il.ExpiresAt AS InviteExpiresAt
|
||||
FROM Hub_Visitors v
|
||||
JOIN Hub_InviteLinks il ON il.ID = v.InviteLinkID
|
||||
WHERE v.VisitorToken = ?
|
||||
LIMIT 1",
|
||||
[$token]
|
||||
);
|
||||
|
||||
if (!$visitor) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invalid_visitor_token'], 401);
|
||||
}
|
||||
|
||||
// Check if the underlying invite link is still valid
|
||||
if ($visitor['InviteRevoked']) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invite_revoked'], 403);
|
||||
}
|
||||
|
||||
if ($visitor['InviteExpiresAt'] && strtotime($visitor['InviteExpiresAt']) < time()) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invite_expired'], 403);
|
||||
}
|
||||
|
||||
// Update last active
|
||||
queryTimed("UPDATE Hub_Visitors SET LastActiveAt = NOW() WHERE ID = ?", [$visitor['ID']]);
|
||||
|
||||
return $visitor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the allowed channel IDs for a visitor.
|
||||
*/
|
||||
function getVisitorAllowedChannels(array $visitor): array {
|
||||
$channels = json_decode($visitor['AllowedChannels'], true);
|
||||
return is_array($channels) ? array_map('intval', $channels) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a visitor can read a specific channel.
|
||||
*/
|
||||
function visitorCanReadChannel(array $visitor, int $channelId): bool {
|
||||
$allowed = getVisitorAllowedChannels($visitor);
|
||||
return in_array($channelId, $allowed, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for a visitor. Aborts with 429 if exceeded.
|
||||
*/
|
||||
function checkVisitorRateLimit(int $visitorId): void {
|
||||
// Current window start (rounded to 5-min intervals)
|
||||
$windowSec = VCGW_RATE_LIMIT_WINDOW_SEC;
|
||||
$windowStart = date('Y-m-d H:i:s', (int)(floor(time() / $windowSec) * $windowSec));
|
||||
|
||||
$row = queryOne(
|
||||
"SELECT MessageCount FROM Hub_VisitorRateLimit
|
||||
WHERE VisitorID = ? AND WindowStart = ?",
|
||||
[$visitorId, $windowStart]
|
||||
);
|
||||
|
||||
$currentCount = $row ? (int)$row['MessageCount'] : 0;
|
||||
|
||||
if ($currentCount >= VCGW_RATE_LIMIT_MAX) {
|
||||
$resetAt = (int)(floor(time() / $windowSec) * $windowSec) + $windowSec;
|
||||
jsonResponse([
|
||||
'OK' => false,
|
||||
'ERROR' => 'rate_limit_exceeded',
|
||||
'Limit' => VCGW_RATE_LIMIT_MAX,
|
||||
'WindowSeconds' => $windowSec,
|
||||
'ResetsAt' => toISO8601(date('Y-m-d H:i:s', $resetAt)),
|
||||
], 429);
|
||||
}
|
||||
|
||||
// Upsert the count
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_VisitorRateLimit (VisitorID, WindowStart, MessageCount)
|
||||
VALUES (?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE MessageCount = MessageCount + 1",
|
||||
[$visitorId, $windowStart]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Require that the caller is a Sprinter agent (via X-Agent-Address header).
|
||||
* Returns the agent address string.
|
||||
*/
|
||||
function requireAgentAuth(): string {
|
||||
$address = headerValue('X-Agent-Address');
|
||||
if (empty($address)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required'], 401);
|
||||
}
|
||||
|
||||
// Verify the agent exists in Sprinter_Agents
|
||||
$agent = queryOne(
|
||||
"SELECT ID, FullAddress FROM Sprinter_Agents WHERE FullAddress = ? AND IsActive = 1 LIMIT 1",
|
||||
[$address]
|
||||
);
|
||||
|
||||
if (!$agent) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'agent_not_found'], 401);
|
||||
}
|
||||
|
||||
return $agent['FullAddress'];
|
||||
}
|
||||
96
api/hub/vcgateway/invites/create.php
Normal file
96
api/hub/vcgateway/invites/create.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/vcgateway/invites/create.php
|
||||
*
|
||||
* Create an invite link for VC Gateway.
|
||||
* Requires agent auth (X-Agent-Address header).
|
||||
*
|
||||
* Body:
|
||||
* Label string optional Human label (e.g. "Sequoia - Partner X")
|
||||
* AllowedChannels int[] required Array of Hub_Channels.ID the visitor can read
|
||||
* HostAddress string required Sprinter agent address for DM target
|
||||
* ExpiresAt string optional ISO8601 expiration datetime (null = never)
|
||||
* MaxUses int optional Max times this link can be used (0 = unlimited)
|
||||
*
|
||||
* Response:
|
||||
* OK, ID, Token, InviteURL
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$agentAddress = requireAgentAuth();
|
||||
$body = readJsonBody();
|
||||
|
||||
// Validate required fields
|
||||
$allowedChannels = $body['AllowedChannels'] ?? null;
|
||||
$hostAddress = trim($body['HostAddress'] ?? '');
|
||||
|
||||
if (!is_array($allowedChannels) || empty($allowedChannels)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'allowed_channels_required'], 400);
|
||||
}
|
||||
|
||||
if (empty($hostAddress)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'host_address_required'], 400);
|
||||
}
|
||||
|
||||
// Validate all channel IDs exist
|
||||
$channelIds = array_map('intval', $allowedChannels);
|
||||
$placeholders = implode(',', array_fill(0, count($channelIds), '?'));
|
||||
$channels = queryTimed(
|
||||
"SELECT ID FROM Hub_Channels WHERE ID IN ($placeholders)",
|
||||
$channelIds
|
||||
);
|
||||
|
||||
$foundIds = array_column($channels, 'ID');
|
||||
$missing = array_diff($channelIds, array_map('intval', $foundIds));
|
||||
if (!empty($missing)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invalid_channel_ids', 'InvalidIDs' => array_values($missing)], 400);
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
$label = trim($body['Label'] ?? '');
|
||||
if (strlen($label) > 255) {
|
||||
$label = substr($label, 0, 255);
|
||||
}
|
||||
|
||||
$expiresAt = null;
|
||||
if (!empty($body['ExpiresAt'])) {
|
||||
$dt = strtotime($body['ExpiresAt']);
|
||||
if ($dt === false || $dt <= time()) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'expires_at_must_be_future'], 400);
|
||||
}
|
||||
$expiresAt = date('Y-m-d H:i:s', $dt);
|
||||
}
|
||||
|
||||
$maxUses = max(0, (int)($body['MaxUses'] ?? 0));
|
||||
|
||||
// Generate a URL-safe token
|
||||
$token = bin2hex(random_bytes(24)); // 48 chars, URL-safe
|
||||
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_InviteLinks (Token, Label, AllowedChannels, HostAddress, ExpiresAt, MaxUses, CreatedBy)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
$token,
|
||||
$label,
|
||||
json_encode($channelIds),
|
||||
$hostAddress,
|
||||
$expiresAt,
|
||||
$maxUses,
|
||||
$agentAddress,
|
||||
]
|
||||
);
|
||||
|
||||
$id = (int)lastInsertId();
|
||||
$inviteUrl = baseUrl() . '/vc/' . $token;
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'ID' => $id,
|
||||
'Token' => $token,
|
||||
'InviteURL' => $inviteUrl,
|
||||
]);
|
||||
92
api/hub/vcgateway/invites/get.php
Normal file
92
api/hub/vcgateway/invites/get.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/vcgateway/invites/get.php
|
||||
*
|
||||
* Get a single invite link by ID or Token.
|
||||
* Requires agent auth (X-Agent-Address header).
|
||||
*
|
||||
* Query params:
|
||||
* ID int optional Invite link ID
|
||||
* Token string optional Invite link token
|
||||
*
|
||||
* Response:
|
||||
* OK, Link (object)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$agentAddress = requireAgentAuth();
|
||||
|
||||
$id = (int)($_GET['ID'] ?? 0);
|
||||
$token = trim($_GET['Token'] ?? '');
|
||||
|
||||
if ($id <= 0 && empty($token)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'id_or_token_required'], 400);
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
$row = queryOne("SELECT * FROM Hub_InviteLinks WHERE ID = ?", [$id]);
|
||||
} else {
|
||||
$row = queryOne("SELECT * FROM Hub_InviteLinks WHERE Token = ?", [$token]);
|
||||
}
|
||||
|
||||
if (!$row) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invite_not_found'], 404);
|
||||
}
|
||||
|
||||
// Get visitor count
|
||||
$vcRow = queryOne(
|
||||
"SELECT COUNT(*) AS cnt FROM Hub_Visitors WHERE InviteLinkID = ?",
|
||||
[(int)$row['ID']]
|
||||
);
|
||||
|
||||
// Compute status
|
||||
$computedStatus = 'active';
|
||||
if ($row['IsRevoked']) {
|
||||
$computedStatus = 'revoked';
|
||||
} elseif ($row['ExpiresAt'] && strtotime($row['ExpiresAt']) <= time()) {
|
||||
$computedStatus = 'expired';
|
||||
} elseif ($row['MaxUses'] > 0 && $row['UseCount'] >= $row['MaxUses']) {
|
||||
$computedStatus = 'exhausted';
|
||||
}
|
||||
|
||||
// Get visitors using this link
|
||||
$visitors = queryTimed(
|
||||
"SELECT ID, DisplayName, CreatedAt, LastActiveAt
|
||||
FROM Hub_Visitors WHERE InviteLinkID = ?
|
||||
ORDER BY CreatedAt DESC",
|
||||
[(int)$row['ID']]
|
||||
);
|
||||
|
||||
$visitorList = [];
|
||||
foreach ($visitors as $v) {
|
||||
$visitorList[] = [
|
||||
'ID' => (int)$v['ID'],
|
||||
'DisplayName' => $v['DisplayName'],
|
||||
'CreatedAt' => toISO8601($v['CreatedAt']),
|
||||
'LastActiveAt' => toISO8601($v['LastActiveAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Link' => [
|
||||
'ID' => (int)$row['ID'],
|
||||
'Token' => $row['Token'],
|
||||
'Label' => $row['Label'],
|
||||
'AllowedChannels' => json_decode($row['AllowedChannels'], true),
|
||||
'HostAddress' => $row['HostAddress'],
|
||||
'ExpiresAt' => $row['ExpiresAt'] ? toISO8601($row['ExpiresAt']) : null,
|
||||
'MaxUses' => (int)$row['MaxUses'],
|
||||
'UseCount' => (int)$row['UseCount'],
|
||||
'VisitorCount' => (int)($vcRow['cnt'] ?? 0),
|
||||
'Status' => $computedStatus,
|
||||
'CreatedBy' => $row['CreatedBy'],
|
||||
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||
'Visitors' => $visitorList,
|
||||
],
|
||||
]);
|
||||
104
api/hub/vcgateway/invites/list.php
Normal file
104
api/hub/vcgateway/invites/list.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/vcgateway/invites/list.php
|
||||
*
|
||||
* List invite links. Optionally filter by status.
|
||||
* Requires agent auth (X-Agent-Address header).
|
||||
*
|
||||
* Query params:
|
||||
* Status string optional "active" | "revoked" | "expired" | "all" (default: "all")
|
||||
* Limit int optional Max results (default: 50, max: 200)
|
||||
* Offset int optional Pagination offset (default: 0)
|
||||
*
|
||||
* Response:
|
||||
* OK, Links[], Total
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$agentAddress = requireAgentAuth();
|
||||
|
||||
$status = strtolower(trim($_GET['Status'] ?? 'all'));
|
||||
$limit = min(200, max(1, (int)($_GET['Limit'] ?? 50)));
|
||||
$offset = max(0, (int)($_GET['Offset'] ?? 0));
|
||||
|
||||
// Build WHERE clause
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
switch ($status) {
|
||||
case 'active':
|
||||
$where[] = 'il.IsRevoked = 0';
|
||||
$where[] = '(il.ExpiresAt IS NULL OR il.ExpiresAt > NOW())';
|
||||
$where[] = '(il.MaxUses = 0 OR il.UseCount < il.MaxUses)';
|
||||
break;
|
||||
case 'revoked':
|
||||
$where[] = 'il.IsRevoked = 1';
|
||||
break;
|
||||
case 'expired':
|
||||
$where[] = 'il.IsRevoked = 0';
|
||||
$where[] = 'il.ExpiresAt IS NOT NULL AND il.ExpiresAt <= NOW()';
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
// No filter
|
||||
break;
|
||||
}
|
||||
|
||||
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
// Count total
|
||||
$countRow = queryOne(
|
||||
"SELECT COUNT(*) AS cnt FROM Hub_InviteLinks il $whereClause",
|
||||
$params
|
||||
);
|
||||
$total = (int)($countRow['cnt'] ?? 0);
|
||||
|
||||
// Fetch links
|
||||
$rows = queryTimed(
|
||||
"SELECT il.*,
|
||||
(SELECT COUNT(*) FROM Hub_Visitors v WHERE v.InviteLinkID = il.ID) AS VisitorCount
|
||||
FROM Hub_InviteLinks il
|
||||
$whereClause
|
||||
ORDER BY il.CreatedAt DESC
|
||||
LIMIT $limit OFFSET $offset",
|
||||
$params
|
||||
);
|
||||
|
||||
$links = [];
|
||||
foreach ($rows as $row) {
|
||||
// Determine computed status
|
||||
$computedStatus = 'active';
|
||||
if ($row['IsRevoked']) {
|
||||
$computedStatus = 'revoked';
|
||||
} elseif ($row['ExpiresAt'] && strtotime($row['ExpiresAt']) <= time()) {
|
||||
$computedStatus = 'expired';
|
||||
} elseif ($row['MaxUses'] > 0 && $row['UseCount'] >= $row['MaxUses']) {
|
||||
$computedStatus = 'exhausted';
|
||||
}
|
||||
|
||||
$links[] = [
|
||||
'ID' => (int)$row['ID'],
|
||||
'Token' => $row['Token'],
|
||||
'Label' => $row['Label'],
|
||||
'AllowedChannels' => json_decode($row['AllowedChannels'], true),
|
||||
'HostAddress' => $row['HostAddress'],
|
||||
'ExpiresAt' => $row['ExpiresAt'] ? toISO8601($row['ExpiresAt']) : null,
|
||||
'MaxUses' => (int)$row['MaxUses'],
|
||||
'UseCount' => (int)$row['UseCount'],
|
||||
'VisitorCount' => (int)$row['VisitorCount'],
|
||||
'Status' => $computedStatus,
|
||||
'CreatedBy' => $row['CreatedBy'],
|
||||
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Links' => $links,
|
||||
'Total' => $total,
|
||||
]);
|
||||
48
api/hub/vcgateway/invites/revoke.php
Normal file
48
api/hub/vcgateway/invites/revoke.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/vcgateway/invites/revoke.php
|
||||
*
|
||||
* Revoke an invite link. All visitor sessions using this link become invalid.
|
||||
* Requires agent auth (X-Agent-Address header).
|
||||
*
|
||||
* Body:
|
||||
* ID int required The invite link ID to revoke
|
||||
*
|
||||
* Response:
|
||||
* OK
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$agentAddress = requireAgentAuth();
|
||||
$body = readJsonBody();
|
||||
|
||||
$id = (int)($body['ID'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'id_required'], 400);
|
||||
}
|
||||
|
||||
// Verify the link exists and belongs to this agent (or they created it)
|
||||
$link = queryOne(
|
||||
"SELECT ID, CreatedBy, IsRevoked FROM Hub_InviteLinks WHERE ID = ?",
|
||||
[$id]
|
||||
);
|
||||
|
||||
if (!$link) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invite_not_found'], 404);
|
||||
}
|
||||
|
||||
if ($link['IsRevoked']) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'already_revoked'], 400);
|
||||
}
|
||||
|
||||
queryTimed(
|
||||
"UPDATE Hub_InviteLinks SET IsRevoked = 1 WHERE ID = ?",
|
||||
[$id]
|
||||
);
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
51
api/hub/vcgateway/schema.sql
Normal file
51
api/hub/vcgateway/schema.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
-- VC Gateway Schema
|
||||
-- Invite links, visitor sessions, and DM support for anonymous visitors.
|
||||
-- Part of Sprinter Hub - supports fundraising / investor access.
|
||||
|
||||
-- Invite links with expiration and channel scoping
|
||||
CREATE TABLE IF NOT EXISTS Hub_InviteLinks (
|
||||
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
Token VARCHAR(64) NOT NULL COMMENT 'URL-safe token for link',
|
||||
Label VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Human label e.g. investor name',
|
||||
AllowedChannels JSON NOT NULL COMMENT 'Array of Hub_Channels.ID the visitor can read',
|
||||
HostAddress VARCHAR(150) NOT NULL COMMENT 'Sprinter agent address of the host (DM target)',
|
||||
ExpiresAt DATETIME NULL COMMENT 'NULL = never expires',
|
||||
MaxUses INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 = unlimited',
|
||||
UseCount INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
IsRevoked TINYINT(1) NOT NULL DEFAULT 0,
|
||||
CreatedBy VARCHAR(150) NOT NULL COMMENT 'Agent who created the link',
|
||||
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE KEY uq_invite_token (Token),
|
||||
INDEX idx_created_by (CreatedBy),
|
||||
INDEX idx_revoked (IsRevoked),
|
||||
INDEX idx_expires (ExpiresAt)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Visitor sessions - created when someone authenticates via invite link
|
||||
CREATE TABLE IF NOT EXISTS Hub_Visitors (
|
||||
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
InviteLinkID INT UNSIGNED NOT NULL,
|
||||
VisitorToken VARCHAR(64) NOT NULL COMMENT 'Session token for visitor API calls',
|
||||
DisplayName VARCHAR(100) NOT NULL DEFAULT 'Visitor',
|
||||
DMChannelID INT UNSIGNED NULL COMMENT 'Hub_Channels.ID for visitor<->host DM',
|
||||
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
LastActiveAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE KEY uq_visitor_token (VisitorToken),
|
||||
INDEX idx_invite_link (InviteLinkID),
|
||||
INDEX idx_dm_channel (DMChannelID),
|
||||
CONSTRAINT fk_visitor_invite FOREIGN KEY (InviteLinkID) REFERENCES Hub_InviteLinks(ID) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Rate limiting: track visitor message counts per sliding window
|
||||
CREATE TABLE IF NOT EXISTS Hub_VisitorRateLimit (
|
||||
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
VisitorID INT UNSIGNED NOT NULL,
|
||||
WindowStart DATETIME NOT NULL,
|
||||
MessageCount INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
|
||||
UNIQUE KEY uq_visitor_window (VisitorID, WindowStart),
|
||||
CONSTRAINT fk_ratelimit_visitor FOREIGN KEY (VisitorID) REFERENCES Hub_Visitors(ID) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
113
api/hub/vcgateway/visitor/auth.php
Normal file
113
api/hub/vcgateway/visitor/auth.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/vcgateway/visitor/auth.php
|
||||
*
|
||||
* Authenticate as a visitor using an invite link token.
|
||||
* Creates a visitor session and returns a VisitorToken for subsequent API calls.
|
||||
* No user account or Sprinter agent required.
|
||||
*
|
||||
* Body:
|
||||
* InviteToken string required The invite link token from the URL
|
||||
* DisplayName string optional Visitor's display name (default: "Visitor")
|
||||
*
|
||||
* Response:
|
||||
* OK, VisitorToken, VisitorID, DisplayName, AllowedChannels[], HostAddress
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$inviteToken = trim($body['InviteToken'] ?? '');
|
||||
if (empty($inviteToken)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invite_token_required'], 400);
|
||||
}
|
||||
|
||||
// Look up the invite link
|
||||
$link = queryOne(
|
||||
"SELECT * FROM Hub_InviteLinks WHERE Token = ? LIMIT 1",
|
||||
[$inviteToken]
|
||||
);
|
||||
|
||||
if (!$link) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invalid_invite_token'], 404);
|
||||
}
|
||||
|
||||
// Check if revoked
|
||||
if ($link['IsRevoked']) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invite_revoked'], 403);
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if ($link['ExpiresAt'] && strtotime($link['ExpiresAt']) < time()) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invite_expired'], 403);
|
||||
}
|
||||
|
||||
// Check max uses
|
||||
if ($link['MaxUses'] > 0 && $link['UseCount'] >= $link['MaxUses']) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invite_exhausted'], 403);
|
||||
}
|
||||
|
||||
// Sanitize display name
|
||||
$displayName = trim($body['DisplayName'] ?? '');
|
||||
if (empty($displayName)) {
|
||||
$displayName = 'Visitor';
|
||||
}
|
||||
$displayName = substr($displayName, 0, 100);
|
||||
// Strip any HTML/script
|
||||
$displayName = htmlspecialchars($displayName, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Generate visitor token
|
||||
$visitorToken = bin2hex(random_bytes(24));
|
||||
|
||||
// Create the visitor session
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_Visitors (InviteLinkID, VisitorToken, DisplayName)
|
||||
VALUES (?, ?, ?)",
|
||||
[(int)$link['ID'], $visitorToken, $displayName]
|
||||
);
|
||||
|
||||
$visitorId = (int)lastInsertId();
|
||||
|
||||
// Increment use count on the invite link
|
||||
queryTimed(
|
||||
"UPDATE Hub_InviteLinks SET UseCount = UseCount + 1 WHERE ID = ?",
|
||||
[(int)$link['ID']]
|
||||
);
|
||||
|
||||
// Return the session info
|
||||
$allowedChannels = json_decode($link['AllowedChannels'], true);
|
||||
|
||||
// Fetch channel details for the allowed channels
|
||||
$channelDetails = [];
|
||||
if (!empty($allowedChannels)) {
|
||||
$placeholders = implode(',', array_fill(0, count($allowedChannels), '?'));
|
||||
$channels = queryTimed(
|
||||
"SELECT ID, Name, DisplayName, Purpose, ChannelType
|
||||
FROM Hub_Channels
|
||||
WHERE ID IN ($placeholders) AND IsArchived = 0",
|
||||
array_map('intval', $allowedChannels)
|
||||
);
|
||||
foreach ($channels as $ch) {
|
||||
$channelDetails[] = [
|
||||
'ID' => (int)$ch['ID'],
|
||||
'Name' => $ch['Name'],
|
||||
'DisplayName' => $ch['DisplayName'],
|
||||
'Purpose' => $ch['Purpose'],
|
||||
'ChannelType' => $ch['ChannelType'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'VisitorToken' => $visitorToken,
|
||||
'VisitorID' => $visitorId,
|
||||
'DisplayName' => $displayName,
|
||||
'AllowedChannels' => $channelDetails,
|
||||
'HostAddress' => $link['HostAddress'],
|
||||
]);
|
||||
128
api/hub/vcgateway/visitor/feed.php
Normal file
128
api/hub/vcgateway/visitor/feed.php
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/vcgateway/visitor/feed.php
|
||||
*
|
||||
* Read-only message feed for a visitor's allowed channels.
|
||||
* Authenticated via X-Visitor-Token header (not user token).
|
||||
*
|
||||
* Query params:
|
||||
* ChannelID int required Channel to read messages from
|
||||
* Before int optional Cursor: return messages before this ID
|
||||
* After int optional Cursor: return messages after this ID
|
||||
* Limit int optional Max messages (default: 50, max: 100)
|
||||
*
|
||||
* Response:
|
||||
* OK, Messages[], Channel (metadata), HasMore
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$visitor = requireVisitorAuth();
|
||||
|
||||
$channelId = (int)($_GET['ChannelID'] ?? 0);
|
||||
if ($channelId <= 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required'], 400);
|
||||
}
|
||||
|
||||
// Verify visitor has access to this channel
|
||||
if (!visitorCanReadChannel($visitor, $channelId)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_allowed'], 403);
|
||||
}
|
||||
|
||||
// Verify channel exists and is not archived
|
||||
$channel = queryOne(
|
||||
"SELECT ID, Name, DisplayName, Purpose, ChannelType
|
||||
FROM Hub_Channels WHERE ID = ? AND IsArchived = 0",
|
||||
[$channelId]
|
||||
);
|
||||
|
||||
if (!$channel) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404);
|
||||
}
|
||||
|
||||
// Pagination
|
||||
$before = (int)($_GET['Before'] ?? 0);
|
||||
$after = (int)($_GET['After'] ?? 0);
|
||||
$limit = min(100, max(1, (int)($_GET['Limit'] ?? 50)));
|
||||
|
||||
// Build query - read only non-deleted messages
|
||||
$where = ['m.ChannelID = ?', 'm.IsDeleted = 0'];
|
||||
$params = [$channelId];
|
||||
|
||||
if ($before > 0) {
|
||||
$where[] = 'm.ID < ?';
|
||||
$params[] = $before;
|
||||
}
|
||||
if ($after > 0) {
|
||||
$where[] = 'm.ID > ?';
|
||||
$params[] = $after;
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $where);
|
||||
|
||||
// Fetch one extra to check HasMore
|
||||
$fetchLimit = $limit + 1;
|
||||
|
||||
// Order: newest first for Before cursor, oldest first for After cursor
|
||||
$order = ($after > 0) ? 'ASC' : 'DESC';
|
||||
|
||||
$rows = queryTimed(
|
||||
"SELECT m.ID, m.SenderAddress, m.Content, m.ParentID,
|
||||
m.IsEdited, m.CreatedAt, m.UpdatedAt
|
||||
FROM Hub_Messages m
|
||||
WHERE $whereClause
|
||||
ORDER BY m.ID $order
|
||||
LIMIT $fetchLimit",
|
||||
$params
|
||||
);
|
||||
|
||||
$hasMore = count($rows) > $limit;
|
||||
if ($hasMore) {
|
||||
array_pop($rows);
|
||||
}
|
||||
|
||||
// If we fetched in ASC order, reverse for consistent newest-first
|
||||
if ($after > 0) {
|
||||
$rows = array_reverse($rows);
|
||||
}
|
||||
|
||||
// Format messages (read-only: no edit/delete capabilities for visitors)
|
||||
$messages = [];
|
||||
foreach ($rows as $row) {
|
||||
// Resolve sender display name
|
||||
$senderName = $row['SenderAddress'];
|
||||
// Try to get agent name
|
||||
$agent = queryOne(
|
||||
"SELECT AgentName FROM Sprinter_Agents WHERE FullAddress = ? LIMIT 1",
|
||||
[$row['SenderAddress']]
|
||||
);
|
||||
if ($agent) {
|
||||
$senderName = $agent['AgentName'];
|
||||
}
|
||||
|
||||
$messages[] = [
|
||||
'ID' => (int)$row['ID'],
|
||||
'SenderAddress' => $row['SenderAddress'],
|
||||
'SenderName' => $senderName,
|
||||
'Content' => $row['Content'],
|
||||
'ParentID' => $row['ParentID'] ? (int)$row['ParentID'] : null,
|
||||
'IsEdited' => (bool)$row['IsEdited'],
|
||||
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Channel' => [
|
||||
'ID' => (int)$channel['ID'],
|
||||
'Name' => $channel['Name'],
|
||||
'DisplayName' => $channel['DisplayName'],
|
||||
'Purpose' => $channel['Purpose'],
|
||||
],
|
||||
'Messages' => $messages,
|
||||
'HasMore' => $hasMore,
|
||||
]);
|
||||
33
api/tasks/team/active.php
Normal file
33
api/tasks/team/active.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
/**
|
||||
* GET /api/tasks/team/active.php
|
||||
* List all active and paused tasks.
|
||||
*
|
||||
* Optional query params: BotName (filter by bot)
|
||||
* Returns: { OK: true, Tasks: [...] }
|
||||
*/
|
||||
|
||||
$botName = trim($_GET['BotName'] ?? '');
|
||||
|
||||
try {
|
||||
$sql = "SELECT ID, BotName, Title, Status, Channel, StartedOn, PausedOn, Notes, AssignedBy
|
||||
FROM TeamTasks
|
||||
WHERE Status IN ('active', 'paused')";
|
||||
$params = [];
|
||||
|
||||
if ($botName !== '') {
|
||||
$sql .= " AND BotName = ?";
|
||||
$params[] = $botName;
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY StartedOn DESC";
|
||||
|
||||
$tasks = queryTimed($sql, $params);
|
||||
|
||||
jsonResponse(['OK' => true, 'Tasks' => $tasks]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
apiAbort(['OK' => false, 'ERROR' => 'Failed to fetch tasks: ' . $e->getMessage()]);
|
||||
}
|
||||
54
api/tasks/team/create.php
Normal file
54
api/tasks/team/create.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
/**
|
||||
* POST /api/tasks/team/create.php
|
||||
* Register a new team task.
|
||||
*
|
||||
* Body: { BotName, Title, Channel?, Notes?, AssignedBy? }
|
||||
* Returns: { OK: true, ID: <new task ID> }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
|
||||
$botName = trim($data['BotName'] ?? '');
|
||||
$title = trim($data['Title'] ?? '');
|
||||
$channel = trim($data['Channel'] ?? '');
|
||||
$notes = trim($data['Notes'] ?? '');
|
||||
$assignedBy = trim($data['AssignedBy'] ?? '');
|
||||
|
||||
// Validate required fields
|
||||
if ($botName === '' || $title === '') {
|
||||
http_response_code(400);
|
||||
apiAbort(['OK' => false, 'ERROR' => 'BotName and Title are required']);
|
||||
}
|
||||
|
||||
// Length guards
|
||||
if (strlen($botName) > 50) {
|
||||
http_response_code(400);
|
||||
apiAbort(['OK' => false, 'ERROR' => 'BotName max 50 characters']);
|
||||
}
|
||||
if (strlen($title) > 255) {
|
||||
http_response_code(400);
|
||||
apiAbort(['OK' => false, 'ERROR' => 'Title max 255 characters']);
|
||||
}
|
||||
|
||||
try {
|
||||
queryTimed("
|
||||
INSERT INTO TeamTasks (BotName, Title, Status, Channel, StartedOn, Notes, AssignedBy)
|
||||
VALUES (?, ?, 'active', ?, NOW(), ?, ?)
|
||||
", [
|
||||
$botName,
|
||||
$title,
|
||||
$channel ?: null,
|
||||
$notes ?: null,
|
||||
$assignedBy ?: null,
|
||||
]);
|
||||
|
||||
$id = (int) lastInsertId();
|
||||
|
||||
jsonResponse(['OK' => true, 'ID' => $id]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
apiAbort(['OK' => false, 'ERROR' => 'Failed to create task: ' . $e->getMessage()]);
|
||||
}
|
||||
450
api/tasks/team/index.php
Normal file
450
api/tasks/team/index.php
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
/**
|
||||
* Team Task Dashboard — pretty HTML view
|
||||
* GET /api/tasks/team/ or /api/tasks/team/index.php
|
||||
*/
|
||||
|
||||
// Grab filters from query string
|
||||
$botName = trim($_GET['BotName'] ?? '');
|
||||
$status = trim($_GET['Status'] ?? '');
|
||||
$assignedBy = trim($_GET['AssignedBy'] ?? '');
|
||||
|
||||
$validStatuses = ['active', 'paused', 'done', 'cancelled'];
|
||||
|
||||
try {
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($botName !== '') {
|
||||
$where[] = "BotName = ?";
|
||||
$params[] = $botName;
|
||||
}
|
||||
if ($status !== '') {
|
||||
if (in_array($status, $validStatuses, true)) {
|
||||
$where[] = "Status = ?";
|
||||
$params[] = $status;
|
||||
}
|
||||
}
|
||||
if ($assignedBy !== '') {
|
||||
$where[] = "AssignedBy = ?";
|
||||
$params[] = $assignedBy;
|
||||
}
|
||||
|
||||
$whereClause = empty($where) ? '' : 'WHERE ' . implode(' AND ', $where);
|
||||
|
||||
$tasks = queryTimed("
|
||||
SELECT ID, BotName, Title, Status, Channel, StartedOn, PausedOn, CompletedOn, Notes, AssignedBy
|
||||
FROM TeamTasks
|
||||
$whereClause
|
||||
ORDER BY FIELD(Status, 'active', 'paused', 'done', 'cancelled'), StartedOn DESC
|
||||
", $params);
|
||||
|
||||
// Get unique bot names for filter dropdown
|
||||
$bots = queryTimed("SELECT DISTINCT BotName FROM TeamTasks ORDER BY BotName", []);
|
||||
|
||||
// Stats
|
||||
$stats = queryOne("SELECT
|
||||
COUNT(*) AS Total,
|
||||
SUM(Status = 'active') AS Active,
|
||||
SUM(Status = 'paused') AS Paused,
|
||||
SUM(Status = 'done') AS Done,
|
||||
SUM(Status = 'cancelled') AS Cancelled
|
||||
FROM TeamTasks", []);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$tasks = [];
|
||||
$bots = [];
|
||||
$stats = ['Total' => 0, 'Active' => 0, 'Paused' => 0, 'Done' => 0, 'Cancelled' => 0];
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
|
||||
function statusBadge(string $status): string {
|
||||
$colors = [
|
||||
'active' => '#22c55e',
|
||||
'paused' => '#f59e0b',
|
||||
'done' => '#6b7280',
|
||||
'cancelled' => '#ef4444',
|
||||
];
|
||||
$icons = [
|
||||
'active' => '●',
|
||||
'paused' => '⏸',
|
||||
'done' => '✓',
|
||||
'cancelled' => '✕',
|
||||
];
|
||||
$color = $colors[$status] ?? '#6b7280';
|
||||
$icon = $icons[$status] ?? '';
|
||||
return "<span class=\"badge\" style=\"--badge-color: $color\">$icon " . htmlspecialchars($status) . "</span>";
|
||||
}
|
||||
|
||||
function timeAgo(string $datetime): string {
|
||||
if (empty($datetime)) return '—';
|
||||
$ts = strtotime($datetime);
|
||||
$diff = time() - $ts;
|
||||
if ($diff < 60) return 'just now';
|
||||
if ($diff < 3600) return floor($diff / 60) . 'm ago';
|
||||
if ($diff < 86400) return floor($diff / 3600) . 'h ago';
|
||||
if ($diff < 604800) return floor($diff / 86400) . 'd ago';
|
||||
return date('M j', $ts);
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Team Tasks — Payfrit</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||
border-bottom: 1px solid #334155;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px 32px;
|
||||
background: #1e293b;
|
||||
border-bottom: 1px solid #334155;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: #0f172a;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px 32px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters label {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filters select, .filters a.btn {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #334155;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filters select:hover, .filters a.btn:hover {
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.filters a.btn {
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 32px 32px;
|
||||
}
|
||||
|
||||
.task-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.task-table th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #64748b;
|
||||
border-bottom: 1px solid #334155;
|
||||
background: #1e293b;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.task-table td {
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.task-table tr:hover td {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.task-table tr.status-done td {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.task-table tr.status-cancelled td {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.bot-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #334155;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-weight: 500;
|
||||
color: #f1f5f9;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.task-notes {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
max-width: 400px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--badge-color);
|
||||
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--badge-color) 30%, transparent);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.assigned-by {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.channel-tag {
|
||||
font-size: 12px;
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 32px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.json-link {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #334155;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.json-link:hover { color: #60a5fa; border-color: #60a5fa; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header, .stats-bar, .filters, .container { padding-left: 16px; padding-right: 16px; }
|
||||
.task-table { font-size: 13px; }
|
||||
.task-table th, .task-table td { padding: 8px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🚀 Team Tasks</h1>
|
||||
<p>Payfrit bot task tracker — real-time view of all team activity</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-bar">
|
||||
<div class="stat">
|
||||
<div>
|
||||
<div class="stat-number"><?= (int)$stats['Total'] ?></div>
|
||||
<div class="stat-label">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div>
|
||||
<div class="stat-number" style="color: #22c55e"><?= (int)$stats['Active'] ?></div>
|
||||
<div class="stat-label">Active</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div>
|
||||
<div class="stat-number" style="color: #f59e0b"><?= (int)$stats['Paused'] ?></div>
|
||||
<div class="stat-label">Paused</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div>
|
||||
<div class="stat-number" style="color: #6b7280"><?= (int)$stats['Done'] ?></div>
|
||||
<div class="stat-label">Done</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div>
|
||||
<div class="stat-number" style="color: #ef4444"><?= (int)$stats['Cancelled'] ?></div>
|
||||
<div class="stat-label">Cancelled</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="filters" method="GET">
|
||||
<label>Filter:</label>
|
||||
<select name="BotName" onchange="this.form.submit()">
|
||||
<option value="">All Bots</option>
|
||||
<?php foreach ($bots as $b): ?>
|
||||
<option value="<?= htmlspecialchars($b['BotName']) ?>" <?= $botName === $b['BotName'] ? 'selected' : '' ?>>
|
||||
@<?= htmlspecialchars($b['BotName']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<select name="Status" onchange="this.form.submit()">
|
||||
<option value="">All Statuses</option>
|
||||
<?php foreach ($validStatuses as $s): ?>
|
||||
<option value="<?= $s ?>" <?= $status === $s ? 'selected' : '' ?>><?= ucfirst($s) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ($botName || $status || $assignedBy): ?>
|
||||
<a class="btn" href="?">Clear Filters</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<div class="container">
|
||||
<?php if (empty($tasks)): ?>
|
||||
<div class="empty-state">
|
||||
<p>No tasks found</p>
|
||||
<span>Tasks will appear here when bots register work via the API.</span>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<table class="task-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Bot</th>
|
||||
<th>Task</th>
|
||||
<th>Status</th>
|
||||
<th>Assigned By</th>
|
||||
<th>Channel</th>
|
||||
<th>Started</th>
|
||||
<th>Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($tasks as $t): ?>
|
||||
<tr class="status-<?= htmlspecialchars($t['Status']) ?>">
|
||||
<td style="color: #64748b; font-size: 12px;"><?= (int)$t['ID'] ?></td>
|
||||
<td>
|
||||
<span class="bot-name">
|
||||
<span class="bot-avatar"><?= strtoupper(substr($t['BotName'], 0, 1)) ?></span>
|
||||
@<?= htmlspecialchars($t['BotName']) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="task-title"><?= htmlspecialchars($t['Title']) ?></div>
|
||||
<?php if (!empty($t['Notes'])): ?>
|
||||
<div class="task-notes"><?= htmlspecialchars($t['Notes']) ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= statusBadge($t['Status']) ?></td>
|
||||
<td class="assigned-by"><?= !empty($t['AssignedBy']) ? '@' . htmlspecialchars($t['AssignedBy']) : '—' ?></td>
|
||||
<td><?= !empty($t['Channel']) ? '<span class="channel-tag">#' . htmlspecialchars($t['Channel']) . '</span>' : '—' ?></td>
|
||||
<td class="time" title="<?= htmlspecialchars($t['StartedOn'] ?? '') ?>"><?= timeAgo($t['StartedOn'] ?? '') ?></td>
|
||||
<td class="time" title="<?= htmlspecialchars($t['CompletedOn'] ?? '') ?>"><?= timeAgo($t['CompletedOn'] ?? '') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<a class="json-link" href="list.php" target="_blank">{ } JSON</a>
|
||||
|
||||
<?php if (isset($error)): ?>
|
||||
<script>console.error('Task dashboard error: <?= addslashes($error) ?>');</script>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
</html>
|
||||
89
api/tasks/team/list.php
Normal file
89
api/tasks/team/list.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
/**
|
||||
* GET /api/tasks/team/list.php
|
||||
* List all tasks with optional filters.
|
||||
*
|
||||
* Query params:
|
||||
* BotName — filter by bot name
|
||||
* Status — filter by status (active/paused/done/cancelled)
|
||||
* Channel — filter by channel
|
||||
* AssignedBy — filter by who assigned
|
||||
* Since — only tasks started on or after this date (YYYY-MM-DD)
|
||||
* Limit — max rows (default 100, max 500)
|
||||
* Offset — pagination offset (default 0)
|
||||
*
|
||||
* Returns: { OK: true, Tasks: [...], Total: <count> }
|
||||
*/
|
||||
|
||||
$botName = trim($_GET['BotName'] ?? '');
|
||||
$status = trim($_GET['Status'] ?? '');
|
||||
$channel = trim($_GET['Channel'] ?? '');
|
||||
$assignedBy = trim($_GET['AssignedBy'] ?? '');
|
||||
$since = trim($_GET['Since'] ?? '');
|
||||
$limit = min(max((int) ($_GET['Limit'] ?? 100), 1), 500);
|
||||
$offset = max((int) ($_GET['Offset'] ?? 0), 0);
|
||||
|
||||
$validStatuses = ['active', 'paused', 'done', 'cancelled'];
|
||||
|
||||
try {
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($botName !== '') {
|
||||
$where[] = "BotName = ?";
|
||||
$params[] = $botName;
|
||||
}
|
||||
|
||||
if ($status !== '') {
|
||||
if (!in_array($status, $validStatuses, true)) {
|
||||
http_response_code(400);
|
||||
apiAbort(['OK' => false, 'ERROR' => 'Invalid status filter']);
|
||||
}
|
||||
$where[] = "Status = ?";
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
if ($channel !== '') {
|
||||
$where[] = "Channel = ?";
|
||||
$params[] = $channel;
|
||||
}
|
||||
|
||||
if ($assignedBy !== '') {
|
||||
$where[] = "AssignedBy = ?";
|
||||
$params[] = $assignedBy;
|
||||
}
|
||||
|
||||
if ($since !== '') {
|
||||
// Validate date format
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $since)) {
|
||||
http_response_code(400);
|
||||
apiAbort(['OK' => false, 'ERROR' => 'Since must be YYYY-MM-DD']);
|
||||
}
|
||||
$where[] = "StartedOn >= ?";
|
||||
$params[] = $since . ' 00:00:00';
|
||||
}
|
||||
|
||||
$whereClause = empty($where) ? '' : 'WHERE ' . implode(' AND ', $where);
|
||||
|
||||
// Get total count
|
||||
$countRow = queryOne("SELECT COUNT(*) AS Total FROM TeamTasks $whereClause", $params);
|
||||
$total = (int) ($countRow['Total'] ?? 0);
|
||||
|
||||
// Get paginated results
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
$tasks = queryTimed("
|
||||
SELECT ID, BotName, Title, Status, Channel, StartedOn, PausedOn, CompletedOn, Notes, AssignedBy
|
||||
FROM TeamTasks
|
||||
$whereClause
|
||||
ORDER BY StartedOn DESC
|
||||
LIMIT ? OFFSET ?
|
||||
", $params);
|
||||
|
||||
jsonResponse(['OK' => true, 'Tasks' => $tasks, 'Total' => $total]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
apiAbort(['OK' => false, 'ERROR' => 'Failed to fetch tasks: ' . $e->getMessage()]);
|
||||
}
|
||||
17
api/tasks/team/schema.sql
Normal file
17
api/tasks/team/schema.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- TeamTasks: Centralized task tracker for all bots
|
||||
-- Run against payfrit_dev (and later payfrit prod)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS TeamTasks (
|
||||
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
BotName VARCHAR(50) NOT NULL,
|
||||
Title VARCHAR(255) NOT NULL,
|
||||
Status ENUM('active','paused','done','cancelled') NOT NULL DEFAULT 'active',
|
||||
Channel VARCHAR(100) DEFAULT NULL,
|
||||
StartedOn DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PausedOn DATETIME DEFAULT NULL,
|
||||
CompletedOn DATETIME DEFAULT NULL,
|
||||
Notes TEXT DEFAULT NULL,
|
||||
AssignedBy VARCHAR(50) DEFAULT NULL,
|
||||
INDEX idx_status (Status),
|
||||
INDEX idx_botname (BotName)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
80
api/tasks/team/update.php
Normal file
80
api/tasks/team/update.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
|
||||
/**
|
||||
* POST /api/tasks/team/update.php
|
||||
* Update an existing team task (change status, add notes).
|
||||
*
|
||||
* Body: { ID, Status?, Notes?, Channel? }
|
||||
* Returns: { OK: true }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
|
||||
$id = (int) ($data['ID'] ?? 0);
|
||||
$status = trim($data['Status'] ?? '');
|
||||
$notes = $data['Notes'] ?? null;
|
||||
$channel = $data['Channel'] ?? null;
|
||||
|
||||
if ($id <= 0) {
|
||||
http_response_code(400);
|
||||
apiAbort(['OK' => false, 'ERROR' => 'ID is required']);
|
||||
}
|
||||
|
||||
// Validate status if provided
|
||||
$validStatuses = ['active', 'paused', 'done', 'cancelled'];
|
||||
if ($status !== '' && !in_array($status, $validStatuses, true)) {
|
||||
http_response_code(400);
|
||||
apiAbort(['OK' => false, 'ERROR' => 'Status must be one of: ' . implode(', ', $validStatuses)]);
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify task exists
|
||||
$task = queryOne("SELECT ID, Status FROM TeamTasks WHERE ID = ?", [$id]);
|
||||
if (!$task) {
|
||||
http_response_code(404);
|
||||
apiAbort(['OK' => false, 'ERROR' => 'Task not found']);
|
||||
}
|
||||
|
||||
// Build dynamic update
|
||||
$sets = [];
|
||||
$params = [];
|
||||
|
||||
if ($status !== '') {
|
||||
$sets[] = "Status = ?";
|
||||
$params[] = $status;
|
||||
|
||||
// Auto-set timestamp columns based on status transitions
|
||||
if ($status === 'paused') {
|
||||
$sets[] = "PausedOn = NOW()";
|
||||
} elseif ($status === 'done' || $status === 'cancelled') {
|
||||
$sets[] = "CompletedOn = NOW()";
|
||||
} elseif ($status === 'active' && $task['Status'] === 'paused') {
|
||||
// Resuming from pause — clear PausedOn
|
||||
$sets[] = "PausedOn = NULL";
|
||||
}
|
||||
}
|
||||
|
||||
if ($notes !== null) {
|
||||
$sets[] = "Notes = ?";
|
||||
$params[] = trim($notes);
|
||||
}
|
||||
|
||||
if ($channel !== null) {
|
||||
$sets[] = "Channel = ?";
|
||||
$params[] = trim($channel);
|
||||
}
|
||||
|
||||
if (empty($sets)) {
|
||||
http_response_code(400);
|
||||
apiAbort(['OK' => false, 'ERROR' => 'Nothing to update — provide Status, Notes, or Channel']);
|
||||
}
|
||||
|
||||
$params[] = $id;
|
||||
queryTimed("UPDATE TeamTasks SET " . implode(', ', $sets) . " WHERE ID = ?", $params);
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
apiAbort(['OK' => false, 'ERROR' => 'Failed to update task: ' . $e->getMessage()]);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../api/helpers.php';
|
||||
// No runAuth() — cron/public endpoint
|
||||
requireCronSecret();
|
||||
|
||||
/**
|
||||
* Expire stale chats (older than 20 minutes with no recent activity).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../api/helpers.php';
|
||||
require_once __DIR__ . '/../api/config/stripe.php';
|
||||
// No runAuth() — cron/public endpoint
|
||||
requireCronSecret();
|
||||
|
||||
/**
|
||||
* Scheduled task to handle tab expiry and cleanup.
|
||||
|
|
|
|||
|
|
@ -91,10 +91,10 @@ foreach ($parentItems as $parent) {
|
|||
$qty = (int) $parent['Quantity'];
|
||||
|
||||
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;
|
||||
|
||||
$itemRows .= '<tr>';
|
||||
|
|
@ -116,7 +116,7 @@ foreach ($parentItems as $parent) {
|
|||
foreach ($children as $child) {
|
||||
$modParent = queryOne("SELECT Name FROM Items WHERE ID = ?", [(int) $child['ParentItemID']]);
|
||||
$modParentName = $modParent['Name'] ?? '';
|
||||
$modTotal = (float) $child['Price'] * $qty;
|
||||
$modTotal = round((float) $child['Price'] * $qty, 2);
|
||||
$cartGrandTotal += $modTotal;
|
||||
|
||||
$itemRows .= '<tr class="modifier">';
|
||||
|
|
@ -152,17 +152,32 @@ $isCardOrder = (float) $order['PaymentFromCreditCard'] > 0;
|
|||
$receiptTip = (float) ($order['TipAmount'] ?? 0);
|
||||
$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;
|
||||
$cardFeeFixed = 0.30;
|
||||
$totalCustomerPaysRaw = ($totalBeforeCardFee + $cardFeeFixed) / (1 - $cardFeePercent);
|
||||
$orderGrandTotal = round($totalCustomerPaysRaw * 100) / 100;
|
||||
$taxAmount = round($taxAmountRaw * 100) / 100;
|
||||
$cardFee = $orderGrandTotal - $cartGrandTotal - $taxAmount - round($payfritFeeRaw * 100) / 100 - $deliveryFee - $receiptTip;
|
||||
$cardFee = round($cardFee * 100) / 100;
|
||||
} else {
|
||||
$orderGrandTotal = round($totalBeforeCardFee * 100) / 100;
|
||||
$taxAmount = round($taxAmountRaw * 100) / 100;
|
||||
$cardFee = 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
16
show_order.php
Normal file
16
show_order.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
/**
|
||||
* Legacy redirect: show_order.php → /receipt/
|
||||
* Old links (payfr.it/show_order.php?UUID=...) now point to the PHP receipt page.
|
||||
*/
|
||||
|
||||
$uuid = $_GET['UUID'] ?? '';
|
||||
$isAdminView = $_GET['is_admin_view'] ?? '0';
|
||||
|
||||
$params = http_build_query([
|
||||
'UUID' => $uuid,
|
||||
'is_admin_view' => $isAdminView,
|
||||
]);
|
||||
|
||||
header("Location: /receipt/index.php?{$params}", true, 301);
|
||||
exit;
|
||||
Loading…
Add table
Reference in a new issue