From 26ffa0e71f0caf680256b7dc1c78d1372c74c1e5 Mon Sep 17 00:00:00 2001 From: th Date: Sat, 11 Apr 2026 18:49:53 +0200 Subject: [PATCH] plugin(collab-buckets): collaborative buckets via relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end shared buckets between relay tenants. Bucket metadata + members sync through new relay routes (collab-buckets, collab-cards, collab-locks); card content rides the existing inter-tenant encryption hop. Edit locks, ownership transfer, LWW conflict stashing, undo for local-bucket destructive actions, "Make collaborative…" upgrade path, and a resilient outbox for relay-down create attempts. See plugins/collab-buckets/README.md and TODO.md for the full picture. Core hooks added so the plugin can integrate without monkey-patching: - buckets.js context menu: collab-aware rename/color/delete + Members & owner / Make collaborative entries - editor.js dispatches hyprcrd:editor-open / hyprcrd:editor-close for the per-card edit-lock heartbeat - app.js exposes window.state for the plugin's MutationObserver - index.php POST/PATCH/DELETE on /api/buckets refuse to mutate collab buckets directly (must go through the relay) - Database.php adds buckets.collab_uid column + unique partial index - import handler now restores color and collab_uid (was silently dropping color, which caused the test runner's auto-restore to wipe custom bucket colors) - relay bootstrap creates collab_buckets, collab_bucket_members, collab_cards, collab_card_locks tables and prunes tombstones / expired locks on every request Co-Authored-By: Claude Opus 4.6 (1M context) --- public/index.php | 31 +- public/js/app.js | 3 + public/js/modules/buckets.js | 96 +- public/js/modules/editor.js | 4 + public/plugins/collab-buckets/README.md | 358 +++++++ public/plugins/collab-buckets/TODO.md | 151 +++ public/plugins/collab-buckets/backend.php | 876 ++++++++++++++++++ .../plugins/collab-buckets/collab-buckets.css | 304 ++++++ .../plugins/collab-buckets/collab-buckets.js | 719 ++++++++++++++ public/plugins/collab-buckets/plugin.json | 10 + relay/inc/bootstrap.php | 62 ++ relay/inc/routes/collab-buckets.php | 166 ++++ relay/inc/routes/collab-cards.php | 167 ++++ relay/inc/routes/collab-locks.php | 75 ++ relay/index.php | 3 + src/Database.php | 5 + 16 files changed, 3017 insertions(+), 13 deletions(-) create mode 100644 public/plugins/collab-buckets/README.md create mode 100644 public/plugins/collab-buckets/TODO.md create mode 100644 public/plugins/collab-buckets/backend.php create mode 100644 public/plugins/collab-buckets/collab-buckets.css create mode 100644 public/plugins/collab-buckets/collab-buckets.js create mode 100644 public/plugins/collab-buckets/plugin.json create mode 100644 relay/inc/routes/collab-buckets.php create mode 100644 relay/inc/routes/collab-cards.php create mode 100644 relay/inc/routes/collab-locks.php diff --git a/public/index.php b/public/index.php index abf573b..c469add 100644 --- a/public/index.php +++ b/public/index.php @@ -341,19 +341,38 @@ if (str_starts_with($route, '/api/')) { $input = json_decode(file_get_contents('php://input'), true); $name = trim($input['name'] ?? ''); if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name is required']); exit; } + // Normal POST creates a local bucket. Collab buckets go through + // /api/collab-buckets (plugin-owned) and can't be created here. $db->prepare('INSERT INTO buckets (name) VALUES (:name)')->execute(['name' => $name]); echo json_encode($db->query('SELECT * FROM buckets WHERE id = ' . $db->lastInsertId())->fetch()); exit; } if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'PATCH') { $input = json_decode(file_get_contents('php://input'), true); + // Collab buckets: name and color must go through the relay plugin so + // all members see the update. Only position (local reorder) is allowed. + $b = $db->prepare('SELECT collab_uid FROM buckets WHERE id = :id'); + $b->execute(['id' => $m[1]]); + $existing = $b->fetch(); + $isCollab = $existing && !empty($existing['collab_uid']); + $allowed = $isCollab ? ['position'] : ['name', 'position', 'color']; $fields = []; $params = ['id' => $m[1]]; - foreach (['name', 'position', 'color'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } } + foreach ($allowed as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } } if ($fields) $db->prepare('UPDATE buckets SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); $b = $db->prepare('SELECT * FROM buckets WHERE id = :id'); $b->execute(['id' => $m[1]]); echo json_encode($b->fetch()); exit; } if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'DELETE') { + // Collab buckets must go through /api/collab-buckets/{uid} so the relay + // and plugin mirror stay consistent. Refuse deletion here. + $b = $db->prepare('SELECT collab_uid FROM buckets WHERE id = :id'); + $b->execute(['id' => $m[1]]); + $existing = $b->fetch(); + if ($existing && !empty($existing['collab_uid'])) { + http_response_code(400); + echo json_encode(['error' => 'collab buckets must be deleted via /api/collab-buckets/' . $existing['collab_uid']]); + exit; + } $db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $m[1]]); $db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $m[1]]); echo json_encode(['ok' => true]); exit; @@ -625,8 +644,14 @@ if (str_starts_with($route, '/api/')) { $found = $existing->fetchColumn(); if ($found) { $bucketMap[$oldId] = $found; continue; } } - $stmt = $db->prepare('INSERT INTO buckets (name, position, created_at) VALUES (:name, :position, :created_at)'); - $stmt->execute(['name' => $b['name'], 'position' => $b['position'] ?? 0, 'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s')]); + $stmt = $db->prepare('INSERT INTO buckets (name, position, color, collab_uid, created_at) VALUES (:name, :position, :color, :collab_uid, :created_at)'); + $stmt->execute([ + 'name' => $b['name'], + 'position' => $b['position'] ?? 0, + 'color' => $b['color'] ?? '#64748b', + 'collab_uid' => $b['collab_uid'] ?? null, + 'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s'), + ]); $bucketMap[$oldId] = $db->lastInsertId(); } diff --git a/public/js/app.js b/public/js/app.js index f3c441d..8de42ad 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -39,6 +39,9 @@ window._getSelectedIds = getSelectedIds; window._clearSelection = clearSelection; window._refreshTasks = refreshTasksView; window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); }; +// Exposed for plugins that need to read the shared state (e.g. collab-buckets +// reads `state.buckets` in its MutationObserver to find collab_uid mapping). +window.state = state; window._extractToCard = async (text) => { if (!text?.trim()) return; const lines = text.trim().split('\n'); diff --git a/public/js/modules/buckets.js b/public/js/modules/buckets.js index 6cd44a0..fa24cbe 100644 --- a/public/js/modules/buckets.js +++ b/public/js/modules/buckets.js @@ -3,6 +3,7 @@ import { $, esc, api } from './util.js'; import { state, CARD_COLORS } from './state.js'; import { showDialog } from './dialog.js'; +import { pushUndo } from './undo.js'; // ── Bucket context menu ── @@ -24,9 +25,18 @@ async function showBucketCtx(x, y, bucket) { const canRight = idx < state.buckets.length - 1; const hasCards = state.cards.some(c => c.bucket_id == bucket.id); + const isCollab = !!bucket.collab_uid; ctxMenu.innerHTML = `
+ ${isCollab && typeof window._collabBucketsEditDialog === 'function' + ? `` + : '' + } + ${!isCollab && typeof window._collabBucketsUpgradeDialog === 'function' + ? `` + : '' + }
@@ -56,12 +66,35 @@ async function showBucketCtx(x, y, bucket) { document.getElementById('bctx-colors').classList.toggle('hidden'); return; } + else if (a === 'collab-manage') { + hideBucketCtx(); + if (typeof window._collabBucketsEditDialog === 'function') { + const changed = await window._collabBucketsEditDialog(bucket.collab_uid); + if (changed) await window._reloadAll(); + } + return; + } + else if (a === 'collab-upgrade') { + hideBucketCtx(); + if (typeof window._collabBucketsUpgradeDialog === 'function') { + const changed = await window._collabBucketsUpgradeDialog(bucket.id); + if (changed) await window._reloadAll(); + } + return; + } else if (a === 'rename') { hideBucketCtx(); const name = await showDialog({ title: 'rename_bucket', input: true, placeholder: bucket.name }); if (!name || !name.trim()) return; - await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { name: name.trim() } }); - await loadBuckets(); window._renderBoard(); + // Collab buckets: push name change through the plugin (relay + + // mirror). The next poll pulls the new name into the local row. + if (bucket.collab_uid && typeof window._collabBucketsPatch === 'function') { + const res = await window._collabBucketsPatch(bucket.collab_uid, { name: name.trim() }); + if (!res?.ok) { alert(res?.error || 'rename failed'); return; } + } else { + await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { name: name.trim() } }); + } + await window._reloadAll(); } else if (a === 'move-left' || a === 'move-right') { hideBucketCtx(); @@ -76,12 +109,37 @@ async function showBucketCtx(x, y, bucket) { hideBucketCtx(); const cards = state.cards.filter(c => c.bucket_id == bucket.id); if (cards.length > 0 || localStorage.getItem('hyprcrd-confirm-delete') !== '0') { - const ok = await showDialog({ title: 'delete_bucket', message: cards.length ? `Delete "${bucket.name}"? ${cards.length} card(s) will be kept.` : `Delete "${bucket.name}"?`, danger: true, okLabel: 'Delete' }); + const msg = bucket.collab_uid + ? (cards.length ? `Leave / delete "${bucket.name}"? ${cards.length} card(s) will be kept locally.` : `Leave / delete "${bucket.name}"?`) + : (cards.length ? `Delete "${bucket.name}"? ${cards.length} card(s) will be kept.` : `Delete "${bucket.name}"?`); + const ok = await showDialog({ title: 'delete_bucket', message: msg, danger: true, okLabel: bucket.collab_uid ? 'Leave / delete' : 'Delete' }); if (!ok) return; } if (state.activeBucket == bucket.id) state.activeBucket = 'all'; - await api(`api/buckets/${bucket.id}`, { method: 'DELETE' }); - await loadBuckets(); await window._reloadAll(); + // Collab bucket: route through plugin so the relay is notified + // (owner = delete for everyone; member = just leave). + if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') { + const res = await window._collabBucketsDelete(bucket.collab_uid); + if (!res?.ok) { alert(res?.error || 'delete failed'); return; } + // Undo is intentionally NOT wired for collab deletes/leaves + // — restoring would require re-creating on the relay and + // re-attaching cards under a new uid. Too messy for MVP. + } else { + // Snapshot the row + affected card ids before deleting so + // the undo entry can restore them. + const snapshot = { name: bucket.name, color: bucket.color, position: bucket.position }; + const affected = cards.map(c => c.id); + await api(`api/buckets/${bucket.id}`, { method: 'DELETE' }); + pushUndo(`delete bucket "${bucket.name}"`, async () => { + const created = await api('api/buckets', { method: 'POST', body: { name: snapshot.name } }); + if (created?.id) { + await api(`api/buckets/${created.id}`, { method: 'PATCH', body: { color: snapshot.color, position: snapshot.position } }); + // Re-attach cards to the recreated bucket + await Promise.all(affected.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id } }))); + } + }); + } + await window._reloadAll(); } else if (a === 'delete-all') { hideBucketCtx(); @@ -90,8 +148,13 @@ async function showBucketCtx(x, y, bucket) { if (!ok) return; for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' }); if (state.activeBucket == bucket.id) state.activeBucket = 'all'; - await api(`api/buckets/${bucket.id}`, { method: 'DELETE' }); - await loadBuckets(); await window._reloadAll(); + if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') { + const res = await window._collabBucketsDelete(bucket.collab_uid); + if (!res?.ok) { alert(res?.error || 'delete failed'); return; } + } else { + await api(`api/buckets/${bucket.id}`, { method: 'DELETE' }); + } + await window._reloadAll(); } })); @@ -99,9 +162,14 @@ async function showBucketCtx(x, y, bucket) { ctxMenu.querySelectorAll('[data-bctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideBucketCtx(); - await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: btn.dataset.bctxColor } }); - await loadBuckets(); - window._renderBoard(); + const newColor = btn.dataset.bctxColor; + if (bucket.collab_uid && typeof window._collabBucketsPatch === 'function') { + const res = await window._collabBucketsPatch(bucket.collab_uid, { color: newColor }); + if (!res?.ok) { alert(res?.error || 'color change failed'); return; } + } else { + await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: newColor } }); + } + await window._reloadAll(); })); } @@ -256,6 +324,14 @@ function renderBar() { const addBtn = $('add-bucket-btn'); addBtn?.addEventListener('click', async () => { + // Plugins (e.g. collab-buckets) can override the default create + // dialog by exposing window._collabBucketsCreateDialog. It returns + // true when a bucket was created, in which case we reload. + if (typeof window._collabBucketsCreateDialog === 'function') { + const created = await window._collabBucketsCreateDialog(); + if (created) { await loadBuckets(); window._renderBoard(); } + return; + } const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' }); if (!name || !name.trim()) return; await api('api/buckets', { method: 'POST', body: { name: name.trim() } }); diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js index 6add5d4..16c74e0 100644 --- a/public/js/modules/editor.js +++ b/public/js/modules/editor.js @@ -200,6 +200,8 @@ export function openModal(card = null) { currentLine = -1; updateActiveLine(); inpTitle.focus(); + // Plugins (collab-buckets) hook into this to heartbeat an edit lock + window.dispatchEvent(new CustomEvent('hyprcrd:editor-open', { detail: { card } })); } inpTitle.addEventListener('input', updatePreview); @@ -354,6 +356,7 @@ body.addEventListener('keyup', updateActiveLine); window.addEventListener('resize', () => { if (linesVisible) updateGutter(); }); export function closeModal({ animate = true } = {}) { + const closingId = state.editingId; if (animate) startCloseMorph(); else clearMorphGhost(); hideLinkAc(); @@ -366,6 +369,7 @@ export function closeModal({ animate = true } = {}) { state._returnToCalendar = false; window._openCalendar?.(); } + window.dispatchEvent(new CustomEvent('hyprcrd:editor-close', { detail: { cardId: closingId } })); } function updatePreview() { diff --git a/public/plugins/collab-buckets/README.md b/public/plugins/collab-buckets/README.md new file mode 100644 index 0000000..a447987 --- /dev/null +++ b/public/plugins/collab-buckets/README.md @@ -0,0 +1,358 @@ +# collab-buckets plugin + +Collaborative buckets shared between relay tenants. Each bucket has an owner +and a set of members; membership changes and metadata updates propagate +through the existing relay. Collab buckets are **first-class rows in the +normal bucket bar** — the plugin just owns the sync plus an unread +indicator (red dot + 3-cycle pulse) on any tab that has a fresh update. + +**Phase 1 through 5 shipped.** Metadata + membership sync, bucket-bar +integration, full card sync (create / edit / delete, LWW conflict +resolution with local Conflicts-bucket stashing, encrypted round-trip +via the relay's decrypt-reencrypt hop), advisory per-card edit locks +with a 🔒 badge on cards another user is currently editing, +bucket-ownership transfer through a members & owner modal, and the +Phase 5 hardening pass: **"Make collaborative…"** upgrade path for +existing local buckets, **undo** for local bucket delete/leave, +**resilient create** via a local outbox that queues intents when the +relay is unreachable and retries on the next poll, and +**rate-limiting** on all collab endpoints. Remaining nice-to-haves +(position propagation, presence, read receipts, inline member picker, +owner approval flow) are in `TODO.md`. + +--- + +## What it does + +- **Create a collab bucket** from the normal `+ Bucket` button in the + bucket bar. The plugin intercepts the button click (via + `window._collabBucketsCreateDialog`) and shows a modal with an optional + "collaborative" checkbox. Unchecked → a regular local bucket, same as + before. Checked → reveals a member picker listing all other relay + tenants, and the submit path goes through `/api/collab-buckets` instead + of the core `/api/buckets`. +- **See other members' buckets** appear automatically in the bucket bar. + Every 30 seconds the plugin polls the relay for a full resync; any new + or updated bucket the current user is a member of is mirrored into the + main `buckets` table, so the core bar renders it alongside everything + else. +- **Collab tabs are visually distinguished**: the folder icon is swapped + for `ph-users-three`, there's a subtle left-edge accent stripe, and the + right-click menu's rename / color / delete actions route through the + plugin endpoints so changes reach the relay and other members. +- **Unread state**: brand-new buckets and ones whose `updated_at` exceeds + the local `last_seen_at` show a red dot top-right of the tab and pulse 3 + times on the next render. Clicking the tab triggers `mark-seen` which + clears the dot/pulse immediately and persists to the plugin backend. + +## Architecture + +Two places store bucket state locally: + +1. **Main `buckets` table** (core) — gains a `collab_uid` column so collab + buckets are indistinguishable from local buckets in the bucket bar, + bucket selects, card-to-bucket joins, etc. `collab_uid` is unique and + NULL for regular local buckets. +2. **Plugin `collab_buckets` table** — holds collab-only metadata + (`members_json`, `owner_username`, `updated_at`, `last_seen_at`) that + the main table doesn't need. On every poll the plugin keeps both + tables in sync. + +``` +┌───── Browser ─────┐ ┌──── Relay ────┐ +│ │ │ │ +│ bucket-bar (core) │ │ │ +│ ├─ bucket rows │ POST /api/collab-buckets │ collab- │ +│ │ incl. collab │ (create via + Bucket modal) │ buckets.php │ +│ │ collab_uid │ ───────────────────────────▶ │ │ +│ │ │ │ collab_ │ +│ collab-buckets.js │ POST /api/collab-buckets │ buckets │ +│ ▲ observer │ /poll (every 30s) │ │ +│ │ MutationObs │ ◀─────────────────────────── │ collab_ │ +│ │ + delegation │ full resync │ bucket_ │ +│ ▼ │ │ members │ +│ backend.php │ │ │ +│ ├─ collab_buckets│ │ │ +│ │ plugin table │ │ │ +│ └─ upserts/ │ │ │ +│ deletes rows │ │ │ +│ in main │ │ │ +│ buckets table │ │ │ +└───────────────────┘ └───────────────┘ +``` + +- **Relay is source of truth** for bucket metadata + membership. The two + local tables are mirrors that together add `last_seen_at` (unread state, + user-specific, never synced). +- **Full resync every 30 s.** Locally cached buckets that are no longer + returned by the relay for this user = we were kicked (or the bucket was + deleted); both tables drop them and any cards referencing them fall back + to unsorted via `ON DELETE SET NULL`. +- **LWW per bucket**: every patch bumps `updated_at` on the relay. The + local poll only re-saves rows whose `updated_at` actually changed, + otherwise it's a no-op and `last_seen_at` is untouched so unread state + survives unrelated polls. + +## Files + +| File | Purpose | +|---|---| +| `plugin.json` | Plugin manifest (auto-enabled unless listed in `data/plugins.json`) | +| `backend.php` | Plugin backend: `collab_buckets` plugin table, relay proxy, full-resync poll, mark-seen, + `collabUpsertMainBucket` / `collabDeleteMainBucket` helpers that keep the core `buckets` table in sync | +| `collab-buckets.js` | Frontend: polling loop, exposes `window._collabBucketsCreateDialog` for the core `+ Bucket` button, MutationObserver on `#bucket-bar` to decorate collab tabs with unread dot + pulse, delegated click handler for mark-seen, helpers for rename / color / delete that core's context menu calls for `collab_uid` buckets | +| `collab-buckets.css` | Collab-tab decoration (left accent stripe, unread dot, pulse animation) + the create / edit modal styling | +| `README.md` | This file | +| `TODO.md` | Deferred features + Phase 3 roadmap | + +### Core-side touchpoints (not in this directory) + +| File | What changes | +|---|---| +| `src/Database.php` | Migration adds `buckets.collab_uid TEXT` + a unique partial index | +| `public/index.php` | `api/buckets` GET returns `collab_uid` (via `SELECT *`). `PATCH` only allows `position` on collab buckets (name/color must go through the plugin so other members see them). `DELETE` refuses collab buckets with a 400 pointing to `/api/collab-buckets/{uid}` | +| `public/js/app.js` | Exposes `window.state = state` so the plugin's observer can read `state.buckets` to resolve `collab_uid` per tab | +| `public/js/modules/buckets.js` | `+ Bucket` click checks `window._collabBucketsCreateDialog` before falling back to the simple dialog. Rename / color / delete actions check `bucket.collab_uid` and route through `window._collabBucketsPatch` / `_collabBucketsDelete` when set | + +Relay-side files (not in this directory, part of the main relay deployment): + +| File | Purpose | +|---|---| +| `relay/inc/bootstrap.php` | Adds `collab_buckets` + `collab_bucket_members` tables (auto-migrated on first boot) | +| `relay/inc/routes/collab-buckets.php` | `POST/GET/PATCH/DELETE /api/collab-buckets[/{uid}]` | +| `relay/index.php` | `require`s the new route file after `chat.php` | + +## API (local) + +All routes live under `/api/collab-buckets` and are registered via +`PluginManager::instance()->onApiRoute()`. + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/api/collab-buckets` | List local mirror. Each row has `has_unread` computed from `last_seen_at` vs `updated_at`. | +| `POST` | `/api/collab-buckets` | `{name, color, members[]}` — generates UID, pushes to relay, mirrors local. The creator's own `last_seen_at = now` so the tile isn't unread for them. | +| `PATCH` | `/api/collab-buckets/{uid}` | `{name?, color?, members?}` — owner-only, proxied to relay. Local row updates on next poll. | +| `DELETE` | `/api/collab-buckets/{uid}` | Owner → delete; member → leave. Relay decides based on the authenticated caller. | +| `POST` | `/api/collab-buckets/poll` | Full resync. Drops stale local rows, upserts changed remote rows, preserves `last_seen_at`. | +| `POST` | `/api/collab-buckets/mark-seen` | `{uid}` — sets `last_seen_at = now` so the dot / pulse clear. | +| `GET` | `/api/collab-buckets/relay-tenants` | Proxy to relay `/api/tenants` for the member-picker. | + +## API (relay) + +Exact endpoints the local plugin forwards to. Authentication: the standard +`X-Relay-User` / `X-Relay-Key` client-API headers that the existing chat and +messages routes also use. + +### Buckets + +| Method | Path | Behaviour | +|---|---|---| +| `POST` | `/api/collab-buckets` | `{uid, name, color, members: [usernames]}`. Unknown usernames are silently dropped; the owner is always added to the member list regardless of whether they included themselves. | +| `GET` | `/api/collab-buckets` | Returns all buckets where the caller is a member (owner or otherwise). Each row carries its full member list as `members: [usernames]`. | +| `PATCH` | `/api/collab-buckets/{uid}` | Owner-only. Member-list replacement is total — pass the full desired list. | +| `DELETE` | `/api/collab-buckets/{uid}` | Owner → `DELETE` of bucket + memberships (with `action: deleted`). Member → just that one membership row (with `action: left`); the bucket's `updated_at` is bumped so the owner sees the change on next poll. | + +### Cards (Phase 3) + +| Method | Path | Behaviour | +|---|---|---| +| `POST` | `/api/collab-cards` | `{bucket_uid, card_uid, payload}` — upsert. `payload` is a `HYPRCRD-RELAY-V1` envelope encrypted with the sender's client secret. The relay decrypts it with the sender's secret (via `masterDecrypt` of their stored `secret_encrypted`), re-encrypts with the inter-tenant key, stores. Server generates `updated_at` so all clients agree on the authoritative LWW timestamp. Membership-guarded. | +| `GET` | `/api/collab-cards?since=` | Pull cards updated since `` (ISO string from a previous response's `max_updated_at`) across all buckets the caller is a member of. Each row is re-encrypted under the caller's client secret on the way out. Tombstoned rows return with `deleted: true` and no payload. Hard-capped to 1000 cards per response. | +| `DELETE` | `/api/collab-cards/{bucket_uid}/{card_uid}` | Mark tombstone (not hard-delete) so late pollers still observe the deletion within the 7-day TTL. Membership-guarded. | + +## Schema (local) + +```sql +CREATE TABLE collab_buckets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid TEXT UNIQUE NOT NULL, -- 16-hex string generated by this client + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#38bdf8', + owner_username TEXT NOT NULL, + members_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT -- NULL = never opened → unread +); + +-- Phase 3: per-card sync state. Attached to the core `cards` table via +-- card_id FK. `last_push_at` mirrors the updated_at we last sent to the +-- relay; the push phase picks up cards where c.updated_at > last_push_at. +-- `last_remote_at` mirrors the updated_at we last applied from the relay. +CREATE TABLE collab_card_state ( + card_id INTEGER PRIMARY KEY, + relay_uid TEXT NOT NULL, + bucket_uid TEXT NOT NULL, + last_push_at TEXT, + last_remote_at TEXT, + FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE +); + +-- Phase 3: deletion outbox. Populated by a BEFORE DELETE trigger on the +-- cards table; drained in the push phase of the next poll. +CREATE TABLE collab_tombstones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + relay_uid TEXT NOT NULL, + bucket_uid TEXT NOT NULL, + queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +`members_json` is a JSON array of usernames — flat and cheap to render. +Because the relay is the source of truth, we don't need to normalise +members into a separate local table. + +The plugin also creates a BEFORE-DELETE trigger on `cards` that inserts a +tombstone row whenever a card in a collab bucket is deleted — unless the +suppress temp table `collab_suppress_outbox` has a row, which the plugin +sets during the poll's apply phase so remotely-triggered deletes don't +re-enqueue. + +## Schema (relay) + +```sql +CREATE TABLE collab_buckets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid TEXT UNIQUE NOT NULL, + owner_id INTEGER NOT NULL, + owner_username TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#38bdf8', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE +); +CREATE TABLE collab_bucket_members ( + bucket_uid TEXT NOT NULL, + tenant_id INTEGER NOT NULL, + username TEXT NOT NULL, + added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (bucket_uid, tenant_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +); + +-- Phase 3: the actual synced card content. `payload` is the re-encrypted +-- envelope stored at rest under the inter-tenant key. `deleted = 1` +-- marks a tombstone; bootstrap.php prunes them after 7 days. +CREATE TABLE collab_cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bucket_uid TEXT NOT NULL, + card_uid TEXT NOT NULL, + author_username TEXT NOT NULL, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INTEGER NOT NULL DEFAULT 0, + UNIQUE (bucket_uid, card_uid) +); +``` + +Owner deletion cascades via `ON DELETE CASCADE` on the `owner_id` FK. +Member rows are pruned explicitly on leave (or cascade when a tenant is +deleted outright). `collab_cards` rows belonging to a deleted bucket are +orphaned but unreachable — the GET query joins `collab_bucket_members` +and returns nothing for them, so they're effectively gone. Future hardening +could add a cascade trigger. + +## Card sync flow (Phase 3) + +Every poll does three things in strict order inside one request: + +1. **Drain tombstones.** The `collab_tombstones` outbox table is populated + by a BEFORE DELETE trigger on `cards` — whenever a user deletes a card + that sits in a collab bucket, the trigger inserts a row `{relay_uid, + bucket_uid}` into the outbox. The poll pushes each row to + `DELETE /api/collab-cards/{bucket}/{card}` and drops the outbox entry + on success. Failed pushes stay queued and retry next poll. + +2. **Push dirty cards.** One query finds every card in a collab bucket + whose `updated_at` is greater than `collab_card_state.last_push_at` + (or never-pushed). Each is encrypted with the local `relay_key` and + POSTed to `/api/collab-cards`. On success, `last_push_at` is stamped + with the server's returned `updated_at` so the next poll skips it. + Cards without a `relay_uid` get one assigned (16 hex chars) and the + core `cards.relay_uid` column is updated so the card has a stable + cross-tenant identity. + +3. **Pull deltas.** `GET /api/collab-cards?since=` fetches every + card updated or tombstoned since the stored marker (core `settings` + row `collab_cards_last_remote_at`). For each incoming card: + - If `deleted: true` → look up the local card by `relay_uid` and + `DELETE` it. The BEFORE DELETE trigger is suppressed via the + `collab_suppress_outbox` temp-table flag so it doesn't re-enqueue + the tombstone back into the outbox. + - Otherwise, decrypt the payload, find the local card by `relay_uid`, + and apply LWW (`remote.updated_at > local.updated_at` → overwrite). + New cards are INSERTed with `bucket_id` resolved via + `buckets.collab_uid`. In both cases `collab_card_state.last_push_at` + and `last_remote_at` are set to the remote's `updated_at` so the + next push phase doesn't re-send what was just received. + +Conflict resolution is strict LWW on `updated_at`. If two users save the +same card within the same second, the second writer wins; the first +writer's changes are silently overwritten on their next pull. Phase 4 +adds optional edit-locks to surface the conflict before it happens. + +## Unread / pulse state + +`has_unread` is computed by the plugin backend on every read of +`/api/collab-buckets`: + +``` +has_unread = (last_seen_at IS NULL) OR (updated_at > last_seen_at) +``` + +- **Brand-new bucket** → poll inserts the plugin row with `last_seen_at = + NULL` → unread. +- **Metadata / member change** → relay bumps `updated_at` → poll upserts → + `updated_at > last_seen_at` → unread. +- **Click tab** → `mark-seen` sets `last_seen_at = now` → unread clears on + the next render. + +The plugin frontend watches `#bucket-bar` via `MutationObserver`. After +every core re-render, it walks the tabs, looks up each bucket in +`state.buckets`, and applies `.bucket-tab-collab` plus (if unread) the +`.pulsing` class and an injected `.collab-tab-dot` element. The pulse +animation is CSS-driven (`collab-bucket-pulse 1.2s ease-in-out 3`), and +the JS strips `.pulsing` after 3.7 s so subsequent re-renders don't +retrigger it mid-cycle. + +## Theming + +All chrome hooks into the standard design tokens: + +- `--ui-radius-button` — tile and button rounding +- `--ui-radius-modal` — create/edit modal +- `--ui-radius-input` — form inputs +- `--ui-shadow-modal` — modal drop shadow +- `--ui-blur-overlay` — modal backdrop blur +- `--collab-tile-color` (per-tile) — bucket color, mixed into border, text, + hover background, and pulse ring + +So a collab tile automatically inherits the currently selected theme and the +user's design-token choices without any plugin-side theming code. + +## Why this shape + +- **First-class bucket rows, not a separate UI.** Collab buckets show up + exactly where users already look for buckets. No extra header tiles, no + second "collab+" button — the existing `+ Bucket` workflow just gains a + "collaborative" checkbox. The mental model stays "this is a bucket", the + plugin only layers sync on top. +- **Polling, not push.** No long-lived connection, no WebSocket, no daemon. + 30-second cadence is perfectly fine for the "someone added a card to our + shared bucket" latency envelope and reuses the existing relay HTTP path. +- **Relay as source of truth.** The local mirror is almost stateless — only + `last_seen_at` is user-specific and never leaves the device. Wipes and + re-syncs are trivial. +- **Owner-only writes.** Simpler than ACLs. Owners who want to hand off + control can delete-and-recreate, or we add a transfer endpoint in Phase 4. +- **Soft coupling to core.** Core exposes two tiny hooks + (`window._collabBucketsCreateDialog`, `window.state`) plus a single + column on the buckets table. Everything else — dialog, decoration, + polling, mark-seen — lives in the plugin directory and goes away when + the plugin is disabled. + +## Disabling + +Like every other plugin, add `"collab-buckets"` to the `disabled` array in +`data/plugins.json`. The local table is left in place (not dropped) so +re-enabling doesn't lose unread state. diff --git a/public/plugins/collab-buckets/TODO.md b/public/plugins/collab-buckets/TODO.md new file mode 100644 index 0000000..426f451 --- /dev/null +++ b/public/plugins/collab-buckets/TODO.md @@ -0,0 +1,151 @@ +# collab-buckets plugin — roadmap + +> Phase 1, 2 and 3 are shipped: metadata + membership sync, bucket-bar +> integration, and full card sync with encrypted round-trip and LWW +> conflict resolution. Phase 4 is polish — edit locks, ownership +> transfer, member presence, etc. Phase 5 is hardening. + +## Phase 3 — card sync (DONE ✓) + +- [x] **`collab_card_state` table (local)** — maps a local `cards.id` to + a collab `bucket_uid` + `relay_uid` plus `last_push_at` and + `last_remote_at` for LWW tracking. Poll phase 1 finds dirty cards + via `c.updated_at > cs.last_push_at`. +- [x] **Save propagation without editor coupling** — the poll's push + phase is query-based (no trigger, no hook). Whenever the user + saves a card and the core bumps `cards.updated_at`, the next + poll within 30s finds it dirty and pushes. +- [x] **Relay `collab_cards` table + routes** — `POST /api/collab-cards` + decrypts-reencrypts via inter-tenant key; `GET ?since=` + re-encrypts per recipient; `DELETE /{bucket}/{card}` marks a + tombstone with a 7-day TTL (cleaned up by bootstrap.php). +- [x] **Poll extension** — `collabSyncCards()` runs three phases + (tombstones → push → pull) with a temp-table suppress flag so the + delete trigger doesn't re-enqueue during apply. +- [x] **Card visibility** — collab buckets already take part in the core + bucket-bar's click-to-filter flow, so cards synced into them + automatically show up on the board. +- [x] **Card deletion propagation** — BEFORE DELETE trigger writes to + `collab_tombstones`. Drained in the next poll as a DELETE. + Suppressed during the apply phase so remote deletes don't loop + back as local tombstones. +- [x] **Conflict losers as "(conflict from X)" duplicates** — when the + apply phase is about to LWW-clobber a local card that had edits + since our last push (`local.updated_at > collab_card_state.last_push_at`), + we now INSERT a copy of the local version into a local-only + "Conflicts" bucket with a `(conflict from ) ...` title + prefix before the overwrite. The poll response carries a + `conflicts` count; the frontend pops a toast pointing the user at + the Conflicts bucket when it's non-zero. +- [x] **Moving a card between collab buckets (or out to a local one)** + — the push phase now has a pre-pass that finds cards where + `collab_card_state.bucket_uid` no longer matches the card's + current bucket collab_uid. For each such card it sends a + tombstone to the old bucket. If the card is still in a (different) + collab bucket, state is rewritten with the new `bucket_uid` and + `last_push_at = NULL` so the main push loop re-uploads under the + new bucket. If the card left collab entirely, the state row is + removed. + +## Phase 4 — polish + +- [x] **Per-card edit lock** — when the editor opens on a collab card the + plugin heartbeats `POST /api/collab-locks {bucket_uid, card_uid}` + every 30 s with a 60 s TTL on the relay. The lock list is folded + into the regular `/api/collab-buckets/poll` response so no extra + round-trips are needed. Foreign locks show a 🔒 badge on the + affected board card + a subtle inset outline. Releases on editor + close and best-effort on `beforeunload` via `fetch(..., keepalive)`. + Advisory only — users can still force-edit; LWW + conflict stashing + remain the final arbiter. Core `editor.js` dispatches + `hyprcrd:editor-open` / `hyprcrd:editor-close` `CustomEvent`s so + the plugin hooks in without monkey-patching. +- [x] **Ownership transfer** — `PATCH /api/collab-buckets/{uid}` + accepts `{owner: }`. The relay validates that the new + owner is currently a member, swaps `owner_id` + `owner_username`, + and forces both the caller and the new owner to stay in the member + list on the same request. Surfaced in the UI via a new "Members & + owner…" entry in the bucket context menu that opens a plugin + dialog with a member picker + owner dropdown (only shown when the + caller is the current owner; other members see a read-only view). +- [ ] **Position propagation (optional)** — reorder is purely local right + now. If multiple members expect the same order, we'd need to sync + `position` via the relay, but it's probably fine to leave this as a + per-user preference. +- [ ] **Member presence indicator** — show a small dot next to usernames in + the members panel when they were seen by the relay within the last + minute (reuse chat's heartbeat or add a collab-specific ping). +- [ ] **"Recently active members"** — show avatars (first letter) of + members who touched a card in the last hour, on the collab bucket tab. +- [ ] **Read receipts** — "3 / 5 seen" for the latest update on each + collab bucket. Requires the relay to store per-member `last_seen_at` + — bigger schema change. + +## Phase 5 — hardening & UX + +- [x] **Migration of existing local buckets** — the bucket context menu + on a non-collab bucket shows a "Make collaborative…" entry when + the plugin is loaded. It opens a plugin dialog where the owner + picks members, then `POST /api/collab-buckets/upgrade + {bucket_id, members}` generates a new `uid`, pushes it to the + relay, and writes `collab_uid` onto the same local `buckets` row + — no card shuffling, no id rewrite. The first poll after the + upgrade sees every card in the bucket as dirty (no + `collab_card_state`) and pushes them. +- [x] **Undo for destructive actions** — local bucket delete/leave now + hooks into the existing `pushUndo` system: the undo entry + recreates the bucket via `POST /api/buckets`, restores the + original color+position with PATCH, and re-attaches every card + that pointed at the old row. Collab bucket delete/leave is + intentionally *not* wired — restoring would need to recreate on + the relay under a new uid and re-attach cards there too, which + is messy and less valuable since collab deletes are usually + deliberate. +- [x] **Resilient create via outbox** — a new + `collab_bucket_outbox(id, bucket_id, members_json, queued_at)` + table. The create/upgrade flow always creates the local `buckets` + row first and then tries `collabAttemptUpgrade()`. On relay + failure the intent is queued in the outbox and the next poll + retries. The response includes `queued: true` so the frontend can + show a "saved locally, will sync later" info toast; a follow-up + poll that successfully drains the outbox fires a second toast. + Stale outbox entries for deleted buckets are dropped automatically. +- [x] **Rate limiting on collab endpoints** — every collab route now + funnels through the existing relay `isRateLimited()` helper with + generous caps (120/min for GET, 240/min for POST). Over-limit + requests get a clean `429 rate limited` response. +- [ ] **Inline member picker in the bucket context menu** — currently + needs the manage-modal for any change. A quick `+user` / `-user` + flyout would help for tiny edits. +- [ ] **Owner-side member approval flow** — owners can still add any + relay user unilaterally. A pending-invite state with accept/reject + on the invitee side would be nicer for consent but requires a new + relay table + plumbing. + +## Known limitations (current) + +- **LWW conflicts still clobber on the remote side.** When two users save + the same card within a few seconds, the later writer wins. The earlier + writer gets their local version stashed into a `Conflicts` bucket and + a toast notification — nothing is lost — but their version is no + longer the canonical one anyone else sees. Phase 4 edit-locks prevent + the race in the first place. +- **Up to ~30 s sync latency.** Changes propagate on the next poll, not + instantly. Acceptable for MVP; a "push now" hook after save would cut + latency to ~1 s. +- **Full resync on every poll.** Cheap for small member counts but grows + O(N_buckets × N_members) on the relay side. A `since=updated_at` filter + would help — adding it is safe because the poll also has to handle full + removals (kicked from bucket), and we can fold the deletion list into the + response. +- **Bucket metadata is plaintext on the relay.** Name + color + member list + are visible to a relay admin with DB access. Card content (Phase 3) will + be encrypted end-to-end via the existing decrypt-reencrypt hop; bucket + metadata could follow the same pattern but adds complexity for minimal + gain and makes member picking harder. +- **No offline support.** Creating a bucket while the relay is unreachable + fails hard. Phase 5 outbox pattern would fix this. +- **Race on UID generation.** The client generates `bin2hex(random_bytes(8))` + = 64 bits of entropy. Collisions between independent clients are + astronomically unlikely, but the relay rejects them with 409 if they ever + happen. No retry on the client side yet. diff --git a/public/plugins/collab-buckets/backend.php b/public/plugins/collab-buckets/backend.php new file mode 100644 index 0000000..b7160d4 --- /dev/null +++ b/public/plugins/collab-buckets/backend.php @@ -0,0 +1,876 @@ + create on relay + mirror local + * PATCH /api/collab-buckets/{uid} {name?, color?, members?} -> push to relay + * DELETE /api/collab-buckets/{uid} owner: delete; member: leave + * POST /api/collab-buckets/poll full re-sync from relay + * POST /api/collab-buckets/mark-seen {uid} -> reset dot/pulse state + * GET /api/collab-buckets/relay-tenants proxy to relay /api/tenants for the member-picker + * + * Phase 1+2: bucket metadata + membership only. Card sync lands in Phase 3. + */ + +(function () { + $db = Database::connect(); + $db->exec(" + CREATE TABLE IF NOT EXISTS collab_buckets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#38bdf8', + owner_username TEXT NOT NULL, + members_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT + ) + "); + $db->exec("CREATE INDEX IF NOT EXISTS idx_collab_buckets_uid ON collab_buckets(uid)"); + + // ── Phase 3 schema: card sync tracking ─────────────────────── + // Per-card state: which relay uid and bucket uid the card maps to, plus + // the last time we pushed it and the last remote updated_at we applied. + // `last_push_at` is compared against `cards.updated_at` to find dirty + // cards that need a push. On apply, `last_push_at` is bumped to the + // remote's updated_at so we don't re-push what we just received. + $db->exec(" + CREATE TABLE IF NOT EXISTS collab_card_state ( + card_id INTEGER PRIMARY KEY, + relay_uid TEXT NOT NULL, + bucket_uid TEXT NOT NULL, + last_push_at TEXT, + last_remote_at TEXT, + FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE + ) + "); + $db->exec("CREATE INDEX IF NOT EXISTS idx_collab_card_state_uid ON collab_card_state(relay_uid)"); + + // Tombstone outbox: populated by a BEFORE DELETE trigger on the cards + // table. The poll's push phase drains it and sends each entry as a + // DELETE to the relay. + // + // The trigger fires unconditionally on any collab-card delete, including + // deletions caused by *applying a remote tombstone*. That would loop the + // tombstone back to the relay pointlessly. To break the loop without + // needing a temp-table suppress flag (SQLite forbids triggers on + // permanent tables from referencing `temp.*` objects), the apply phase + // snapshots MAX(id) on `collab_tombstones` before it starts and deletes + // every row with id > that marker after it finishes — i.e. everything + // that the apply phase itself caused. + $db->exec(" + CREATE TABLE IF NOT EXISTS collab_tombstones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + relay_uid TEXT NOT NULL, + bucket_uid TEXT NOT NULL, + queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + "); + // Phase 5: create-outbox for buckets. When the user creates (or + // upgrades) a collab bucket but the relay is unreachable, the local + // row is created anyway and an entry is queued here. The poll drains + // it on every tick by retrying the upgrade. + $db->exec(" + CREATE TABLE IF NOT EXISTS collab_bucket_outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bucket_id INTEGER NOT NULL, + members_json TEXT NOT NULL DEFAULT '[]', + queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + "); + $db->exec(" + CREATE TRIGGER IF NOT EXISTS collab_card_delete_tombstone + BEFORE DELETE ON cards + WHEN OLD.bucket_id IS NOT NULL + AND OLD.relay_uid IS NOT NULL + BEGIN + INSERT INTO collab_tombstones (relay_uid, bucket_uid) + SELECT OLD.relay_uid, b.collab_uid + FROM buckets b + WHERE b.id = OLD.bucket_id AND b.collab_uid IS NOT NULL; + END + "); +})(); + +/** Encrypt a JSON payload with the local relay_key using the same envelope + * format the rest of hyprcrd uses (HYPRCRD-RELAY-V1). */ +function collabEncryptPayload(array $fields): ?string { + $key = collabRelaySettings()['key'] ?? ''; + if ($key === '') return null; + $encKey = substr(hash('sha256', $key), 0, 32); + $plain = json_encode($fields); + $iv = random_bytes(16); + $cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv); + if ($cipher === false) return null; + return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher); +} + +/** Decrypt the inverse. Returns an associative array, or null on failure. */ +function collabDecryptPayload(string $envelope): ?array { + if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return null; + $b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1)); + $raw = base64_decode($b64); + if (!$raw || strlen($raw) < 17) return null; + $key = collabRelaySettings()['key'] ?? ''; + if ($key === '') return null; + $encKey = substr(hash('sha256', $key), 0, 32); + $iv = substr($raw, 0, 16); + $cipher = substr($raw, 16); + $plain = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv); + if ($plain === false) return null; + $data = json_decode($plain, true); + return is_array($data) ? $data : null; +} + +function collabJson($data, int $code = 200): void { + http_response_code($code); + header('Content-Type: application/json'); + echo json_encode($data); + exit; +} + +function collabRelaySettings(): array { + $db = Database::connect(); + return [ + 'url' => $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '', + 'user' => $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '', + 'key' => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '', + ]; +} + +function collabRelayCall(string $path, string $method = 'GET', $body = null): array { + $r = collabRelaySettings(); + if (!$r['url'] || !$r['user'] || !$r['key']) { + return ['ok' => false, 'error' => 'relay not configured (set URL, username, and secret in settings)']; + } + $url = rtrim($r['url'], '/') . $path; + $ch = curl_init($url); + $opts = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'X-Relay-User: ' . $r['user'], + 'X-Relay-Key: ' . $r['key'], + ], + CURLOPT_TIMEOUT => 10, + ]; + if ($body !== null) $opts[CURLOPT_POSTFIELDS] = json_encode($body); + curl_setopt_array($ch, $opts); + $response = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($status !== 200) { + $decoded = json_decode($response ?: '', true); + return [ + 'ok' => false, + 'status' => $status, + 'error' => is_array($decoded) && !empty($decoded['error']) ? $decoded['error'] : "HTTP $status", + 'url' => $url, + ]; + } + $decoded = json_decode($response ?: '', true); + return is_array($decoded) ? $decoded : ['ok' => false, 'error' => 'invalid relay response']; +} + +/** Serialize a local bucket row for the frontend. */ +function collabShapeLocalBucket(array $r): array { + $members = json_decode($r['members_json'] ?? '[]', true) ?: []; + return [ + 'uid' => $r['uid'], + 'name' => $r['name'], + 'color' => $r['color'], + 'owner_username' => $r['owner_username'], + 'members' => $members, + 'created_at' => $r['created_at'], + 'updated_at' => $r['updated_at'], + 'last_seen_at' => $r['last_seen_at'], + // A bucket has unread state if it was never opened OR if remote + // updated it after the last time we opened it. + 'has_unread' => $r['last_seen_at'] === null || $r['updated_at'] > $r['last_seen_at'], + ]; +} + +/** Upsert the corresponding row in the main `buckets` table so the collab + * bucket shows up in the standard bucket bar. Returns the main bucket id. */ +function collabUpsertMainBucket(string $uid, string $name, string $color): int { + $db = Database::connect(); + $stmt = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u'); + $stmt->execute(['u' => $uid]); + $existing = $stmt->fetchColumn(); + if ($existing) { + $db->prepare('UPDATE buckets SET name = :n, color = :c WHERE id = :id') + ->execute(['n' => $name, 'c' => $color, 'id' => $existing]); + return (int)$existing; + } + // Put new collab buckets at the end of the bar + $maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn(); + $db->prepare('INSERT INTO buckets (name, color, collab_uid, position) VALUES (:n, :c, :u, :p)') + ->execute(['n' => $name, 'c' => $color, 'u' => $uid, 'p' => $maxPos + 1]); + return (int)$db->lastInsertId(); +} + +/** Delete the main `buckets` row for a collab bucket (if any) and unlink its + * cards. Uses the same ON DELETE SET NULL semantics as the regular delete path. */ +function collabDeleteMainBucket(string $uid): void { + $db = Database::connect(); + $stmt = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u'); + $stmt->execute(['u' => $uid]); + $bid = $stmt->fetchColumn(); + if (!$bid) return; + $db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $bid]); + $db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $bid]); +} + +/** + * Shared upgrade path: given a local buckets row, generate a fresh uid, + * push the create to the relay, then (on success) rewrite the local + * collab_uid and install the plugin mirror row. Used by both the + * "upgrade existing bucket" route AND the new outbox drain that retries + * failed creates. + * + * Returns an array with: + * ok=true + uid on success + * ok=false + error on relay failure — the caller decides + * whether to queue a retry in the outbox + * ok=true + already=true if the bucket already has a collab_uid + */ +function collabAttemptUpgrade(int $bucketId, array $members): array { + $db = Database::connect(); + $stmt = $db->prepare('SELECT id, name, color, collab_uid FROM buckets WHERE id = :id'); + $stmt->execute(['id' => $bucketId]); + $bucket = $stmt->fetch(); + if (!$bucket) return ['ok' => false, 'error' => 'bucket not found']; + if (!empty($bucket['collab_uid'])) { + return ['ok' => true, 'already' => true, 'uid' => $bucket['collab_uid']]; + } + + $uid = bin2hex(random_bytes(8)); + $res = collabRelayCall('/api/collab-buckets', 'POST', [ + 'uid' => $uid, + 'name' => $bucket['name'], + 'color' => $bucket['color'], + 'members' => $members, + ]); + if (!($res['ok'] ?? false)) { + return ['ok' => false, 'error' => $res['error'] ?? 'relay create failed']; + } + + $db->prepare('UPDATE buckets SET collab_uid = :u WHERE id = :id') + ->execute(['u' => $uid, 'id' => $bucketId]); + + $now = date('Y-m-d H:i:s'); + $self = collabRelaySettings()['user']; + $resolvedMembers = $res['members'] ?? $members; + $db->prepare(' + INSERT INTO collab_buckets + (uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at) + VALUES + (:uid, :n, :c, :ou, :m, :ca, :ua, :ls) + ON CONFLICT(uid) DO UPDATE SET + name = excluded.name, color = excluded.color, + owner_username = excluded.owner_username, + members_json = excluded.members_json, + updated_at = excluded.updated_at, last_seen_at = excluded.last_seen_at + ')->execute([ + 'uid' => $uid, 'n' => $bucket['name'], 'c' => $bucket['color'], + 'ou' => $self, + 'm' => json_encode($resolvedMembers), + 'ca' => $now, 'ua' => $now, + 'ls' => $now, + ]); + return ['ok' => true, 'uid' => $uid]; +} + +/** Find or create a local-only "Conflicts" bucket where LWW losers get + * stashed. Never gets a collab_uid so it doesn't sync anywhere. */ +function collabFindOrCreateConflictsBucket(): int { + $db = Database::connect(); + $stmt = $db->prepare("SELECT id FROM buckets WHERE name = 'Conflicts' AND collab_uid IS NULL"); + $stmt->execute(); + $id = $stmt->fetchColumn(); + if ($id) return (int)$id; + $maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn(); + $db->prepare("INSERT INTO buckets (name, color, position) VALUES ('Conflicts', '#fb7185', :p)") + ->execute(['p' => $maxPos + 1]); + return (int)$db->lastInsertId(); +} + +/** Duplicate a card into the Conflicts bucket with a title marker, so the + * local user can see what they lost before the remote overwrite applied. + * Called from the apply phase when LWW is about to clobber unsynced local + * edits (local.updated_at > last_push_at && remote.updated_at > local). */ +function collabStashConflict(PDO $db, array $local, string $remoteAuthor): void { + $bid = collabFindOrCreateConflictsBucket(); + $origTitle = (string)($local['title'] ?? ''); + $newTitle = '(conflict' . ($remoteAuthor !== '' ? ' from ' . $remoteAuthor : '') . ') ' . $origTitle; + $db->prepare(" + INSERT INTO cards (title, body, color, tags, position, due_date, bucket_id, created_at, updated_at) + VALUES (:t, :b, :c, :tg, :p, :d, :bid, datetime('now'), datetime('now')) + ")->execute([ + 't' => substr($newTitle, 0, 400), + 'b' => $local['body'] ?? '', + 'c' => $local['color'] ?? '#fb7185', + 'tg' => $local['tags'] ?? '', + 'p' => (int)($local['position'] ?? 0), + 'd' => $local['due_date'] ?? null, + 'bid' => $bid, + ]); +} + +/** Build the card payload we send to the relay. Fields that don't matter + * cross-user (parent_id for groups, caldav_*, the autoinc id) are omitted. */ +function collabBuildCardPayload(array $card): array { + return [ + 'title' => $card['title'] ?? '', + 'body' => $card['body'] ?? '', + 'color' => $card['color'] ?? '#38bdf8', + 'tags' => $card['tags'] ?? '', + 'position' => (int)($card['position'] ?? 0), + 'due_date' => $card['due_date'] ?? null, + 'updated_at' => $card['updated_at'] ?? null, + ]; +} + +/** + * Phase 3 sync: push local changes + pull remote deltas for all collab + * buckets. Returns counters for the response body. + * + * Order matters: + * 1. Drain tombstones — pushes delete-your-card notifications before we + * pull, otherwise we might immediately re-apply a remote version of + * the just-deleted card. + * 2. Push dirty cards — `cards.updated_at > collab_card_state.last_push_at` + * means the user edited since we last pushed. Encrypt, POST, stamp + * last_push_at so the next poll skips. + * 3. Pull deltas — since the highest `last_remote_at` we have, apply LWW + * to local rows. The suppress temp-table flag keeps the BEFORE DELETE + * trigger from re-queueing remotely-triggered deletes. + */ +function collabSyncCards(PDO $db): array { + $relay = collabRelaySettings(); + $stats = ['pushed' => 0, 'pulled' => 0, 'deleted_local' => 0, 'tombstones_sent' => 0, 'conflicts' => 0, 'moved' => 0]; + if (!$relay['url'] || !$relay['user'] || !$relay['key']) return $stats; + + // ── 1. Drain tombstones ──────────────────────────────── + $tombRows = $db->query('SELECT id, relay_uid, bucket_uid FROM collab_tombstones ORDER BY id ASC')->fetchAll(); + foreach ($tombRows as $t) { + $res = collabRelayCall( + '/api/collab-cards/' . urlencode($t['bucket_uid']) . '/' . urlencode($t['relay_uid']), + 'DELETE' + ); + if (($res['ok'] ?? false)) { + $db->prepare('DELETE FROM collab_tombstones WHERE id = :id')->execute(['id' => $t['id']]); + $stats['tombstones_sent']++; + } + // If a tombstone push fails it stays queued and gets retried next poll. + } + + // ── 1b. Detect moved cards ───────────────────────────── + // A card previously tracked in collab_card_state under bucket_uid X can + // now be in (a) no bucket, (b) a local bucket without collab_uid, or + // (c) a different collab bucket. In all three cases we need to push a + // tombstone to the original bucket so other members stop seeing the + // stale copy. If the card is still a collab card in a new bucket, the + // normal push phase below picks it up and pushes under the new uid. + $movedRows = $db->query(" + SELECT cs.card_id, cs.relay_uid, cs.bucket_uid AS old_bucket_uid, + c.id IS NULL AS card_missing, + b.collab_uid AS new_bucket_uid + FROM collab_card_state cs + LEFT JOIN cards c ON c.id = cs.card_id + LEFT JOIN buckets b ON b.id = c.bucket_id + WHERE c.id IS NULL + OR b.collab_uid IS NULL + OR b.collab_uid != cs.bucket_uid + ")->fetchAll(); + foreach ($movedRows as $mv) { + // Tombstone on the OLD bucket — members of that bucket should stop + // seeing this card regardless of where it went. + $res = collabRelayCall( + '/api/collab-cards/' . urlencode($mv['old_bucket_uid']) . '/' . urlencode($mv['relay_uid']), + 'DELETE' + ); + if (!($res['ok'] ?? false)) continue; // retry next poll + $stats['moved']++; + + if ($mv['card_missing'] || empty($mv['new_bucket_uid'])) { + // Card is gone or moved out of collab entirely — forget about it + $db->prepare('DELETE FROM collab_card_state WHERE card_id = :id') + ->execute(['id' => $mv['card_id']]); + } else { + // Card moved to a different collab bucket. Rewrite state so the + // push phase below picks it up under the new bucket_uid and also + // resets last_push_at so the push actually fires. + $db->prepare(" + UPDATE collab_card_state + SET bucket_uid = :b, last_push_at = NULL + WHERE card_id = :id + ")->execute(['b' => $mv['new_bucket_uid'], 'id' => $mv['card_id']]); + } + } + + // ── 2. Push dirty cards ──────────────────────────────── + // Find every card in a collab bucket whose updated_at is newer than the + // last time we pushed it (or was never pushed). + $dirty = $db->query(" + SELECT c.id, c.title, c.body, c.color, c.tags, c.position, c.due_date, + c.updated_at, c.relay_uid, b.collab_uid AS bucket_uid, + cs.last_push_at + FROM cards c + INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL + LEFT JOIN collab_card_state cs ON cs.card_id = c.id + WHERE cs.last_push_at IS NULL OR c.updated_at > cs.last_push_at + ")->fetchAll(); + + $assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id'); + $stateUpsert = $db->prepare(" + INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at) + VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL)) + ON CONFLICT(card_id) DO UPDATE SET + relay_uid = excluded.relay_uid, + bucket_uid = excluded.bucket_uid, + last_push_at = excluded.last_push_at + "); + + foreach ($dirty as $card) { + // Ensure the card has a relay_uid (cross-tenant identity) + $relayUid = $card['relay_uid']; + if (!$relayUid) { + $relayUid = bin2hex(random_bytes(8)); + $assignUid->execute(['u' => $relayUid, 'id' => $card['id']]); + } + $payload = collabEncryptPayload(collabBuildCardPayload($card)); + if ($payload === null) continue; // no relay_key or openssl failure + + $res = collabRelayCall('/api/collab-cards', 'POST', [ + 'bucket_uid' => $card['bucket_uid'], + 'card_uid' => $relayUid, + 'payload' => $payload, + ]); + if (!($res['ok'] ?? false)) continue; // retry next poll + + $stateUpsert->execute([ + 'cid' => $card['id'], + 'cid2' => $card['id'], + 'ru' => $relayUid, + 'bu' => $card['bucket_uid'], + 'push' => $res['updated_at'] ?? $card['updated_at'], + ]); + $stats['pushed']++; + } + + // ── 3. Pull deltas ───────────────────────────────────── + $since = $db->query("SELECT value FROM settings WHERE key = 'collab_cards_last_remote_at'")->fetchColumn(); + if (!$since) $since = ''; + $qs = $since !== '' ? ('?since=' . urlencode($since)) : ''; + $res = collabRelayCall('/api/collab-cards' . $qs); + if (!($res['ok'] ?? false)) return $stats; + + $incoming = $res['cards'] ?? []; + $maxTs = $res['max_updated_at'] ?? $since; + if (!$incoming) { + if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs); + return $stats; + } + + // The delete trigger fires unconditionally on collab-card deletes, + // including the ones the apply phase itself causes. To avoid looping + // the delete back to the relay we snapshot the highest tombstone id + // before apply and purge everything > that marker afterwards. + $tombstoneMark = (int)$db->query('SELECT COALESCE(MAX(id), 0) FROM collab_tombstones')->fetchColumn(); + try { + foreach ($incoming as $c) { + // Resolve local bucket id + $bid = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u'); + $bid->execute(['u' => $c['bucket_uid']]); + $localBucketId = $bid->fetchColumn(); + if (!$localBucketId) continue; // bucket mirror missing — shouldn't happen + + // Find local card by relay_uid — grab all fields so we can + // stash a conflict copy if needed before overwriting + $find = $db->prepare('SELECT id, title, body, color, tags, position, due_date, updated_at FROM cards WHERE relay_uid = :u'); + $find->execute(['u' => $c['card_uid']]); + $local = $find->fetch(); + + if (!empty($c['deleted'])) { + if ($local) { + $db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $local['id']]); + $db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')->execute(['id' => $local['id']]); + $stats['deleted_local']++; + } + continue; + } + + // Decrypt payload + $fields = collabDecryptPayload($c['payload'] ?? ''); + if ($fields === null) continue; + + if ($local) { + // LWW: only accept remote if it's strictly newer than local + if (($local['updated_at'] ?? '') >= ($c['updated_at'] ?? '')) continue; + + // Conflict detection: if the local card has local edits that + // were never pushed (local.updated_at > last_push_at), we're + // about to clobber them. Stash a copy into the local-only + // Conflicts bucket before overwriting. + $cs = $db->prepare('SELECT last_push_at FROM collab_card_state WHERE card_id = :id'); + $cs->execute(['id' => $local['id']]); + $lastPushAt = $cs->fetchColumn(); + if ($lastPushAt === false) $lastPushAt = null; + $hasUnpushed = $lastPushAt === null + ? false // first apply of a remote card — no "local edits" yet + : ($local['updated_at'] ?? '') > $lastPushAt; + if ($hasUnpushed) { + collabStashConflict($db, $local, (string)($c['author_username'] ?? '')); + $stats['conflicts']++; + } + + $db->prepare(" + UPDATE cards SET + title = :t, body = :b, color = :c, tags = :tg, + position = :p, due_date = :d, + bucket_id = :bid, + updated_at = :ua + WHERE id = :id + ")->execute([ + 't' => $fields['title'] ?? '', + 'b' => $fields['body'] ?? '', + 'c' => $fields['color'] ?? '#38bdf8', + 'tg' => $fields['tags'] ?? '', + 'p' => (int)($fields['position'] ?? 0), + 'd' => $fields['due_date'] ?? null, + 'bid' => $localBucketId, + 'ua' => $c['updated_at'], + 'id' => $local['id'], + ]); + $localId = (int)$local['id']; + } else { + // New card + $db->prepare(" + INSERT INTO cards (title, body, color, tags, position, due_date, bucket_id, relay_uid, created_at, updated_at) + VALUES (:t, :b, :c, :tg, :p, :d, :bid, :u, :ca, :ua) + ")->execute([ + 't' => $fields['title'] ?? '', + 'b' => $fields['body'] ?? '', + 'c' => $fields['color'] ?? '#38bdf8', + 'tg' => $fields['tags'] ?? '', + 'p' => (int)($fields['position'] ?? 0), + 'd' => $fields['due_date'] ?? null, + 'bid' => $localBucketId, + 'u' => $c['card_uid'], + 'ca' => $c['updated_at'], + 'ua' => $c['updated_at'], + ]); + $localId = (int)$db->lastInsertId(); + } + + // Record the remote state so we don't push it back on the next poll + $db->prepare(" + INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at) + VALUES (:cid, :ru, :bu, :p, :r) + ON CONFLICT(card_id) DO UPDATE SET + relay_uid = excluded.relay_uid, + bucket_uid = excluded.bucket_uid, + last_push_at = excluded.last_push_at, + last_remote_at = excluded.last_remote_at + ")->execute([ + 'cid' => $localId, + 'ru' => $c['card_uid'], + 'bu' => $c['bucket_uid'], + 'p' => $c['updated_at'], + 'r' => $c['updated_at'], + ]); + $stats['pulled']++; + } + } finally { + // Purge any tombstones the apply phase itself created. Tombstones + // queued before apply (by actual user deletes) survive and get + // drained on the next poll iteration. + $db->prepare('DELETE FROM collab_tombstones WHERE id > :m')->execute(['m' => $tombstoneMark]); + } + + if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs); + return $stats; +} + +/** Tiny key/value on the core `settings` table. */ +function collabSaveSetting(string $key, string $value): void { + Database::connect() + ->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (:k, :v)") + ->execute(['k' => $key, 'v' => $value]); +} + +PluginManager::instance()->onApiRoute(function (string $route, string $method) { + + // ── List local mirror ────────────────────────────────────── + if ($route === '/api/collab-buckets' && $method === 'GET') { + $db = Database::connect(); + $rows = $db->query(' + SELECT uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at + FROM collab_buckets + ORDER BY updated_at DESC + ')->fetchAll(); + $out = array_map('collabShapeLocalBucket', $rows); + collabJson(['ok' => true, 'buckets' => $out]); + } + + // ── Create on relay, mirror local ────────────────────────── + // Resilient path: always create the local bucket first so the user + // can keep working even when the relay is down. Then attempt to + // upgrade it via the shared helper. On relay failure we queue the + // upgrade in collab_bucket_outbox and the next poll retries. + if ($route === '/api/collab-buckets' && $method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $name = trim((string)($input['name'] ?? '')); + $color = (string)($input['color'] ?? '#38bdf8'); + $members = is_array($input['members'] ?? null) ? $input['members'] : []; + if ($name === '') collabJson(['ok' => false, 'error' => 'name required'], 400); + + $db = Database::connect(); + $maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn(); + $db->prepare('INSERT INTO buckets (name, color, position) VALUES (:n, :c, :p)') + ->execute(['n' => $name, 'c' => $color, 'p' => $maxPos + 1]); + $bucketId = (int)$db->lastInsertId(); + + $up = collabAttemptUpgrade($bucketId, $members); + if ($up['ok'] ?? false) { + collabJson(['ok' => true, 'uid' => $up['uid'], 'bucket_id' => $bucketId]); + } + // Relay unreachable: queue the upgrade for retry. The bucket + // already exists locally so the user sees it immediately. + $db->prepare('INSERT INTO collab_bucket_outbox (bucket_id, members_json) VALUES (:b, :m)') + ->execute(['b' => $bucketId, 'm' => json_encode($members)]); + collabJson([ + 'ok' => true, + 'queued' => true, + 'bucket_id' => $bucketId, + 'error' => $up['error'] ?? 'relay unreachable — will retry on next poll', + ]); + } + + // ── Update on relay (owner-only) ─────────────────────────── + if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') { + $uid = $m[1]; + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $res = collabRelayCall('/api/collab-buckets/' . urlencode($uid), 'PATCH', $input); + if (!($res['ok'] ?? false)) { + collabJson(['ok' => false, 'error' => $res['error'] ?? 'patch failed'], 500); + } + // Don't mirror the patch locally — the next poll will pick up the new + // state from the relay (keeps the relay as source of truth). + collabJson(['ok' => true]); + } + + // ── Delete / leave ───────────────────────────────────────── + if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') { + $uid = $m[1]; + $res = collabRelayCall('/api/collab-buckets/' . urlencode($uid), 'DELETE'); + if (!($res['ok'] ?? false)) { + collabJson(['ok' => false, 'error' => $res['error'] ?? 'delete failed'], 500); + } + Database::connect()->prepare('DELETE FROM collab_buckets WHERE uid = :u')->execute(['u' => $uid]); + collabDeleteMainBucket($uid); + collabJson(['ok' => true]); + } + + // ── Poll: full resync ────────────────────────────────────── + if ($route === '/api/collab-buckets/poll' && $method === 'POST') { + $db = Database::connect(); + + // Phase 5: drain the create/upgrade outbox first. Each queued row + // is a "tried to make this local bucket collab but the relay was + // down" intent. Retry the upgrade helper; on success drop the + // outbox row; on continued failure leave it queued for next poll. + $outbox = $db->query('SELECT id, bucket_id, members_json FROM collab_bucket_outbox ORDER BY id ASC')->fetchAll(); + $outboxProcessed = 0; + foreach ($outbox as $o) { + $members = json_decode($o['members_json'] ?? '[]', true) ?: []; + $up = collabAttemptUpgrade((int)$o['bucket_id'], $members); + if ($up['ok'] ?? false) { + $db->prepare('DELETE FROM collab_bucket_outbox WHERE id = :id')->execute(['id' => $o['id']]); + $outboxProcessed++; + } elseif (strpos((string)($up['error'] ?? ''), 'bucket not found') !== false) { + // Bucket was deleted locally before the upgrade could fly; + // drop the stale outbox entry instead of retrying forever. + $db->prepare('DELETE FROM collab_bucket_outbox WHERE id = :id')->execute(['id' => $o['id']]); + } + // All other failures: leave queued, retry next poll. + } + + $res = collabRelayCall('/api/collab-buckets'); + if (!($res['ok'] ?? false)) { + collabJson(['ok' => false, 'error' => $res['error'] ?? 'poll failed']); + } + $remote = $res['buckets'] ?? []; + $remoteUids = array_column($remote, 'uid'); + + // Any local bucket that is no longer on the relay for us = we were + // removed (or the bucket was deleted). Drop both the plugin mirror + // and the corresponding row in the main buckets table. + $staleUids = []; + if ($remoteUids) { + $ph = implode(',', array_fill(0, count($remoteUids), '?')); + $sel = $db->prepare("SELECT uid FROM collab_buckets WHERE uid NOT IN ($ph)"); + $sel->execute($remoteUids); + $staleUids = array_column($sel->fetchAll(), 'uid'); + if ($staleUids) { + $stmt = $db->prepare("DELETE FROM collab_buckets WHERE uid NOT IN ($ph)"); + $stmt->execute($remoteUids); + } + } else { + $staleUids = array_column($db->query('SELECT uid FROM collab_buckets')->fetchAll(), 'uid'); + $db->exec('DELETE FROM collab_buckets'); + } + foreach ($staleUids as $uid) { + collabDeleteMainBucket($uid); + } + + // Upsert each remote bucket. Preserve local last_seen_at so the dot + // state survives across polls. + $changed = 0; + $upsert = $db->prepare(" + INSERT INTO collab_buckets + (uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at) + VALUES + (:uid, :n, :c, :ou, :m, :ca, :ua, + COALESCE((SELECT last_seen_at FROM collab_buckets WHERE uid = :uid2), NULL)) + ON CONFLICT(uid) DO UPDATE SET + name = excluded.name, + color = excluded.color, + owner_username = excluded.owner_username, + members_json = excluded.members_json, + created_at = excluded.created_at, + updated_at = excluded.updated_at + "); + $check = $db->prepare('SELECT updated_at FROM collab_buckets WHERE uid = :u'); + foreach ($remote as $b) { + // Always mirror into main buckets table (idempotent upsert) + collabUpsertMainBucket($b['uid'], $b['name'], $b['color']); + + $check->execute(['u' => $b['uid']]); + $existing = $check->fetchColumn(); + if ($existing === ($b['updated_at'] ?? '')) continue; // unchanged, skip plugin-table write + $upsert->execute([ + 'uid' => $b['uid'], + 'n' => $b['name'], + 'c' => $b['color'], + 'ou' => $b['owner_username'], + 'm' => json_encode($b['members'] ?? []), + 'ca' => $b['created_at'], + 'ua' => $b['updated_at'], + 'uid2' => $b['uid'], + ]); + $changed++; + } + + // ── Phase 3: card sync ───────────────────────────────── + $cardStats = collabSyncCards($db); + // If anything changed locally we hint to the frontend to reload + $cardsChanged = ( + $cardStats['pushed'] + + $cardStats['pulled'] + + $cardStats['deleted_local'] + + $cardStats['tombstones_sent'] + + $cardStats['conflicts'] + + $cardStats['moved'] + ) > 0; + + // ── Phase 4: fold the current lock list into the poll response + // so the frontend can decorate cards without a second round-trip. + $locks = []; + $lockRes = collabRelayCall('/api/collab-locks'); + if (($lockRes['ok'] ?? false) && is_array($lockRes['locks'] ?? null)) { + $locks = $lockRes['locks']; + } + + // Any pending outbox entries still waiting for a reachable relay + $outboxPending = (int)$db->query('SELECT COUNT(*) FROM collab_bucket_outbox')->fetchColumn(); + + collabJson([ + 'ok' => true, + 'changed' => $changed, + 'total' => count($remote), + 'cards_changed' => $cardsChanged, + 'card_stats' => $cardStats, + 'locks' => $locks, + 'outbox_processed' => $outboxProcessed, + 'outbox_pending' => $outboxPending, + ]); + } + + // ── Mark a bucket as seen (clears dot + stops pulse next render) ── + if ($route === '/api/collab-buckets/mark-seen' && $method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $uid = trim((string)($input['uid'] ?? '')); + if ($uid === '') collabJson(['ok' => false, 'error' => 'uid required'], 400); + Database::connect() + ->prepare("UPDATE collab_buckets SET last_seen_at = datetime('now') WHERE uid = :u") + ->execute(['u' => $uid]); + collabJson(['ok' => true]); + } + + // ── Proxy: relay tenants (for the create-bucket member picker) ── + // The relay's /api/tenants returns {tenants, self} without an `ok` field, + // so we wrap it here to match the shape the frontend expects. + if ($route === '/api/collab-buckets/relay-tenants' && $method === 'GET') { + $res = collabRelayCall('/api/tenants'); + if (isset($res['ok']) && $res['ok'] === false) { + collabJson($res); + } + collabJson([ + 'ok' => true, + 'tenants' => $res['tenants'] ?? [], + 'self' => $res['self'] ?? collabRelaySettings()['user'], + ]); + } + + // ── Phase 5: upgrade an existing local bucket to collab ─── + // Keeps the same local buckets.id (so cards stay attached), writes a + // new collab_uid onto the row, and creates the mirror on the relay. + // On the very next poll the push phase sees every card in that bucket + // as dirty (no collab_card_state rows) and uploads them. If the relay + // is unreachable, the upgrade is queued in the outbox and retried on + // the next poll. + if ($route === '/api/collab-buckets/upgrade' && $method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $bucketId = (int)($input['bucket_id'] ?? 0); + $members = is_array($input['members'] ?? null) ? $input['members'] : []; + if ($bucketId <= 0) collabJson(['ok' => false, 'error' => 'bucket_id required'], 400); + + $db = Database::connect(); + $up = collabAttemptUpgrade($bucketId, $members); + if ($up['ok'] ?? false) { + if (!empty($up['already'])) collabJson(['ok' => false, 'error' => 'bucket is already collaborative'], 400); + collabJson(['ok' => true, 'uid' => $up['uid']]); + } + // Relay unreachable — queue retry + $db->prepare('INSERT INTO collab_bucket_outbox (bucket_id, members_json) VALUES (:b, :m)') + ->execute(['b' => $bucketId, 'm' => json_encode($members)]); + collabJson([ + 'ok' => true, + 'queued' => true, + 'error' => $up['error'] ?? 'relay unreachable — will retry on next poll', + ]); + } + + // ── Phase 4: edit-lock passthrough ───────────────────────── + if ($route === '/api/collab-locks' && $method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $res = collabRelayCall('/api/collab-locks', 'POST', $input); + collabJson($res); + } + if (preg_match('#^/api/collab-locks/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') { + $res = collabRelayCall('/api/collab-locks/' . urlencode($m[1]) . '/' . urlencode($m[2]), 'DELETE'); + collabJson($res); + } +}); diff --git a/public/plugins/collab-buckets/collab-buckets.css b/public/plugins/collab-buckets/collab-buckets.css new file mode 100644 index 0000000..19d897e --- /dev/null +++ b/public/plugins/collab-buckets/collab-buckets.css @@ -0,0 +1,304 @@ +/* ── Collab buckets plugin ── */ + +/* Decoration for bucket-bar tabs that represent a collab bucket. The core + still renders the tab via its normal `.bucket-tab` rule; the plugin only + layers a few extras on top: a small 👥 icon swap (done in JS), an unread + dot pinned top-right, and a 3-cycle pulse animation when a fresh update + arrives. */ + +.bucket-tab.bucket-tab-collab { + position: relative; + /* Subtle left emphasis so collab tabs read differently at a glance */ + box-shadow: inset 2px 0 0 0 color-mix(in srgb, var(--t-accent2) 70%, transparent); +} + +.bucket-tab.bucket-tab-collab .collab-tab-dot { + position: absolute; + top: -3px; + right: -3px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--t-danger); + border: 1px solid var(--t-bg); + box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 60%, transparent); + pointer-events: none; +} + +.bucket-tab.bucket-tab-collab.pulsing { + animation: collab-bucket-pulse 1.2s ease-in-out 3; +} +/* Keep the 3-cycle cadence regardless of global animation-speed override */ +body.anim-fast .bucket-tab.bucket-tab-collab.pulsing, +body.anim-slow .bucket-tab.bucket-tab-collab.pulsing { + animation: collab-bucket-pulse 1.2s ease-in-out 3 !important; + animation-duration: 1.2s !important; +} +@keyframes collab-bucket-pulse { + 0%, 100% { + box-shadow: inset 2px 0 0 0 color-mix(in srgb, var(--t-accent2) 70%, transparent), + 0 0 0 0 color-mix(in srgb, var(--t-danger) 60%, transparent); + } + 50% { + box-shadow: inset 2px 0 0 0 color-mix(in srgb, var(--t-accent2) 70%, transparent), + 0 0 0 4px color-mix(in srgb, var(--t-danger) 0%, transparent); + } +} + +/* ── Create / edit modal (used by the + Bucket button when the plugin + is loaded) ── */ +.collab-modal-overlay { + position: fixed; + inset: 0; + z-index: 240; + background: var(--t-overlay, rgba(0,0,0,0.6)); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + backdrop-filter: blur(var(--ui-blur-overlay, 0px)); + -webkit-backdrop-filter: blur(var(--ui-blur-overlay, 0px)); +} +.collab-modal { + width: 100%; + max-width: 480px; + max-height: 85vh; + background: var(--t-surface); + border: 1px solid var(--t-border); + border-radius: var(--ui-radius-modal); + box-shadow: var(--ui-shadow-modal); + display: flex; + flex-direction: column; + overflow: hidden; +} +.collab-modal-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid var(--t-border); + flex-shrink: 0; +} +.collab-modal-close { + background: transparent; + border: 1px solid var(--t-border); + border-radius: var(--ui-radius-button); + color: var(--t-muted); + cursor: pointer; + padding: 4px 8px; + display: flex; + align-items: center; + transition: color 0.12s, border-color 0.12s; +} +.collab-modal-close:hover { color: var(--t-danger); border-color: var(--t-danger); } +.collab-modal-body { + padding: 16px; + overflow-y: auto; + flex: 1; + min-height: 0; +} +.collab-field { display: block; margin-bottom: 14px; } +.collab-field:last-child { margin-bottom: 0; } +.collab-field.hidden { display: none; } +.collab-label { + display: block; + font-family: var(--t-font-pixel, 'Silkscreen', cursive); + font-size: 9px; + color: var(--t-dim); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 5px; +} +.collab-input { + width: 100%; + background: var(--t-bg); + border: 1px solid var(--t-border); + border-radius: var(--ui-radius-input); + color: var(--t-bright); + padding: 7px 10px; + font-size: 12px; + font-family: var(--t-font-content, 'JetBrains Mono', monospace); + outline: none; + transition: border-color 0.12s; +} +.collab-input:focus { border-color: var(--t-accent); } +.collab-color { + width: 52px; + height: 32px; + border: 1px solid var(--t-border); + border-radius: var(--ui-radius-input); + background: transparent; + cursor: pointer; + padding: 2px; +} + +.collab-toggle-row { + display: flex !important; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: var(--t-bg); + border: 1px solid var(--t-border); + border-radius: var(--ui-radius-input); + cursor: pointer; + font-size: 12px; + color: var(--t-text); + user-select: none; +} +.collab-toggle-row input { accent-color: var(--t-accent); cursor: pointer; } +.collab-toggle-row:hover { border-color: var(--t-accent); color: var(--t-bright); } + +.collab-members-wrap { margin-top: 10px; } +.collab-members { + border: 1px solid var(--t-border); + border-radius: var(--ui-radius-input); + background: var(--t-bg); + max-height: 200px; + overflow-y: auto; + padding: 4px; +} +.collab-members-empty { + font-size: 11px; + color: var(--t-dim); + font-style: italic; + padding: 8px 10px; +} +.collab-member-row { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + cursor: pointer; + font-size: 12px; + color: var(--t-text); + border-radius: 2px; + user-select: none; +} +.collab-member-row:hover { + background: var(--t-raised); + color: var(--t-bright); +} +.collab-member-row input { cursor: pointer; accent-color: var(--t-accent); } + +.collab-modal-footer { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--t-border); + flex-shrink: 0; +} +.collab-modal-status { + font-family: var(--t-font-pixel, 'Silkscreen', cursive); + font-size: 9px; + color: var(--t-dim); + min-height: 14px; +} +.collab-btn-cancel, +.collab-btn-submit { + padding: 6px 14px; + font-family: var(--t-font-pixel, 'Silkscreen', cursive); + font-size: 10px; + background: transparent; + cursor: pointer; + border: 1px solid var(--t-border); + border-radius: var(--ui-radius-button); + color: var(--t-muted); + transition: color 0.12s, border-color 0.12s, background 0.12s; +} +.collab-btn-cancel:hover { + color: var(--t-danger); + border-color: var(--t-danger); +} +.collab-btn-submit { + color: var(--t-accent); + border-color: var(--t-accent); +} +.collab-btn-submit:hover { + background: color-mix(in srgb, var(--t-accent) 12%, transparent); +} + +.collab-readonly { + display: block; + padding: 7px 10px; + background: var(--t-bg); + border: 1px solid var(--t-border); + border-radius: var(--ui-radius-input); + color: var(--t-text); + font-family: var(--t-font-content, 'JetBrains Mono', monospace); + font-size: 12px; +} +.collab-hint { + margin-top: 4px; + font-size: 10px; + color: var(--t-dim); + font-style: italic; +} + +/* ── Phase 4: edit-lock indicator on board cards ── */ +.board-card.collab-locked { + box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--t-accent2) 55%, transparent); +} +.collab-lock-badge { + position: absolute; + top: 6px; + right: 6px; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + color: var(--t-accent2); + background: color-mix(in srgb, var(--t-surface) 92%, transparent); + border: 1px solid color-mix(in srgb, var(--t-accent2) 60%, var(--t-border)); + border-radius: 50%; + z-index: 6; + pointer-events: none; + font-size: 11px; +} + +/* ── Conflict toast (LWW losers stashed into the Conflicts bucket) ── */ +.collab-conflict-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 220; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px 10px 12px; + max-width: 480px; + font-family: var(--t-font-pixel, 'Silkscreen', cursive); + font-size: 10px; + color: var(--t-bright); + background: var(--t-surface); + border: 1px solid var(--t-danger); + border-radius: var(--ui-radius-modal); + box-shadow: var(--ui-shadow-modal); + transition: opacity 0.4s; +} +.collab-conflict-toast i.ph-warning { color: var(--t-danger); flex-shrink: 0; } +.collab-conflict-toast b { color: var(--t-danger); font-weight: 700; } +.collab-conflict-toast.fade-out { opacity: 0; } +/* Queued variant reuses the layout but switches the danger accents for + a neutral info color — used for "relay was down, bucket is queued" and + "queued bucket finally synced" messages. */ +.collab-conflict-toast.collab-queued-toast { + border-color: var(--t-accent2); +} +.collab-conflict-toast.collab-queued-toast i.ph-cloud-arrow-up { + color: var(--t-accent2); + flex-shrink: 0; +} +.collab-conflict-close { + background: transparent; + border: none; + color: var(--t-dim); + cursor: pointer; + padding: 2px 4px; + margin-left: 6px; + display: flex; + align-items: center; +} +.collab-conflict-close:hover { color: var(--t-danger); } diff --git a/public/plugins/collab-buckets/collab-buckets.js b/public/plugins/collab-buckets/collab-buckets.js new file mode 100644 index 0000000..b8ec215 --- /dev/null +++ b/public/plugins/collab-buckets/collab-buckets.js @@ -0,0 +1,719 @@ +// ── Collab Buckets Plugin Frontend ── +// +// Phase 1+2 shape (post-refactor): collab buckets are first-class bucket +// rows in the standard bucket bar. The plugin just owns the sync + the +// visual unread-state decoration: +// +// - Polls /api/collab-buckets/poll every POLL_INTERVAL_MS. The backend +// mirrors each remote bucket into the main `buckets` table so the core +// bucket bar renders it automatically. +// - Exposes `window._collabBucketsCreateDialog` — an async function that +// the core + Bucket button calls so we can pop a richer "new bucket" +// modal with an optional "collaborative" checkbox + member picker. +// - Watches #bucket-bar via MutationObserver. After every render, finds +// tabs whose data-bucket id corresponds to a collab bucket and attaches +// a pulse animation + red unread dot if the bucket has unread state. +// - Delegated click handler on #bucket-bar calls mark-seen for collab tabs. +// +// The plugin never touches the header anymore. No more standalone tiles. + +const hyprcrd = window.hyprcrd; +if (!hyprcrd) throw new Error('[collab-buckets] window.hyprcrd missing'); + +const $ = (id) => document.getElementById(id); + +// uid -> collab bucket meta (from /api/collab-buckets) +let collabByUid = new Map(); +let pollHandle = null; +const POLL_INTERVAL_MS = 30000; + +// Phase 4: edit locks +// card_uid -> { bucket_uid, holder_username, expires_at } +let collabLocks = new Map(); +// Active heartbeat for the currently open editor (if editing a collab card) +let activeLock = null; // { bucket_uid, card_uid, timer } +const LOCK_HEARTBEAT_MS = 30000; + +// ── Helpers ─────────────────────────────────────────────────────── + +function escapeHtml(str) { + return String(str ?? '') + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +// ── Loading / polling ───────────────────────────────────────────── + +async function loadCollabMeta() { + try { + const res = await fetch('api/collab-buckets').then(r => r.json()); + if (!res?.ok) return; + const next = new Map(); + for (const b of res.buckets || []) next.set(b.uid, b); + collabByUid = next; + // Expose for other plugin code + for debugging + window._collabBucketsByUid = collabByUid; + decorateBucketBar(); + } catch (err) { /* silent; relay may be offline */ } +} + +async function pollOnce() { + try { + const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' }); + if (!res?.ok) return; + // Reload if: + // * a bucket changed (metadata / members) + // * the bucket set shrunk or grew + // * card sync pushed / pulled / deleted anything (Phase 3) + const bucketChanged = res.changed > 0 || res.total !== collabByUid.size; + const cardsChanged = !!res.cards_changed; + if (bucketChanged || cardsChanged) { + await loadCollabMeta(); + if (typeof window._reloadAll === 'function') await window._reloadAll(); + } + // If LWW clobbered local edits, the backend stashed them in a local + // "Conflicts" bucket — surface that visibly so the user knows. + const conflicts = res.card_stats?.conflicts || 0; + if (conflicts > 0) { + showConflictToast(conflicts); + } + // Phase 5: if a queued bucket create finally pushed through, tell + // the user. Retry failures stay silent. + if ((res.outbox_processed || 0) > 0) { + const n = res.outbox_processed; + showQueuedToast(`${n} queued bucket${n === 1 ? '' : 's'} synced to the relay.`); + } + // Phase 4: edit-lock list comes back with the poll + if (Array.isArray(res.locks)) { + const next = new Map(); + for (const l of res.locks) next.set(l.card_uid, l); + collabLocks = next; + window._collabLocks = collabLocks; + decorateBoardLocks(); + } + } catch { /* silent */ } +} + +function showConflictToast(n) { + const existing = document.getElementById('collab-conflict-toast'); + if (existing) existing.remove(); + const toast = document.createElement('div'); + toast.id = 'collab-conflict-toast'; + toast.className = 'collab-conflict-toast'; + toast.innerHTML = ` + + ${n} local change${n === 1 ? '' : 's'} overwritten by a collaborator — backup in the Conflicts bucket. + + `; + document.body.appendChild(toast); + toast.querySelector('.collab-conflict-close').addEventListener('click', () => toast.remove()); + setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 10000); +} + +/** Informational toast — used when the relay is unreachable and a bucket + * create was queued, or when the outbox retry succeeds later. */ +function showQueuedToast(message) { + const existing = document.getElementById('collab-queued-toast'); + if (existing) existing.remove(); + const toast = document.createElement('div'); + toast.id = 'collab-queued-toast'; + toast.className = 'collab-conflict-toast collab-queued-toast'; + toast.innerHTML = ` + + ${escapeHtml(message)} + + `; + document.body.appendChild(toast); + toast.querySelector('.collab-conflict-close').addEventListener('click', () => toast.remove()); + setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000); +} + +function startPolling() { + stopPolling(); + pollHandle = setInterval(pollOnce, POLL_INTERVAL_MS); +} +function stopPolling() { + if (pollHandle) { clearInterval(pollHandle); pollHandle = null; } +} + +// ── Bucket bar decoration ───────────────────────────────────────── + +/** + * For every tab in #bucket-bar whose bucket has a collab_uid, apply + * collab-specific classes (.bucket-tab-collab, .has-unread, .pulsing) + * and inject an unread dot. Runs after every core re-render via the + * MutationObserver below. + */ +function decorateBucketBar() { + const bar = document.getElementById('bucket-bar'); + if (!bar || !window.state?.buckets) return; + + // Build bucket-id -> row map so we can look up collab_uid + const byId = new Map(); + for (const b of window.state.buckets) byId.set(String(b.id), b); + + for (const tab of bar.querySelectorAll('.bucket-tab[data-bucket]')) { + const bid = tab.dataset.bucket; + const row = byId.get(bid); + if (!row || !row.collab_uid) continue; + + tab.classList.add('bucket-tab-collab'); + tab.dataset.collabUid = row.collab_uid; + + // Replace the default folder icon with the "users" icon so the tab + // reads as collaborative at a glance. We query by class and swap + // the phosphor icon class set. Safe to run on every render — the + // "icon swap" is idempotent. + const icon = tab.querySelector('i.ph'); + if (icon && !icon.classList.contains('ph-users-three')) { + icon.classList.remove('ph-folder'); + icon.classList.add('ph-users-three'); + } + + // Unread state + const meta = collabByUid.get(row.collab_uid); + const wasUnread = tab.classList.contains('has-unread'); + const isUnread = !!meta?.has_unread; + tab.classList.toggle('has-unread', isUnread); + + // Inject / remove dot element + let dot = tab.querySelector('.collab-tab-dot'); + if (isUnread && !dot) { + dot = document.createElement('span'); + dot.className = 'collab-tab-dot'; + tab.appendChild(dot); + } else if (!isUnread && dot) { + dot.remove(); + } + + // Fresh unread -> pulse 3x. JS strips the class after 3.7s so + // subsequent decorate-calls don't retrigger mid-animation. + if (isUnread && !wasUnread) { + tab.classList.add('pulsing'); + setTimeout(() => tab.classList.remove('pulsing'), 3700); + } + } +} + +// ── Mark-seen on click (delegated, fires after core's click handler) ── + +function setupClickInterceptor() { + const bar = document.getElementById('bucket-bar'); + if (!bar) return; + bar.addEventListener('click', async (e) => { + const tab = e.target.closest('.bucket-tab[data-bucket]'); + if (!tab) return; + const uid = tab.dataset.collabUid; + if (!uid) return; + // Clear unread locally immediately for snappy feedback + const meta = collabByUid.get(uid); + if (meta) meta.has_unread = false; + tab.classList.remove('has-unread', 'pulsing'); + tab.querySelector('.collab-tab-dot')?.remove(); + // Persist to the plugin backend + hyprcrd.api('api/collab-buckets/mark-seen', { method: 'POST', body: { uid } }).catch(() => {}); + }); +} + +// ── MutationObserver: redecorate on every bar re-render ─────────── + +function setupBarObserver() { + const bar = document.getElementById('bucket-bar'); + if (!bar) return; + const obs = new MutationObserver(() => decorateBucketBar()); + obs.observe(bar, { childList: true, subtree: true }); +} + +// ── Create dialog (hooked by core's + Bucket button) ────────────── + +async function fetchRelayTenants() { + try { + const res = await hyprcrd.api('api/collab-buckets/relay-tenants'); + return res?.ok ? res : null; + } catch { return null; } +} + +/** + * Shown when the user clicks the core "+ Bucket" button. Returns true when + * a bucket was created (so core can reload) and false / null when canceled. + */ +async function createBucketDialog() { + // Lazy-load the relay tenants so a local-only bucket doesn't pay for + // the round trip when the user just wants a simple bucket. + const relayP = fetchRelayTenants(); + + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'collab-modal-overlay'; + overlay.innerHTML = ` +
+
+ + new bucket +
+ +
+
+ + + + +
+ +
+ `; + document.body.appendChild(overlay); + + const done = (created) => { overlay.remove(); resolve(created); }; + overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false)); + overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false)); + overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); }); + document.addEventListener('keydown', function esc(e) { + if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); } + }); + + const collabEl = overlay.querySelector('#__cb_collab'); + const membersWrap = overlay.querySelector('#__cb_members_wrap'); + const membersList = overlay.querySelector('#__cb_members_list'); + const status = overlay.querySelector('#__cb_status'); + + let peers = []; + let tenantsLoaded = false; + + collabEl.addEventListener('change', async () => { + if (!collabEl.checked) { + membersWrap.classList.add('hidden'); + return; + } + membersWrap.classList.remove('hidden'); + if (tenantsLoaded) return; + const relay = await relayP; + tenantsLoaded = true; + if (!relay) { + membersList.innerHTML = '
could not reach the relay — uncheck collaborative to create a local bucket
'; + return; + } + const self = relay.self || ''; + peers = (relay.tenants || []).filter(t => (t.username || t) !== self); + if (!peers.length) { + membersList.innerHTML = '
no other relay users
'; + return; + } + membersList.innerHTML = peers.map(t => { + const u = typeof t === 'string' ? t : (t.username || ''); + return ``; + }).join(''); + }); + + overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => { + const name = overlay.querySelector('#__cb_name').value.trim(); + const color = overlay.querySelector('#__cb_color').value; + const isCollab = collabEl.checked; + if (!name) { status.textContent = 'name required'; status.style.color = 'var(--t-danger)'; return; } + status.textContent = 'creating…'; + status.style.color = 'var(--t-dim)'; + try { + if (isCollab) { + const members = [...overlay.querySelectorAll('#__cb_members_list input:checked')].map(c => c.value); + const res = await hyprcrd.api('api/collab-buckets', { method: 'POST', body: { name, color, members } }); + if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; } + // Relay was unreachable; the bucket exists locally and the + // upgrade is queued. Show a toast so the user knows. + if (res.queued) { + showQueuedToast('Bucket created locally — will sync to the relay on the next successful poll.'); + } + } else { + // Regular bucket: POST /api/buckets (core route), then PATCH color. + const created = await hyprcrd.api('api/buckets', { method: 'POST', body: { name } }); + if (created?.id && color) { + await hyprcrd.api(`api/buckets/${created.id}`, { method: 'PATCH', body: { color } }); + } + } + await loadCollabMeta(); + done(true); + } catch (err) { + status.textContent = String(err?.message || err); + status.style.color = 'var(--t-danger)'; + } + }); + + setTimeout(() => overlay.querySelector('#__cb_name')?.focus(), 50); + }); +} + +// Expose for core's + Bucket handler +window._collabBucketsCreateDialog = createBucketDialog; + +/** + * Edit dialog for an existing collab bucket. Shown by the core bucket + * context menu's "Members & owner…" item. The current owner sees a + * member picker + an owner dropdown; other members see everything as + * read-only labels since only the owner can push updates. Returns true + * when the PATCH succeeded so core can reload. + */ +async function editBucketDialog(collabUid) { + const bucket = collabByUid.get(collabUid); + if (!bucket) return false; + const relay = await fetchRelayTenants(); + if (!relay) { + await hyprcrd.dialog.show({ + title: 'collab buckets', + message: 'Could not reach the relay.', + okLabel: 'Ok', cancelLabel: '', + }); + return false; + } + const self = relay.self || ''; + const allTenants = (relay.tenants || []).map(t => typeof t === 'string' ? t : (t.username || '')); + const peers = allTenants.filter(u => u && u !== self); + const isOwner = bucket.owner_username === self; + const currentMembers = new Set((bucket.members || []).filter(u => u && u !== self)); + + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'collab-modal-overlay'; + overlay.innerHTML = ` +
+
+ + ${escapeHtml(bucket.name)} — members +
+ +
+
+
+ Owner + ${isOwner + ? ` +
Transferring will hand over all edit rights. You stay a member.
` + : `
${escapeHtml(bucket.owner_username)}
` + } +
+
+ Members (${peers.length} available) + ${isOwner + ? `
+ ${peers.map(u => { + const checked = currentMembers.has(u) ? 'checked' : ''; + return ``; + }).join('') || '
no other relay users
'} +
` + : `
+ ${(bucket.members || []).map(u => `
${escapeHtml(u)}${u === self ? ' (you)' : ''}
`).join('')} +
+
Only the owner can change members or transfer ownership.
` + } +
+
+ +
+ `; + document.body.appendChild(overlay); + + const done = (changed) => { overlay.remove(); resolve(changed); }; + overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false)); + overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false)); + overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); }); + document.addEventListener('keydown', function esc(e) { + if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); } + }); + + if (!isOwner) return; // read-only view; no submit handler + + overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => { + const newOwner = overlay.querySelector('#__cb_owner').value; + const members = [...overlay.querySelectorAll('#__cb_edit_members input:checked')].map(c => c.value); + const status = overlay.querySelector('#__cb_status'); + const payload = { members }; + if (newOwner && newOwner !== self) payload.owner = newOwner; + status.textContent = 'saving…'; + status.style.color = 'var(--t-dim)'; + try { + const res = await hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), { + method: 'PATCH', body: payload, + }); + if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; } + await pollOnce(); + await loadCollabMeta(); + done(true); + } catch (err) { + status.textContent = String(err?.message || err); + status.style.color = 'var(--t-danger)'; + } + }); + }); +} + +window._collabBucketsEditDialog = editBucketDialog; + +/** + * Upgrade dialog for turning an existing local bucket into a collab + * bucket. The core bucket context menu shows "Make collaborative…" for + * non-collab buckets when the plugin is loaded. The dialog just picks + * members — name/color come from the existing bucket and are reused. + * Returns true on success so core can reload. + */ +async function upgradeBucketDialog(localBucketId) { + const bucket = window.state?.buckets?.find(b => String(b.id) === String(localBucketId)); + if (!bucket || bucket.collab_uid) return false; + const relay = await fetchRelayTenants(); + if (!relay) { + await hyprcrd.dialog.show({ + title: 'collab buckets', + message: 'Could not reach the relay. Check the relay settings first.', + okLabel: 'Ok', cancelLabel: '', + }); + return false; + } + const self = relay.self || ''; + const peers = (relay.tenants || []).map(t => typeof t === 'string' ? t : (t.username || '')).filter(u => u && u !== self); + + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'collab-modal-overlay'; + overlay.innerHTML = ` +
+
+ + make "${escapeHtml(bucket.name)}" collaborative +
+ +
+
+
+
This bucket and all its cards will be shared with the members you pick. Existing cards are pushed on the next sync (~30s). You stay the owner.
+
+
+ Members (${peers.length} available) + ${peers.length === 0 + ? '
no other relay users
' + : `
+ ${peers.map(u => ``).join('')} +
` + } +
+
+ +
+ `; + document.body.appendChild(overlay); + + const done = (changed) => { overlay.remove(); resolve(changed); }; + overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false)); + overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false)); + overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); }); + document.addEventListener('keydown', function esc(e) { + if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); } + }); + + overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => { + const status = overlay.querySelector('#__cb_status'); + const members = [...overlay.querySelectorAll('#__cb_up_members input:checked')].map(c => c.value); + status.textContent = 'upgrading…'; + status.style.color = 'var(--t-dim)'; + try { + const res = await hyprcrd.api('api/collab-buckets/upgrade', { + method: 'POST', + body: { bucket_id: localBucketId, members }, + }); + if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; } + await pollOnce(); + await loadCollabMeta(); + done(true); + } catch (err) { + status.textContent = String(err?.message || err); + status.style.color = 'var(--t-danger)'; + } + }); + }); +} + +window._collabBucketsUpgradeDialog = upgradeBucketDialog; + +// Expose helper so the core bucket context menu can route collab-bucket +// rename / color / delete through the plugin endpoints. +window._collabBucketsPatch = async function (collabUid, payload) { + return hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), { method: 'PATCH', body: payload }); +}; +window._collabBucketsDelete = async function (collabUid) { + return hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), { method: 'DELETE' }); +}; + +// ── Phase 4: board-card lock decoration ─────────────────────────── + +/** + * Walk every rendered board card and add / remove a lock indicator if + * another user currently holds an edit lock on it. Idempotent — safe to + * call on every render / lock-poll. + */ +function decorateBoardLocks() { + const board = document.getElementById('board'); + if (!board || !window.state?.cards) return; + // Build card_id -> card row lookup so we can resolve relay_uid + const byId = new Map(); + for (const c of window.state.cards) byId.set(String(c.id), c); + + const self = window._collabSelf || null; + for (const el of board.querySelectorAll('.board-card[data-id]')) { + const card = byId.get(el.dataset.id); + const lock = card && card.relay_uid ? collabLocks.get(card.relay_uid) : null; + // Ignore our own lock — no point showing the user their own editing state + const foreign = lock && lock.holder_username && lock.holder_username !== self; + el.classList.toggle('collab-locked', !!foreign); + let badge = el.querySelector('.collab-lock-badge'); + if (foreign) { + if (!badge) { + badge = document.createElement('div'); + badge.className = 'collab-lock-badge'; + badge.innerHTML = ''; + el.appendChild(badge); + } + badge.title = `Being edited by ${lock.holder_username}`; + } else if (badge) { + badge.remove(); + } + } +} + +// Re-decorate whenever the board re-renders (reuses the existing #board +// MutationObserver pattern). +function setupBoardObserver() { + const board = document.getElementById('board'); + if (!board) return; + const obs = new MutationObserver(() => decorateBoardLocks()); + obs.observe(board, { childList: true, subtree: false }); +} + +// ── Phase 4: heartbeat the lock while editing a collab card ─────── + +/** + * Resolve a card to its collab bucket_uid + relay_uid. Returns null for + * non-collab cards (which don't get a lock). + */ +function collabIdentityOf(card) { + if (!card || !card.bucket_id) return null; + const bucket = window.state?.buckets?.find(b => String(b.id) === String(card.bucket_id)); + if (!bucket?.collab_uid) return null; + if (!card.relay_uid) return null; // not yet pushed → no lock + return { bucket_uid: bucket.collab_uid, card_uid: card.relay_uid }; +} + +async function acquireLock(bucket_uid, card_uid) { + try { + await hyprcrd.api('api/collab-locks', { + method: 'POST', + body: { bucket_uid, card_uid }, + }); + } catch { /* silent */ } +} + +async function releaseLock(bucket_uid, card_uid) { + try { + await hyprcrd.api( + 'api/collab-locks/' + encodeURIComponent(bucket_uid) + '/' + encodeURIComponent(card_uid), + { method: 'DELETE' }, + ); + } catch { /* silent */ } +} + +function startHeartbeat(bucket_uid, card_uid) { + stopHeartbeat(); + activeLock = { bucket_uid, card_uid, timer: null }; + acquireLock(bucket_uid, card_uid); + activeLock.timer = setInterval(() => acquireLock(bucket_uid, card_uid), LOCK_HEARTBEAT_MS); +} + +function stopHeartbeat() { + if (!activeLock) return; + const { bucket_uid, card_uid, timer } = activeLock; + if (timer) clearInterval(timer); + releaseLock(bucket_uid, card_uid); + activeLock = null; +} + +window.addEventListener('hyprcrd:editor-open', (e) => { + const card = e.detail?.card; + const id = collabIdentityOf(card); + if (id) startHeartbeat(id.bucket_uid, id.card_uid); +}); +window.addEventListener('hyprcrd:editor-close', () => { + stopHeartbeat(); +}); +// Graceful release on tab close +window.addEventListener('beforeunload', () => { + if (activeLock) { + // Best-effort sync release; browsers may block regular fetch, so + // use sendBeacon when available. + try { + if (navigator.sendBeacon) { + const url = 'api/collab-locks/' + encodeURIComponent(activeLock.bucket_uid) + '/' + encodeURIComponent(activeLock.card_uid); + // sendBeacon only supports POST; use fetch keepalive as the + // DELETE fallback. Both are best-effort at unload time. + fetch(url, { method: 'DELETE', keepalive: true }).catch(() => {}); + } + } catch { /* ignore */ } + } +}); + +// ── Init ────────────────────────────────────────────────────────── + +async function init() { + // Wait for #bucket-bar to exist (core renders it on startup) + if (!document.getElementById('bucket-bar')) { + setTimeout(init, 100); + return; + } + setupBarObserver(); + setupClickInterceptor(); + setupBoardObserver(); + await loadCollabMeta(); + await pollOnce(); + await loadCollabMeta(); + decorateBucketBar(); + decorateBoardLocks(); + startPolling(); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, { once: true }); +} else { + init(); +} + +hyprcrd.registerPlugin?.({ + name: 'collab-buckets', + version: '1.1.0', + description: 'Collaborative buckets shared via the relay. First-class rows in the bucket bar with unread pulse/dot.', +}); diff --git a/public/plugins/collab-buckets/plugin.json b/public/plugins/collab-buckets/plugin.json new file mode 100644 index 0000000..1de7c63 --- /dev/null +++ b/public/plugins/collab-buckets/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "collab-buckets", + "version": "1.0.0", + "description": "Collaborative buckets shared between relay tenants. Polls the relay for bucket updates and shows pulsing header tiles with unread indicators.", + "frontend": { + "js": ["collab-buckets.js"], + "css": ["collab-buckets.css"] + }, + "backend": "backend.php" +} diff --git a/relay/inc/bootstrap.php b/relay/inc/bootstrap.php index 1e0db8c..576a89d 100644 --- a/relay/inc/bootstrap.php +++ b/relay/inc/bootstrap.php @@ -181,6 +181,64 @@ $db->exec('CREATE TABLE IF NOT EXISTS chat_messages ( )'); $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_recipient ON chat_messages(recipient_id, fetched, id)'); +// Collaborative buckets: shared among multiple tenants. Each bucket has one +// owner and a membership set. Buckets persist (not ephemeral) since they are +// the container for ongoing collaboration — cleanup only when deleted. +$db->exec('CREATE TABLE IF NOT EXISTS collab_buckets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid TEXT UNIQUE NOT NULL, + owner_id INTEGER NOT NULL, + owner_username TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT "#38bdf8", + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE +)'); +$db->exec('CREATE TABLE IF NOT EXISTS collab_bucket_members ( + bucket_uid TEXT NOT NULL, + tenant_id INTEGER NOT NULL, + username TEXT NOT NULL, + added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (bucket_uid, tenant_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +)'); +$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_member_tenant ON collab_bucket_members(tenant_id, bucket_uid)'); +$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_bucket_updated ON collab_buckets(updated_at)'); + +// Collab cards: the actual synced content, stored at-rest under the +// inter-tenant key. Payload is an encrypted JSON blob containing title, +// body, color, tags, due_date, position. Tombstoned rows (deleted=1) +// survive for 7 days so late pollers see the deletion. +$db->exec('CREATE TABLE IF NOT EXISTS collab_cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bucket_uid TEXT NOT NULL, + card_uid TEXT NOT NULL, + author_username TEXT NOT NULL, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INTEGER NOT NULL DEFAULT 0, + UNIQUE (bucket_uid, card_uid) +)'); +$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_cards_bucket_updated ON collab_cards(bucket_uid, updated_at)'); + +// Phase 4: advisory edit locks on collab cards. A client that opens the +// editor for a collab card heartbeats into this table every 30s with a +// 60s TTL. Other clients poll the list and show a lock indicator on the +// card. The lock is purely advisory — force-editing is still allowed and +// LWW remains the authoritative conflict resolver. +$db->exec('CREATE TABLE IF NOT EXISTS collab_card_locks ( + bucket_uid TEXT NOT NULL, + card_uid TEXT NOT NULL, + holder_id INTEGER NOT NULL, + holder_username TEXT NOT NULL, + acquired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + PRIMARY KEY (bucket_uid, card_uid, holder_id), + FOREIGN KEY (holder_id) REFERENCES tenants(id) ON DELETE CASCADE +)'); +$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_card_locks_expires ON collab_card_locks(expires_at)'); + $db->exec('CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, tenant_id INTEGER NOT NULL, @@ -200,6 +258,10 @@ $db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')" $db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')"); // Chat: 7 days, longer than card sync but still ephemeral $db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')"); +// Collab-card tombstones: 7 days so late pollers still see the delete. +$db->exec("DELETE FROM collab_cards WHERE deleted = 1 AND updated_at < datetime('now', '-7 days')"); +// Collab-card locks: drop everything past its TTL on every request. +$db->exec("DELETE FROM collab_card_locks WHERE expires_at < datetime('now')"); $db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')"); $db->exec("DELETE FROM rate_limits WHERE attempted_at < datetime('now', '-5 minutes')"); diff --git a/relay/inc/routes/collab-buckets.php b/relay/inc/routes/collab-buckets.php new file mode 100644 index 0000000..5123ffa --- /dev/null +++ b/relay/inc/routes/collab-buckets.php @@ -0,0 +1,166 @@ + '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(); + jsonResponse(['error' => 'create failed: ' . $e->getMessage()], 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' => "no such user: $newOwnerName"], 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']); + } +} diff --git a/relay/inc/routes/collab-cards.php b/relay/inc/routes/collab-cards.php new file mode 100644 index 0000000..334306a --- /dev/null +++ b/relay/inc/routes/collab-cards.php @@ -0,0 +1,167 @@ + pull deltas since a timestamp +// DELETE /api/collab-cards/{bucket}/{card} tombstone a card +// +// Encryption hop (same pattern as chat.php): +// POST: arrives encrypted with the sender's client secret → relay +// decrypts with that secret (via masterDecrypt of their +// secret_encrypted) → re-encrypts under the inter-tenant key → +// stored at rest. +// GET: for each row visible to the caller, decrypt from inter-tenant +// key → re-encrypt with the recipient's client secret → return. +// +// Membership check: every operation requires the caller to be a current +// member of the bucket_uid referenced. Drops silently when the caller +// leaves or is kicked. + +if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; } + +$clientTenant = $clientTenant ?? authClient($db); + +/** Is $tenantId a member of $bucketUid right now? */ +function collabCardsIsMember(PDO $db, string $bucketUid, int $tenantId): bool { + $stmt = $db->prepare('SELECT 1 FROM collab_bucket_members WHERE bucket_uid = :b AND tenant_id = :t'); + $stmt->execute(['b' => $bucketUid, 't' => $tenantId]); + return (bool)$stmt->fetchColumn(); +} + +// ── POST: upsert a card ────────────────────────────────────────── + +if ($route === '/api/collab-cards' && $method === 'POST') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + if (isRateLimited($db, 'collab-cards-post', 240)) jsonResponse(['error' => 'rate limited'], 429); + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $bucketUid = trim((string)($input['bucket_uid'] ?? '')); + $cardUid = trim((string)($input['card_uid'] ?? '')); + $encEnvelope = (string)($input['payload'] ?? ''); + if ($bucketUid === '' || $cardUid === '' || $encEnvelope === '') { + jsonResponse(['error' => 'bucket_uid, card_uid, payload required'], 400); + } + if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) { + jsonResponse(['error' => 'not a member of this bucket'], 403); + } + if (empty($clientTenant['secret_encrypted'])) { + jsonResponse(['error' => 'sender has no client secret set — log into the relay UI and configure one in your account'], 400); + } + $senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY); + if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500); + $interKey = getInterTenantKey($db, $MASTER_KEY); + if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set — admin must complete setup'], 500); + + $plain = clientDecrypt($encEnvelope, $senderSecret); + if ($plain === '') jsonResponse(['error' => 'payload decrypt failed (sender secret mismatch?)'], 400); + $stored = clientEncrypt($plain, $interKey); + + // LWW: upsert with server-generated updated_at so clients all agree on + // the authoritative order (client clocks drift, server clock is the tie- + // breaker). Last writer wins. + $db->prepare(' + INSERT INTO collab_cards (bucket_uid, card_uid, author_username, payload, updated_at, deleted) + VALUES (:b, :c, :a, :p, datetime(\'now\'), 0) + ON CONFLICT(bucket_uid, card_uid) DO UPDATE SET + author_username = excluded.author_username, + payload = excluded.payload, + updated_at = excluded.updated_at, + deleted = 0 + ')->execute([ + 'b' => $bucketUid, + 'c' => $cardUid, + 'a' => $clientTenant['username'], + 'p' => $stored, + ]); + + // Return the row's updated_at so the client can track what it pushed + $row = $db->prepare('SELECT updated_at FROM collab_cards WHERE bucket_uid = :b AND card_uid = :c'); + $row->execute(['b' => $bucketUid, 'c' => $cardUid]); + jsonResponse(['ok' => true, 'updated_at' => $row->fetchColumn()]); +} + +// ── GET: pull deltas since a given timestamp ───────────────────── + +if ($route === '/api/collab-cards' && $method === 'GET') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + if (isRateLimited($db, 'collab-cards-get', 120)) jsonResponse(['error' => 'rate limited'], 429); + if (empty($clientTenant['secret_encrypted'])) { + jsonResponse(['error' => 'no client secret set — log into the relay UI and configure one in your account'], 400); + } + $since = trim((string)($_GET['since'] ?? '')); + + $recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY); + if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500); + $interKey = getInterTenantKey($db, $MASTER_KEY); + if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); + + // Join to membership so only buckets the caller is in right now are returned + $sql = ' + SELECT c.bucket_uid, c.card_uid, c.author_username, c.payload, c.updated_at, c.deleted + FROM collab_cards c + INNER JOIN collab_bucket_members m ON m.bucket_uid = c.bucket_uid + WHERE m.tenant_id = :tid + '; + $params = ['tid' => $clientTenant['id']]; + if ($since !== '') { + $sql .= ' AND c.updated_at > :since'; + $params['since'] = $since; + } + $sql .= ' ORDER BY c.updated_at ASC LIMIT 1000'; + $stmt = $db->prepare($sql); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + + $out = []; + $maxTs = $since; + foreach ($rows as $row) { + // Tombstones don't need payload — they just signal "delete locally" + if ((int)$row['deleted'] === 1) { + $out[] = [ + 'bucket_uid' => $row['bucket_uid'], + 'card_uid' => $row['card_uid'], + 'author_username' => $row['author_username'], + 'updated_at' => $row['updated_at'], + 'deleted' => true, + ]; + } else { + $plain = clientDecrypt($row['payload'] ?? '', $interKey); + $reEncrypted = clientEncrypt($plain, $recipientSecret); + $out[] = [ + 'bucket_uid' => $row['bucket_uid'], + 'card_uid' => $row['card_uid'], + 'author_username' => $row['author_username'], + 'payload' => $reEncrypted, + 'updated_at' => $row['updated_at'], + 'deleted' => false, + ]; + } + if ($row['updated_at'] > $maxTs) $maxTs = $row['updated_at']; + } + jsonResponse(['ok' => true, 'cards' => $out, 'max_updated_at' => $maxTs]); +} + +// ── DELETE: tombstone a card ───────────────────────────────────── + +if (preg_match('#^/api/collab-cards/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + $bucketUid = $m[1]; + $cardUid = $m[2]; + if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) { + jsonResponse(['error' => 'not a member of this bucket'], 403); + } + // Mark as tombstone rather than actually deleting. Late pollers see it, + // bootstrap.php cleanup prunes after 7 days. + $db->prepare(' + INSERT INTO collab_cards (bucket_uid, card_uid, author_username, payload, updated_at, deleted) + VALUES (:b, :c, :a, \'\', datetime(\'now\'), 1) + ON CONFLICT(bucket_uid, card_uid) DO UPDATE SET + author_username = excluded.author_username, + payload = \'\', + updated_at = datetime(\'now\'), + deleted = 1 + ')->execute([ + 'b' => $bucketUid, + 'c' => $cardUid, + 'a' => $clientTenant['username'], + ]); + jsonResponse(['ok' => true]); +} diff --git a/relay/inc/routes/collab-locks.php b/relay/inc/routes/collab-locks.php new file mode 100644 index 0000000..2e2396c --- /dev/null +++ b/relay/inc/routes/collab-locks.php @@ -0,0 +1,75 @@ +prepare('SELECT 1 FROM collab_bucket_members WHERE bucket_uid = :b AND tenant_id = :t'); + $stmt->execute(['b' => $bucketUid, 't' => $tenantId]); + return (bool)$stmt->fetchColumn(); +} + +// ── POST: acquire/refresh ──────────────────────────────────────── + +if ($route === '/api/collab-locks' && $method === 'POST') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + if (isRateLimited($db, 'collab-locks-post', 240)) jsonResponse(['error' => 'rate limited'], 429); + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $bucketUid = trim((string)($input['bucket_uid'] ?? '')); + $cardUid = trim((string)($input['card_uid'] ?? '')); + if ($bucketUid === '' || $cardUid === '') jsonResponse(['error' => 'bucket_uid and card_uid required'], 400); + if (!collabLocksIsMember($db, $bucketUid, (int)$clientTenant['id'])) { + jsonResponse(['error' => 'not a member of this bucket'], 403); + } + $db->prepare(" + INSERT INTO collab_card_locks (bucket_uid, card_uid, holder_id, holder_username, acquired_at, expires_at) + VALUES (:b, :c, :hid, :hu, datetime('now'), datetime('now', '+60 seconds')) + ON CONFLICT(bucket_uid, card_uid, holder_id) DO UPDATE SET + expires_at = datetime('now', '+60 seconds') + ")->execute([ + 'b' => $bucketUid, + 'c' => $cardUid, + 'hid' => $clientTenant['id'], + 'hu' => $clientTenant['username'], + ]); + jsonResponse(['ok' => true, 'expires_at' => (string)$db->query("SELECT datetime('now', '+60 seconds')")->fetchColumn()]); +} + +// ── DELETE: release own lock ───────────────────────────────────── + +if (preg_match('#^/api/collab-locks/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + $bucketUid = $m[1]; + $cardUid = $m[2]; + $db->prepare('DELETE FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c AND holder_id = :h') + ->execute(['b' => $bucketUid, 'c' => $cardUid, 'h' => $clientTenant['id']]); + jsonResponse(['ok' => true]); +} + +// ── GET: list locks visible to caller ──────────────────────────── + +if ($route === '/api/collab-locks' && $method === 'GET') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + if (isRateLimited($db, 'collab-locks-get', 120)) jsonResponse(['error' => 'rate limited'], 429); + // Only return locks in buckets the caller is currently a member of, + // and only non-expired rows (bootstrap cleanup + defensive filter). + $stmt = $db->prepare(" + SELECT l.bucket_uid, l.card_uid, l.holder_id, l.holder_username, l.acquired_at, l.expires_at + FROM collab_card_locks l + INNER JOIN collab_bucket_members m ON m.bucket_uid = l.bucket_uid + WHERE m.tenant_id = :tid + AND l.expires_at > datetime('now') + ORDER BY l.acquired_at DESC + "); + $stmt->execute(['tid' => $clientTenant['id']]); + jsonResponse(['ok' => true, 'locks' => $stmt->fetchAll()]); +} diff --git a/relay/index.php b/relay/index.php index 98a45df..e7c54fb 100644 --- a/relay/index.php +++ b/relay/index.php @@ -62,6 +62,9 @@ if (str_starts_with($route, '/api/')) { require __DIR__ . '/inc/routes/admin.php'; require __DIR__ . '/inc/routes/messages.php'; require __DIR__ . '/inc/routes/chat.php'; + require __DIR__ . '/inc/routes/collab-buckets.php'; + require __DIR__ . '/inc/routes/collab-cards.php'; + require __DIR__ . '/inc/routes/collab-locks.php'; jsonResponse(['error' => 'not found'], 404); } diff --git a/src/Database.php b/src/Database.php index 3096824..91aed10 100644 --- a/src/Database.php +++ b/src/Database.php @@ -139,5 +139,10 @@ class Database if (!in_array('color', $bcolNames)) { self::$pdo->exec("ALTER TABLE buckets ADD COLUMN color TEXT NOT NULL DEFAULT '#64748b'"); } + // Collaborative buckets: plugin-managed relay UID. NULL for local buckets. + if (!in_array('collab_uid', $bcolNames)) { + self::$pdo->exec('ALTER TABLE buckets ADD COLUMN collab_uid TEXT DEFAULT NULL'); + self::$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_buckets_collab_uid ON buckets(collab_uid) WHERE collab_uid IS NOT NULL'); + } } }