plugin(collab-buckets): collaborative buckets via relay
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
a29708df85
commit
26ffa0e71f
16 files changed
+3017
-13
No files matched your search
+28
-3
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 = `<div class="py-1">
|
||||
<button class="ctx-item" data-baction="rename"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">Rename</span></button>
|
||||
<button class="ctx-item" data-baction="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">Color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||
${isCollab && typeof window._collabBucketsEditDialog === 'function'
|
||||
? `<button class="ctx-item" data-baction="collab-manage"><i class="ph ph-users-three text-sm"></i><span class="font-pixel text-[10px]">Members & owner…</span></button>`
|
||||
: ''
|
||||
}
|
||||
${!isCollab && typeof window._collabBucketsUpgradeDialog === 'function'
|
||||
? `<button class="ctx-item" data-baction="collab-upgrade"><i class="ph ph-users-three text-sm"></i><span class="font-pixel text-[10px]">Make collaborative…</span></button>`
|
||||
: ''
|
||||
}
|
||||
<div style="height:1px;background:var(--t-border);margin:2px 0"></div>
|
||||
<button class="ctx-item ${!canLeft ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-left"><i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">Move left</span></button>
|
||||
<button class="ctx-item ${!canRight ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-right"><i class="ph ph-arrow-right text-sm"></i><span class="font-pixel text-[10px]">Move right</span></button>
|
||||
@@ -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() } });
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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=<ts>` | Pull cards updated since `<ts>` (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=<stored>` 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.
|
||||
@@ -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=<ts>`
|
||||
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 <author>) ...` 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: <username>}`. 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.
|
||||
@@ -0,0 +1,876 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Collab Buckets Plugin: backend.
|
||||
*
|
||||
* Local mirror of the relay's collab_buckets state. Provides a thin proxy
|
||||
* over the relay's /api/collab-buckets routes plus a poll endpoint that
|
||||
* does a full resync (so deletions / removed memberships propagate).
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/collab-buckets list local buckets
|
||||
* POST /api/collab-buckets {name, color, members[]} -> 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);
|
||||
}
|
||||
});
|
||||
@@ -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); }
|
||||
@@ -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, '"').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 = `
|
||||
<i class="ph ph-warning text-sm"></i>
|
||||
<span>${n} local change${n === 1 ? '' : 's'} overwritten by a collaborator — backup in the <b>Conflicts</b> bucket.</span>
|
||||
<button type="button" class="collab-conflict-close" aria-label="dismiss"><i class="ph ph-x text-sm"></i></button>
|
||||
`;
|
||||
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 = `
|
||||
<i class="ph ph-cloud-arrow-up text-sm"></i>
|
||||
<span>${escapeHtml(message)}</span>
|
||||
<button type="button" class="collab-conflict-close" aria-label="dismiss"><i class="ph ph-x text-sm"></i></button>
|
||||
`;
|
||||
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 = `
|
||||
<div class="collab-modal">
|
||||
<div class="collab-modal-header">
|
||||
<i class="ph ph-folder-plus text-t-accent"></i>
|
||||
<span class="font-pixel text-xs text-t-accent">new bucket</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
|
||||
</div>
|
||||
<div class="collab-modal-body">
|
||||
<label class="collab-field">
|
||||
<span class="collab-label">Name</span>
|
||||
<input type="text" class="collab-input" id="__cb_name" placeholder="bucket name...">
|
||||
</label>
|
||||
<label class="collab-field">
|
||||
<span class="collab-label">Color</span>
|
||||
<input type="color" class="collab-color" id="__cb_color" value="#64748b">
|
||||
</label>
|
||||
<label class="collab-field collab-toggle-row">
|
||||
<input type="checkbox" id="__cb_collab">
|
||||
<span>Collaborative — share with relay users</span>
|
||||
</label>
|
||||
<div class="collab-field collab-members-wrap hidden" id="__cb_members_wrap">
|
||||
<span class="collab-label">Members</span>
|
||||
<div class="collab-members" id="__cb_members_list">
|
||||
<div class="collab-members-empty">loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collab-modal-footer">
|
||||
<div class="collab-modal-status" id="__cb_status"></div>
|
||||
<div style="flex:1"></div>
|
||||
<button class="collab-btn-cancel" type="button">Cancel</button>
|
||||
<button class="collab-btn-submit" type="button">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 = '<div class="collab-members-empty">could not reach the relay — uncheck collaborative to create a local bucket</div>';
|
||||
return;
|
||||
}
|
||||
const self = relay.self || '';
|
||||
peers = (relay.tenants || []).filter(t => (t.username || t) !== self);
|
||||
if (!peers.length) {
|
||||
membersList.innerHTML = '<div class="collab-members-empty">no other relay users</div>';
|
||||
return;
|
||||
}
|
||||
membersList.innerHTML = peers.map(t => {
|
||||
const u = typeof t === 'string' ? t : (t.username || '');
|
||||
return `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}"><span>${escapeHtml(u)}</span></label>`;
|
||||
}).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 = `
|
||||
<div class="collab-modal">
|
||||
<div class="collab-modal-header">
|
||||
<i class="ph ph-users-three text-t-accent"></i>
|
||||
<span class="font-pixel text-xs text-t-accent">${escapeHtml(bucket.name)} — members</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
|
||||
</div>
|
||||
<div class="collab-modal-body">
|
||||
<div class="collab-field">
|
||||
<span class="collab-label">Owner</span>
|
||||
${isOwner
|
||||
? `<select class="collab-input" id="__cb_owner">
|
||||
<option value="${escapeHtml(self)}">${escapeHtml(self)} (you)</option>
|
||||
${[...currentMembers].sort().map(u => `<option value="${escapeHtml(u)}">${escapeHtml(u)}</option>`).join('')}
|
||||
</select>
|
||||
<div class="collab-hint">Transferring will hand over all edit rights. You stay a member.</div>`
|
||||
: `<div class="collab-readonly">${escapeHtml(bucket.owner_username)}</div>`
|
||||
}
|
||||
</div>
|
||||
<div class="collab-field">
|
||||
<span class="collab-label">Members (${peers.length} available)</span>
|
||||
${isOwner
|
||||
? `<div class="collab-members" id="__cb_edit_members">
|
||||
${peers.map(u => {
|
||||
const checked = currentMembers.has(u) ? 'checked' : '';
|
||||
return `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}" ${checked}><span>${escapeHtml(u)}</span></label>`;
|
||||
}).join('') || '<div class="collab-members-empty">no other relay users</div>'}
|
||||
</div>`
|
||||
: `<div class="collab-members">
|
||||
${(bucket.members || []).map(u => `<div class="collab-member-row"><span>${escapeHtml(u)}${u === self ? ' (you)' : ''}</span></div>`).join('')}
|
||||
</div>
|
||||
<div class="collab-hint">Only the owner can change members or transfer ownership.</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="collab-modal-footer">
|
||||
<div class="collab-modal-status" id="__cb_status"></div>
|
||||
<div style="flex:1"></div>
|
||||
<button class="collab-btn-cancel" type="button">${isOwner ? 'Cancel' : 'Close'}</button>
|
||||
${isOwner ? `<button class="collab-btn-submit" type="button">Save</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="collab-modal">
|
||||
<div class="collab-modal-header">
|
||||
<i class="ph ph-users-three text-t-accent"></i>
|
||||
<span class="font-pixel text-xs text-t-accent">make "${escapeHtml(bucket.name)}" collaborative</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
|
||||
</div>
|
||||
<div class="collab-modal-body">
|
||||
<div class="collab-field">
|
||||
<div class="collab-hint">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.</div>
|
||||
</div>
|
||||
<div class="collab-field">
|
||||
<span class="collab-label">Members (${peers.length} available)</span>
|
||||
${peers.length === 0
|
||||
? '<div class="collab-members-empty">no other relay users</div>'
|
||||
: `<div class="collab-members" id="__cb_up_members">
|
||||
${peers.map(u => `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}"><span>${escapeHtml(u)}</span></label>`).join('')}
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="collab-modal-footer">
|
||||
<div class="collab-modal-status" id="__cb_status"></div>
|
||||
<div style="flex:1"></div>
|
||||
<button class="collab-btn-cancel" type="button">Cancel</button>
|
||||
<button class="collab-btn-submit" type="button">Make collaborative</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 = '<i class="ph ph-lock-simple text-xs"></i>';
|
||||
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.',
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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')");
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
// Collab bucket routes:
|
||||
// POST /api/collab-buckets create {uid, name, color, members: [usernames]}
|
||||
// GET /api/collab-buckets list buckets where the caller is a member
|
||||
// PATCH /api/collab-buckets/{uid} owner-only: update name / color / members
|
||||
// DELETE /api/collab-buckets/{uid} owner: delete bucket; non-owner: leave
|
||||
//
|
||||
// No body encryption on buckets — bucket metadata (name, color) is lightweight
|
||||
// shared data. Cards inside the buckets (Phase 3) follow the chat decrypt-
|
||||
// reencrypt-per-recipient pattern so content stays private on the relay.
|
||||
|
||||
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
$clientTenant = $clientTenant ?? authClient($db);
|
||||
|
||||
// ── POST: create ──────────────────────────────────────────────────
|
||||
|
||||
if ($route === '/api/collab-buckets' && $method === 'POST') {
|
||||
if (!$clientTenant) jsonResponse(['error' => '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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
// Collab card routes (Phase 3):
|
||||
// POST /api/collab-cards upsert a card in a collab bucket
|
||||
// GET /api/collab-cards?since=<ts> 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]);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
// Collab card lock routes (Phase 4 — advisory edit locks):
|
||||
// POST /api/collab-locks acquire/refresh a lock
|
||||
// DELETE /api/collab-locks/{bucket}/{card} release own lock
|
||||
// GET /api/collab-locks list locks visible to caller
|
||||
//
|
||||
// Membership is enforced on every operation. A lock refresh just extends
|
||||
// the TTL (60 seconds from now). Expired rows are pruned on every request
|
||||
// via bootstrap.php cleanup, so the GET list never contains stale locks.
|
||||
|
||||
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
$clientTenant = $clientTenant ?? authClient($db);
|
||||
|
||||
function collabLocksIsMember(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: 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()]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user