'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $uid = trim((string)($input['uid'] ?? '')); $name = trim((string)($input['name'] ?? '')); $color = (string)($input['color'] ?? '#38bdf8'); $memberUsernames = is_array($input['members'] ?? null) ? $input['members'] : []; if ($uid === '' || $name === '') jsonResponse(['error' => 'uid and name required'], 400); // Prevent collisions (clients generate UUIDs so this is paranoia) $exists = $db->prepare('SELECT 1 FROM collab_buckets WHERE uid = :u'); $exists->execute(['u' => $uid]); if ($exists->fetchColumn()) jsonResponse(['error' => 'uid already exists'], 409); // Resolve requested members; silently drop unknown usernames $resolved = []; foreach ($memberUsernames as $u) { $u = trim((string)$u); if ($u === '') continue; $t = getTenantByUsername($db, $u); if ($t) $resolved[(int)$t['id']] = $t['username']; } // Always include the owner $resolved[(int)$clientTenant['id']] = $clientTenant['username']; try { $db->beginTransaction(); $db->prepare('INSERT INTO collab_buckets (uid, owner_id, owner_username, name, color) VALUES (:uid, :oid, :ou, :n, :c)') ->execute(['uid' => $uid, 'oid' => $clientTenant['id'], 'ou' => $clientTenant['username'], 'n' => $name, 'c' => $color]); $mstmt = $db->prepare('INSERT INTO collab_bucket_members (bucket_uid, tenant_id, username) VALUES (:b, :t, :u)'); foreach ($resolved as $tid => $uname) { $mstmt->execute(['b' => $uid, 't' => $tid, 'u' => $uname]); } $db->commit(); } catch (Throwable $e) { if ($db->inTransaction()) $db->rollBack(); @error_log('[hyprcrd-relay] collab bucket create failed: ' . $e->getMessage()); jsonResponse(['error' => 'create failed'], 500); } jsonResponse(['ok' => true, 'uid' => $uid, 'members' => array_values($resolved)]); } // ── GET: list buckets I'm a member of ───────────────────────────── if ($route === '/api/collab-buckets' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); if (isRateLimited($db, 'collab-buckets-get', 120)) jsonResponse(['error' => 'rate limited'], 429); $stmt = $db->prepare(' SELECT b.uid, b.name, b.color, b.owner_username, b.created_at, b.updated_at FROM collab_buckets b INNER JOIN collab_bucket_members m ON m.bucket_uid = b.uid WHERE m.tenant_id = :tid ORDER BY b.updated_at DESC '); $stmt->execute(['tid' => $clientTenant['id']]); $buckets = $stmt->fetchAll(); // Attach member list to each bucket $mstmt = $db->prepare('SELECT username FROM collab_bucket_members WHERE bucket_uid = :uid ORDER BY username'); foreach ($buckets as &$b) { $mstmt->execute(['uid' => $b['uid']]); $b['members'] = array_column($mstmt->fetchAll(), 'username'); } jsonResponse(['ok' => true, 'buckets' => $buckets]); } // ── PATCH: owner updates name / color / members ─────────────────── if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $uid = $m[1]; $row = $db->prepare('SELECT owner_id FROM collab_buckets WHERE uid = :u'); $row->execute(['u' => $uid]); $bucket = $row->fetch(); if (!$bucket) jsonResponse(['error' => 'bucket not found'], 404); if ((int)$bucket['owner_id'] !== (int)$clientTenant['id']) jsonResponse(['error' => 'only the owner can update'], 403); $input = json_decode(file_get_contents('php://input'), true) ?: []; $sets = []; $params = ['u' => $uid]; if (isset($input['name'])) { $sets[] = 'name = :n'; $params['n'] = trim((string)$input['name']); } if (isset($input['color'])) { $sets[] = 'color = :c'; $params['c'] = (string)$input['color']; } // Ownership transfer: new owner must already be a member of the bucket. // We set owner_id/owner_username atomically and keep the current // caller in the member list (no implicit leave on transfer). if (isset($input['owner'])) { $newOwnerName = trim((string)$input['owner']); if ($newOwnerName === '') jsonResponse(['error' => 'owner cannot be empty'], 400); $newOwner = getTenantByUsername($db, $newOwnerName); if (!$newOwner) jsonResponse(['error' => 'user not found'], 404); $memCheck = $db->prepare('SELECT 1 FROM collab_bucket_members WHERE bucket_uid = :b AND tenant_id = :t'); $memCheck->execute(['b' => $uid, 't' => $newOwner['id']]); if (!$memCheck->fetchColumn()) { jsonResponse(['error' => 'new owner must already be a member of the bucket'], 400); } $sets[] = 'owner_id = :oid'; $params['oid'] = (int)$newOwner['id']; $sets[] = 'owner_username = :oun'; $params['oun'] = (string)$newOwner['username']; } if ($sets) { $sets[] = "updated_at = datetime('now')"; $db->prepare('UPDATE collab_buckets SET ' . implode(', ', $sets) . ' WHERE uid = :u')->execute($params); } if (isset($input['members']) && is_array($input['members'])) { $resolved = []; foreach ($input['members'] as $u) { $u = trim((string)$u); if ($u === '') continue; $t = getTenantByUsername($db, $u); if ($t) $resolved[(int)$t['id']] = $t['username']; } // Caller is always kept (the user editing) + the current effective // owner (possibly just transferred in this same request). $resolved[(int)$clientTenant['id']] = $clientTenant['username']; if (isset($newOwner)) $resolved[(int)$newOwner['id']] = $newOwner['username']; $db->prepare('DELETE FROM collab_bucket_members WHERE bucket_uid = :u')->execute(['u' => $uid]); $ins = $db->prepare('INSERT INTO collab_bucket_members (bucket_uid, tenant_id, username) VALUES (:b, :t, :u)'); foreach ($resolved as $tid => $uname) { $ins->execute(['b' => $uid, 't' => $tid, 'u' => $uname]); } $db->prepare("UPDATE collab_buckets SET updated_at = datetime('now') WHERE uid = :u")->execute(['u' => $uid]); } jsonResponse(['ok' => true]); } // ── DELETE: owner wipes; member leaves ──────────────────────────── if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $uid = $m[1]; $row = $db->prepare('SELECT owner_id FROM collab_buckets WHERE uid = :u'); $row->execute(['u' => $uid]); $bucket = $row->fetch(); if (!$bucket) jsonResponse(['error' => 'bucket not found'], 404); if ((int)$bucket['owner_id'] === (int)$clientTenant['id']) { // Owner: destroy everything $db->prepare('DELETE FROM collab_bucket_members WHERE bucket_uid = :u')->execute(['u' => $uid]); $db->prepare('DELETE FROM collab_buckets WHERE uid = :u')->execute(['u' => $uid]); jsonResponse(['ok' => true, 'action' => 'deleted']); } else { // Member: remove own membership only $del = $db->prepare('DELETE FROM collab_bucket_members WHERE bucket_uid = :u AND tenant_id = :t'); $del->execute(['u' => $uid, 't' => $clientTenant['id']]); // Bump updated_at so remaining members see the member-list change on next poll $db->prepare("UPDATE collab_buckets SET updated_at = datetime('now') WHERE uid = :u")->execute(['u' => $uid]); jsonResponse(['ok' => true, 'action' => 'left']); } }